mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-27 10:15:21 +02:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0569cc7f5 | |||
| 618aee9c2c | |||
| bda534e485 | |||
| 09fd4c0881 | |||
| 1667866a35 | |||
| 035125d0f8 | |||
| 1bb0cdc405 | |||
| 86019c80a1 | |||
| 8c640d3def | |||
| 7ed1e8a28b | |||
| 3dcfe8c340 | |||
| 042ced81ef | |||
| b37f48380b | |||
| 0a02169782 | |||
| f12e4390f3 | |||
| 82ab45d04e | |||
| 7f77c39296 | |||
| 99eee4f6ee | |||
| 68886502d1 | |||
| 26461c21c4 | |||
| 300466f722 | |||
| 961710cc8b | |||
| eba995f87d | |||
| a67244e79a | |||
| 70502a7651 | |||
| 36b4f5b41d | |||
| 2142bcba25 | |||
| def39ba397 | |||
| 49d59f4466 | |||
| 1c9becc2ba | |||
| f7a07c1fcd | |||
| 1cde591061 | |||
| 8ac18f053c | |||
| 56bdae9ff1 | |||
| 74ddfe9f0e | |||
| acb9500e2a | |||
| 45f621763a | |||
| 0abc65a9bd | |||
| 6d6309973e | |||
| 92ec085d25 | |||
| 767a8befaa | |||
| 09763320dd | |||
| 27fb2997f9 | |||
| 0f46bc5888 | |||
| dccf4fcf3c | |||
| da7fef1ecd | |||
| 58a89a00ef | |||
| f2efc603ba | |||
| efe074d272 | |||
| 8a9efd3a0f | |||
| 251302b9c3 | |||
| 5cdac1405e | |||
| 565ea7cb8b | |||
| 9fa3e22d2e | |||
| 5548783337 | |||
| 0dca8798cb | |||
| d902306fe4 | |||
| baa2a4fcf3 | |||
| 8be7ad9f68 | |||
| 992cbcb3a0 | |||
| e0857aea9b | |||
| 50cd0723c9 | |||
| 4c4b322682 | |||
| 7cff8568c0 | |||
| 801c646a09 | |||
| df4ec87613 | |||
| b08a79b7cb | |||
| 396e9f9f43 | |||
| 0e5a87a911 | |||
| 64d72f6d10 | |||
| 5b40da109b | |||
| 949294952f | |||
| 40a058e369 | |||
| a070d78dd9 | |||
| 105ac538bb | |||
| ce2029774e | |||
| 50c63d7e8d | |||
| d3534080d7 | |||
| b5025193a5 | |||
| 3f85b7ed78 | |||
| 98d008ef6c | |||
| 20eb53fc38 | |||
| 1ea7b307fa | |||
| f18571e0b2 | |||
| 70872d429a | |||
| cbf3db6e30 | |||
| 0be0dcfadc | |||
| abd226c33d | |||
| 89dbdc99a0 | |||
| f89ed18a49 | |||
| 77ac2b537c | |||
| 8ab03b6b66 | |||
| dad70e57c6 | |||
| eb9c6c8330 | |||
| 68da797f4d | |||
| 25948dd296 | |||
| 10d39d6ed1 | |||
| 85e8e674dd | |||
| 0d70392bf0 | |||
| f89b074d28 | |||
| ee2af411aa | |||
| 9ffbe6dd03 | |||
| 0ae6ac2fac | |||
| fd835cc54e | |||
| 07f3140038 | |||
| 3e753d70de | |||
| d578c47975 | |||
| b7a61425ca | |||
| 727f977672 | |||
| fc9d5eeb27 | |||
| fc2aba0120 | |||
| e4f51bb130 | |||
| 9e9d26c752 | |||
| 5c5dd3af44 | |||
| d6468ba283 | |||
| 3b359ad4a7 | |||
| 62a2f42d68 | |||
| 7c70e58129 |
@@ -64,12 +64,6 @@
|
||||
[submodule "app/src/stable/assets/sources/bilibili"]
|
||||
path = app/src/stable/assets/sources/bilibili
|
||||
url = ../plugins/bilibili.git
|
||||
[submodule "app/src/stable/assets/sources/spotify"]
|
||||
path = app/src/stable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
[submodule "app/src/unstable/assets/sources/spotify"]
|
||||
path = app/src/unstable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
[submodule "app/src/stable/assets/sources/bitchute"]
|
||||
path = app/src/stable/assets/sources/bitchute
|
||||
url = ../plugins/bitchute.git
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
|
||||
size 65512557
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
|
||||
size 36133152
|
||||
+1
-1
@@ -181,7 +181,7 @@ dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||
|
||||
//JS
|
||||
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
|
||||
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||
|
||||
//Exoplayer
|
||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FutoVideo"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:replace="android:enableOnBackInvokedCallback"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
tools:targetApi="31"
|
||||
android:largeHeap="true">
|
||||
<provider
|
||||
@@ -61,6 +63,7 @@
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:windowSoftInputMode="adjustPan"
|
||||
android:launchMode="singleInstance"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true">
|
||||
@@ -245,5 +248,25 @@
|
||||
android:name=".activities.PolycentricModerationActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name=".activities.QRCodeFullscreenActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<service
|
||||
android:name=".UpdateDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<receiver
|
||||
android:name=".UpdateActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.InstallUpdateActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.App.TransparentNoUi"
|
||||
android:excludeFromRecents="true"
|
||||
android:finishOnTaskLaunch="true" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -429,6 +429,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
6 -> 1.75f;
|
||||
7 -> 2.0f;
|
||||
8 -> 2.25f;
|
||||
9 -> 2.5f;
|
||||
10 -> 2.75f;
|
||||
11 -> 3.0f;
|
||||
else -> 1.0f;
|
||||
};
|
||||
|
||||
@@ -725,7 +728,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@AdvancedField
|
||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var experimentalCasting: Boolean = false
|
||||
var experimentalCasting: Boolean = true
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@@ -872,9 +875,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
||||
var check: Int = 0;
|
||||
|
||||
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
|
||||
@DropdownFieldOptionsId(R.array.background_download)
|
||||
var backgroundDownload: Int = 0;
|
||||
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
|
||||
//@DropdownFieldOptionsId(R.array.background_download)
|
||||
var shouldBackgroundDownload: Boolean = false;
|
||||
|
||||
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
||||
@DropdownFieldOptionsId(R.array.when_download)
|
||||
@@ -1049,6 +1052,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||
var polycentricLocalCache: Boolean = true;
|
||||
|
||||
var showPrivacyModeDialog: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||
|
||||
@@ -370,17 +370,19 @@ class UIDialogs {
|
||||
}
|
||||
|
||||
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
|
||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
||||
return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
|
||||
setOnDismissListener { dismissAction?.invoke() }
|
||||
}
|
||||
}
|
||||
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
|
||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||
return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||
}
|
||||
|
||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||
@@ -403,13 +405,6 @@ class UIDialogs {
|
||||
dialog.setMaxVersion(lastVersion);
|
||||
}
|
||||
|
||||
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
|
||||
val dialog = AutoUpdateDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.showPredownloaded(apkFile);
|
||||
}
|
||||
|
||||
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
||||
if(!store.hasMissingReconstructions())
|
||||
onConcluded();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import java.io.File
|
||||
|
||||
class UpdateActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
|
||||
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
|
||||
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
|
||||
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdateYes(context: Context, intent: Intent) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
if (version == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||
|
||||
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
}
|
||||
|
||||
private fun handleUpdateNo(context: Context) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
|
||||
}
|
||||
|
||||
private fun handleUpdateNever(context: Context) {
|
||||
AutoUpdateDialog.currentDialog?.dismiss()
|
||||
Settings.instance.autoUpdate.check = 1
|
||||
Settings.instance.save()
|
||||
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
}
|
||||
|
||||
private fun handleDownloadCancel(context: Context, intent: Intent) {
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
|
||||
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
|
||||
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, cancelIntent)
|
||||
|
||||
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||
Logger.i(TAG, "Auto-update disabled, skipping worker run")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val client = ManagedHttpClient()
|
||||
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
|
||||
|
||||
if (latestVersion == null) {
|
||||
Logger.w(TAG, "Failed to fetch latest version in worker")
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
val currentVersion = BuildConfig.VERSION_CODE
|
||||
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
|
||||
|
||||
if (latestVersion <= currentVersion) {
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
|
||||
|
||||
if (StateApp.instance.isMainActive) {
|
||||
withContext(Dispatchers.Main) {
|
||||
StateApp.withContext { ctx ->
|
||||
try {
|
||||
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Result.success()
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UpdateCheckWorker"
|
||||
const val UNIQUE_WORK_NAME = "updateCheck"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.app.Dialog
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class UpdateDownloadService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UpdateDownloadService"
|
||||
const val EXTRA_VERSION = "version"
|
||||
const val EXTRA_CANCEL = "cancel"
|
||||
private const val MAX_RETRIES = 5
|
||||
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||
private const val BUFFER_SIZE = 8 * 1024
|
||||
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
||||
|
||||
var updateDownloadedDialog: Dialog? = null
|
||||
}
|
||||
|
||||
private val job = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||
|
||||
@Volatile
|
||||
private var isDownloading: Boolean = false
|
||||
|
||||
@Volatile
|
||||
private var cancelRequested: Boolean = false
|
||||
|
||||
private var lastProgressUpdateElapsedMs: Long = 0L
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
|
||||
cancelRequested = true
|
||||
Logger.i(TAG, "Download cancel requested")
|
||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val version = intent.getIntExtra(EXTRA_VERSION, 0)
|
||||
if (version == 0) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (isDownloading) {
|
||||
Logger.i(TAG, "Download already in progress, ignoring new start")
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
cancelRequested = false
|
||||
|
||||
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
|
||||
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
|
||||
|
||||
scope.launch {
|
||||
downloadApk(version)
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val force = progress == 100 && !indeterminate
|
||||
|
||||
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||
lastProgressUpdateElapsedMs = now
|
||||
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadApk(version: Int) {
|
||||
val apkFile = StateUpdate.getApkFile(this, version)
|
||||
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||
|
||||
try {
|
||||
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||
onDownloadComplete(version, apkFile)
|
||||
return
|
||||
}
|
||||
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
|
||||
for (attempt in 0 until MAX_RETRIES) {
|
||||
if (cancelRequested) {
|
||||
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
performDownload(StateUpdate.APK_URL, partialFile, version)
|
||||
|
||||
if (!cancelRequested) {
|
||||
if (apkFile.exists()) {
|
||||
apkFile.delete()
|
||||
}
|
||||
if (!partialFile.renameTo(apkFile)) {
|
||||
throw IllegalStateException("Failed to rename partial APK file")
|
||||
}
|
||||
onDownloadComplete(version, apkFile)
|
||||
}
|
||||
break
|
||||
} catch (t: Throwable) {
|
||||
if (cancelRequested) {
|
||||
Logger.i(TAG, "Download cancelled by user", t)
|
||||
break
|
||||
}
|
||||
|
||||
if (attempt == MAX_RETRIES - 1) {
|
||||
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
|
||||
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
|
||||
break
|
||||
} else {
|
||||
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
|
||||
delay(backoffMs)
|
||||
backoffMs *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDownloading = false
|
||||
cancelRequested = false
|
||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun performDownload(url: String, partialFile: File, version: Int) {
|
||||
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
if (startOffset > 0L) {
|
||||
setRequestProperty("Range", "bytes=$startOffset-")
|
||||
}
|
||||
}
|
||||
|
||||
connection.connect()
|
||||
val responseCode = connection.responseCode
|
||||
|
||||
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
|
||||
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
|
||||
partialFile.delete()
|
||||
startOffset = 0L
|
||||
} else if (responseCode != HttpURLConnection.HTTP_OK &&
|
||||
responseCode != HttpURLConnection.HTTP_PARTIAL) {
|
||||
throw IllegalStateException("Unexpected HTTP response code $responseCode")
|
||||
}
|
||||
|
||||
val contentLength = connection.contentLengthLong
|
||||
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
|
||||
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var downloaded = 0L
|
||||
var lastProgress = -1
|
||||
|
||||
connection.inputStream.use { input ->
|
||||
FileOutputStream(partialFile, startOffset > 0L).use { output ->
|
||||
while (!cancelRequested) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) {
|
||||
break
|
||||
}
|
||||
output.write(buffer, 0, read)
|
||||
downloaded += read
|
||||
|
||||
if (totalBytes > 0L) {
|
||||
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
|
||||
if (progress != lastProgress) {
|
||||
lastProgress = progress
|
||||
val safeProgress = when {
|
||||
progress < 0 -> 0
|
||||
progress > 100 -> 100
|
||||
else -> progress
|
||||
}
|
||||
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
|
||||
}
|
||||
} else {
|
||||
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelRequested && totalBytes > 0L) {
|
||||
val finalProgress = 100
|
||||
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
|
||||
}
|
||||
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelRequested) {
|
||||
throw CancellationException("Download cancelled")
|
||||
}
|
||||
|
||||
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
|
||||
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
|
||||
}
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDownloadComplete(version: Int, apkFile: File) {
|
||||
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
|
||||
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
|
||||
|
||||
if (StateApp.instance.isMainActive) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
StateApp.withContext { ctx ->
|
||||
try {
|
||||
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
|
||||
"Update downloaded",
|
||||
"Would you like to install it now?", null, 0,
|
||||
UIDialogs.Action("Not now", {
|
||||
updateDownloadedDialog = null
|
||||
}, ActionStyle.NONE, true),
|
||||
UIDialogs.Action("Install", {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||
}, ActionStyle.PRIMARY, true));
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||
updateDownloadedDialog = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent.FLAG_MUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.PendingIntent.getBroadcast
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.receivers.InstallReceiver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
||||
object UpdateInstaller {
|
||||
private const val TAG = "UpdateInstaller"
|
||||
|
||||
@SuppressLint("RequestInstallPackagesPolicy")
|
||||
fun startInstall(context: Context, version: Int, apkFile: File) {
|
||||
if (!apkFile.exists()) {
|
||||
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
||||
UIDialogs.toast(context, "Update file missing")
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
|
||||
return
|
||||
}
|
||||
|
||||
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
UIDialogs.toast(context, "Updates are managed by the Play Store")
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
if (!pm.canRequestPackageInstalls()) {
|
||||
UIDialogs.toast(context, "Allow this app to install updates, then try again")
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
|
||||
|
||||
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = "package:${context.packageName}".toUri()
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
return
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Logger.e(TAG, "Failed to check unknown sources permission", t)
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var inputStream: InputStream? = null
|
||||
var session: PackageInstaller.Session? = null
|
||||
try {
|
||||
|
||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
val sessionId = packageInstaller.createSession(params)
|
||||
session = packageInstaller.openSession(sessionId)
|
||||
|
||||
inputStream = apkFile.inputStream()
|
||||
val dataLength = apkFile.length()
|
||||
|
||||
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
||||
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
|
||||
session.fsync(sessionStream)
|
||||
}
|
||||
|
||||
val intent = Intent(context, InstallReceiver::class.java).apply {
|
||||
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
|
||||
}
|
||||
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||
val statusReceiver = pendingIntent.intentSender
|
||||
|
||||
InstallReceiver.onReceiveResult.subscribe(this) { message ->
|
||||
InstallReceiver.onReceiveResult.clear();
|
||||
onReceiveResult(context, version, apkFile, message);
|
||||
};
|
||||
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
|
||||
session.commit(statusReceiver)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Exception while installing update", e)
|
||||
session?.abandon()
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to install update: ${e.message}")
|
||||
}
|
||||
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
|
||||
} finally {
|
||||
session?.close()
|
||||
inputStream?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
|
||||
try {
|
||||
InstallReceiver.onReceiveResult.remove(this)
|
||||
|
||||
if (result.isNullOrEmpty()) {
|
||||
Logger.i(TAG, "Update install finished successfully")
|
||||
UpdateNotificationManager.showInstallSucceededNotification(context, version)
|
||||
} else {
|
||||
Logger.w(TAG, "Update install failed: $result")
|
||||
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
|
||||
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle install result", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_MUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.PendingIntent.getBroadcast
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.activities.InstallUpdateActivity
|
||||
import java.io.File
|
||||
|
||||
object UpdateNotificationManager {
|
||||
private const val CHANNEL_ID = "app_updates"
|
||||
private const val CHANNEL_NAME = "App updates"
|
||||
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
|
||||
|
||||
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
|
||||
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
|
||||
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
|
||||
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
||||
private const val REQUEST_CODE_INSTALL = 1001
|
||||
|
||||
const val EXTRA_VERSION = "version"
|
||||
const val EXTRA_APK_PATH = "apk_path"
|
||||
|
||||
const val NOTIF_ID_AVAILABLE = 2001
|
||||
const val NOTIF_ID_DOWNLOADING = 2002
|
||||
const val NOTIF_ID_READY = 2003
|
||||
const val NOTIF_ID_INSTALL_FAILED = 2004
|
||||
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = CHANNEL_DESCRIPTION
|
||||
enableVibration(false)
|
||||
enableLights(false)
|
||||
setSound(null, null)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun showInstallSucceededNotification(context: Context, version: Int) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureChannel(context)
|
||||
|
||||
val launchIntent = context.packageManager
|
||||
.getLaunchIntentForPackage(context.packageName)
|
||||
?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||
}
|
||||
|
||||
val launchPendingIntent = launchIntent?.let {
|
||||
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Update installed")
|
||||
.setContentText("Version $version installed. Tap to open.")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
|
||||
if (launchPendingIntent != null) {
|
||||
builder.setContentIntent(launchPendingIntent)
|
||||
builder.addAction(0, "Open app", launchPendingIntent)
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
|
||||
}
|
||||
|
||||
fun showUpdateAvailableNotification(context: Context, version: Int) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureChannel(context)
|
||||
|
||||
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||
action = ACTION_UPDATE_YES
|
||||
putExtra(EXTRA_VERSION, version)
|
||||
}
|
||||
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||
action = ACTION_UPDATE_NO
|
||||
putExtra(EXTRA_VERSION, version)
|
||||
}
|
||||
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||
action = ACTION_UPDATE_NEVER
|
||||
putExtra(EXTRA_VERSION, version)
|
||||
}
|
||||
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Update available")
|
||||
.setContentText("A new version ($version) is available.")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(yesPendingIntent)
|
||||
.setSilent(true)
|
||||
.addAction(0, "Never", neverPendingIntent)
|
||||
.addAction(0, "Not now", noPendingIntent)
|
||||
.addAction(0, "Download", yesPendingIntent)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
|
||||
}
|
||||
|
||||
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
|
||||
ensureChannel(context)
|
||||
|
||||
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||
action = ACTION_DOWNLOAD_CANCEL
|
||||
putExtra(EXTRA_VERSION, version)
|
||||
}
|
||||
val cancelPendingIntent = getBroadcast(
|
||||
context,
|
||||
3,
|
||||
cancelIntent,
|
||||
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Downloading update")
|
||||
.setContentText("Downloading version $version")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.addAction(0, "Cancel", cancelPendingIntent)
|
||||
|
||||
if (indeterminate) {
|
||||
builder.setProgress(0, 0, true)
|
||||
} else {
|
||||
builder.setProgress(100, progress, false)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
|
||||
}
|
||||
|
||||
|
||||
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
ensureChannel(context)
|
||||
|
||||
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Update downloaded")
|
||||
.setContentText("Tap to install version $version.")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(installPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
.addAction(0, "Install", installPendingIntent)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||
}
|
||||
|
||||
|
||||
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
ensureChannel(context)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Failed to download update")
|
||||
.setContentText(error?.message ?: "Unknown error")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||
}
|
||||
|
||||
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
|
||||
return
|
||||
|
||||
ensureChannel(context)
|
||||
|
||||
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.foreground)
|
||||
.setContentTitle("Failed to install update")
|
||||
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
.setContentIntent(installPendingIntent)
|
||||
.addAction(0, "Install again", installPendingIntent)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
|
||||
}
|
||||
|
||||
fun cancelAll(context: Context) {
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
|
||||
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.icu.util.Output
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.os.OperationCanceledException
|
||||
@@ -44,6 +42,9 @@ import java.util.*
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import androidx.core.graphics.scale
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
|
||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||
fun getRandomString(sizeOfRandomString: Int): String {
|
||||
@@ -101,7 +102,7 @@ fun String.isHexColor(): Boolean {
|
||||
|
||||
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
||||
|
||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||
|
||||
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
|
||||
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
||||
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
|
||||
it.flush();
|
||||
};
|
||||
|
||||
fun loadBitmap(url: String): Bitmap {
|
||||
try {
|
||||
val client = ManagedHttpClient();
|
||||
val response = client.get(url);
|
||||
if (response.isOk && response.body != null) {
|
||||
val bitmapStream = response.body.byteStream();
|
||||
val bitmap = BitmapFactory.decodeStream(bitmapStream);
|
||||
return bitmap;
|
||||
} else {
|
||||
throw Exception("Failed to find data at URL.");
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
||||
this.movementMethod = PlatformLinkMovementMethod(context);
|
||||
}
|
||||
@@ -458,4 +442,11 @@ fun addressScore(addr: InetAddress): Int {
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
||||
|
||||
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
|
||||
return this;
|
||||
//.downsample(DownsampleStrategy.AT_MOST)
|
||||
//.override(maxSizePx, maxSizePx)
|
||||
//.centerInside()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UpdateInstaller
|
||||
import com.futo.platformplayer.UpdateNotificationManager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.io.File
|
||||
|
||||
class InstallUpdateActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
UpdateNotificationManager.cancelAll(this)
|
||||
|
||||
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
|
||||
|
||||
if (version == 0 || apkPath.isNullOrEmpty()) {
|
||||
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val apkFile = File(apkPath)
|
||||
if (!apkFile.exists()) {
|
||||
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
|
||||
UIDialogs.Companion.toast(this, "Update file missing")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
UpdateInstaller.startInstall(this, version, apkFile)
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
|
||||
Intent(context, InstallUpdateActivity::class.java).apply {
|
||||
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -33,7 +34,6 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.curlbind.Libcurl
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.RootInsetsController
|
||||
@@ -67,6 +67,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
|
||||
@@ -202,6 +203,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||
lateinit var _fragSettings: SettingsFragment;
|
||||
lateinit var _fragDeveloper: DeveloperFragment;
|
||||
lateinit var _fragLogin: LoginFragment;
|
||||
|
||||
lateinit var _fragBrowser: BrowserFragment;
|
||||
|
||||
@@ -210,7 +212,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
//State
|
||||
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
||||
lateinit var fragCurrent: MainFragment private set;
|
||||
var fragCurrent: MainFragment? = null; private set;
|
||||
private var _parameterCurrent: Any? = null;
|
||||
|
||||
var fragBeforeOverlay: MainFragment? = null; private set;
|
||||
@@ -243,17 +245,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
}
|
||||
private val _notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
private val _notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
if (isGranted)
|
||||
UIDialogs.toast(this, "Notification permission granted");
|
||||
else
|
||||
UIDialogs.toast(this, "Notification permission denied");
|
||||
};
|
||||
|
||||
fun requestNotificationPermissions() {
|
||||
_notificationPermissionLauncher?.launch(_notifPermission);
|
||||
}
|
||||
|
||||
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
||||
|
||||
@@ -329,6 +320,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
//Preload common files to memory
|
||||
FragmentedStorage.get<SubscriptionStorage>();
|
||||
FragmentedStorage.get<Settings>();
|
||||
@@ -396,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||
_fragSettings = SettingsFragment.newInstance();
|
||||
_fragDeveloper = DeveloperFragment.newInstance();
|
||||
_fragLogin = LoginFragment.newInstance();
|
||||
|
||||
_fragBrowser = BrowserFragment.newInstance();
|
||||
|
||||
@@ -414,12 +410,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
updateSegmentPaddings();
|
||||
};
|
||||
_fragVideoDetail.onTransitioning.subscribe {
|
||||
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
|
||||
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) {
|
||||
Logger.i(TAG, "onTransition Setting elevation higher");
|
||||
_fragContainerOverlay.elevation =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
||||
else
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "onTransition Setting elevation lower");
|
||||
_fragContainerOverlay.elevation =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_fragVideoDetail.onCloseEvent.subscribe {
|
||||
@@ -562,7 +563,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
defaultTab.action(_fragBotBarMenu);
|
||||
StateSubscriptions.instance;
|
||||
|
||||
fragCurrent.onShown(null, false);
|
||||
fragCurrent?.onShown(null, false);
|
||||
|
||||
//Other stuff
|
||||
rootView.progress = 0f;
|
||||
@@ -617,6 +618,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
|
||||
}
|
||||
|
||||
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
||||
|
||||
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
||||
@@ -1149,7 +1154,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
||||
return;
|
||||
|
||||
if (!fragCurrent.onBackPressed())
|
||||
if (!(fragCurrent?.onBackPressed() ?: true))
|
||||
closeSegment();
|
||||
}
|
||||
|
||||
@@ -1200,6 +1205,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Fragment> navigate(parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
|
||||
val segment = getFragment<T>();
|
||||
navigate(segment as MainFragment, parameter, withHistory, isBack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||
@@ -1222,27 +1232,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
return;
|
||||
}
|
||||
|
||||
fragCurrent.onHide();
|
||||
fragCurrent?.onHide();
|
||||
|
||||
if (segment.isMainView) {
|
||||
var transaction = supportFragmentManager.beginTransaction();
|
||||
if (segment.topBar != null) {
|
||||
if (segment.topBar != fragCurrent.topBar) {
|
||||
if (segment.topBar != fragCurrent?.topBar) {
|
||||
transaction = transaction
|
||||
.show(segment.topBar as Fragment)
|
||||
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
|
||||
fragCurrent.topBar?.onHide();
|
||||
fragCurrent?.topBar?.onHide();
|
||||
}
|
||||
} else if (fragCurrent.topBar != null)
|
||||
transaction.hide(fragCurrent.topBar as Fragment);
|
||||
} else if (fragCurrent?.topBar != null)
|
||||
transaction.hide(fragCurrent?.topBar as Fragment);
|
||||
|
||||
transaction = transaction.replace(R.id.fragment_main, segment);
|
||||
|
||||
if (segment.hasBottomBar) {
|
||||
if (!fragCurrent.hasBottomBar)
|
||||
if (!(fragCurrent?.hasBottomBar ?: false))
|
||||
transaction = transaction.show(_fragBotBarMenu);
|
||||
} else {
|
||||
if (fragCurrent.hasBottomBar)
|
||||
if (fragCurrent?.hasBottomBar ?: false)
|
||||
transaction = transaction.hide(_fragBotBarMenu);
|
||||
}
|
||||
transaction.commitNow();
|
||||
@@ -1255,10 +1265,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||
_queue.add(Pair(fragCurrent, _parameterCurrent));
|
||||
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
|
||||
|
||||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
||||
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
|
||||
fragBeforeOverlay = fragCurrent;
|
||||
|
||||
fragCurrent = segment;
|
||||
@@ -1289,11 +1299,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
navigate(last.first, last.second, false, true);
|
||||
} else {
|
||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
|
||||
finish();
|
||||
} else {
|
||||
//UIDialogs.toast("Grayjay continues in background because of an open video.")
|
||||
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
|
||||
try {
|
||||
_fragVideoDetail._viewDetail?.startPictureInPicture();
|
||||
_fragVideoDetail?.forcePictureInPicture();
|
||||
} catch (ex: Throwable) {
|
||||
} //Fail silently
|
||||
}
|
||||
else
|
||||
moveTaskToBack(false);
|
||||
/*
|
||||
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||
finish();
|
||||
})
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1347,6 +1370,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||
SettingsFragment:: class -> _fragSettings as T;
|
||||
DeveloperFragment::class -> _fragDeveloper as T;
|
||||
LoginFragment::class -> _fragLogin as T;
|
||||
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
|
||||
}
|
||||
}
|
||||
@@ -1354,7 +1378,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
private fun updateSegmentPaddings() {
|
||||
var paddingBottom = 0f;
|
||||
if (fragCurrent.hasBottomBar)
|
||||
if (fragCurrent?.hasBottomBar ?: false)
|
||||
paddingBottom += HEIGHT_MENU_DP;
|
||||
|
||||
_fragContainerOverlay.setPadding(
|
||||
@@ -1371,6 +1395,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
);
|
||||
}
|
||||
|
||||
var _callbackPermissionAudio: ((Boolean)->Unit)? = null;
|
||||
var _callbackPermissionVideo: ((Boolean)->Unit)? = null;
|
||||
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
_callbackPermissionAudio?.invoke(isGranted);
|
||||
});
|
||||
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
_callbackPermissionVideo?.invoke(isGranted);
|
||||
});
|
||||
fun requestPermissionAudio(cb: ((Boolean)->Unit)? = null) {
|
||||
_callbackPermissionAudio = cb;
|
||||
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
||||
}
|
||||
fun requestPermissionVideo(cb: ((Boolean)->Unit)? = null) {
|
||||
_callbackPermissionVideo = cb;
|
||||
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
||||
}
|
||||
|
||||
|
||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
|
||||
@@ -13,15 +13,18 @@ import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.SignedEvent
|
||||
import com.futo.polycentric.core.StorageTypeCRDTItem
|
||||
@@ -29,8 +32,10 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.toBase64Url
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
|
||||
class PolycentricBackupActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonShare: BigButton;
|
||||
private lateinit var _buttonCopy: BigButton;
|
||||
private lateinit var _buttonExportFile: BigButton;
|
||||
private lateinit var _imageQR: ImageView;
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
private lateinit var _textQRHint: TextView;
|
||||
private lateinit var _loader: View
|
||||
|
||||
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||
uri?.let { fileUri ->
|
||||
try {
|
||||
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
|
||||
outputStream.write(_exportBundle.toByteArray())
|
||||
}
|
||||
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to write to document", e)
|
||||
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
|
||||
_buttonShare = findViewById(R.id.button_share)
|
||||
_buttonCopy = findViewById(R.id.button_copy)
|
||||
_buttonExportFile = findViewById(R.id.button_export_file)
|
||||
_imageQR = findViewById(R.id.image_qr)
|
||||
_textQR = findViewById(R.id.text_qr)
|
||||
_textQRHint = findViewById(R.id.text_qr_hint)
|
||||
_loader = findViewById(R.id.progress_loader)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_textQRHint.visibility = View.INVISIBLE
|
||||
_loader.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
_buttonExportFile.visibility = View.INVISIBLE
|
||||
|
||||
lifecycleScope.launch {
|
||||
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
|
||||
_exportBundle = bundle
|
||||
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
|
||||
|
||||
try {
|
||||
val pair = withContext(Dispatchers.IO) {
|
||||
val bundle = createExportBundle()
|
||||
if (!isContentSuitableForQRCode(bundle)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val dimension = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
||||
).toInt()
|
||||
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
Pair(bundle, qr)
|
||||
}
|
||||
|
||||
_exportBundle = pair.first
|
||||
_imageQR.setImageBitmap(pair.second)
|
||||
_imageQR.visibility = View.VISIBLE
|
||||
_textQR.visibility = View.VISIBLE
|
||||
_textQRHint.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.VISIBLE
|
||||
_buttonCopy.visibility = View.VISIBLE
|
||||
|
||||
_imageQR.setOnClickListener {
|
||||
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
|
||||
val byteSize = bundle.toByteArray(Charsets.UTF_8).size
|
||||
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
|
||||
|
||||
if (e.message?.contains("Data too big") == true) {
|
||||
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
|
||||
_buttonExportFile.visibility = View.VISIBLE
|
||||
} else {
|
||||
_textQR.text = getString(R.string.failed_to_generate_qr_code)
|
||||
}
|
||||
|
||||
_textQR.visibility = View.VISIBLE
|
||||
_textQRHint.visibility = View.INVISIBLE
|
||||
_buttonShare.visibility = View.VISIBLE
|
||||
_buttonCopy.visibility = View.VISIBLE
|
||||
|
||||
// Hide QR image since generation failed
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
} finally {
|
||||
_loader.visibility = View.GONE
|
||||
}
|
||||
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
};
|
||||
|
||||
_buttonExportFile.onClick.subscribe {
|
||||
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
|
||||
_createDocumentLauncher.launch(fileName)
|
||||
};
|
||||
}
|
||||
|
||||
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||
}
|
||||
|
||||
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
|
||||
return bitMatrixToBitmap(bitMatrix);
|
||||
if (!isContentSuitableForQRCode(content)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||
hints[EncodeHintType.MARGIN] = 1
|
||||
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||
return bitMatrixToBitmap(bitMatrix)
|
||||
}
|
||||
|
||||
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||
@@ -203,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
.setBody(exportBundle.toByteString())
|
||||
.build();
|
||||
|
||||
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
|
||||
val data = urlInfo.toByteArray()
|
||||
return "polycentric://" + data.toBase64Url()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+133
-61
@@ -32,100 +32,166 @@ import userpackage.Protocol
|
||||
import userpackage.Protocol.ExportBundle
|
||||
|
||||
class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonHelp: ImageButton;
|
||||
private lateinit var _buttonScanProfile: LinearLayout;
|
||||
private lateinit var _buttonImportProfile: LinearLayout;
|
||||
private lateinit var _editProfile: EditText;
|
||||
private lateinit var _loaderOverlay: LoaderOverlay;
|
||||
private lateinit var _buttonHelp: ImageButton
|
||||
private lateinit var _buttonScanProfile: LinearLayout
|
||||
private lateinit var _buttonImportFile: LinearLayout
|
||||
private lateinit var _buttonImportProfile: LinearLayout
|
||||
private lateinit var _editProfile: EditText
|
||||
private lateinit var _loaderOverlay: LoaderOverlay
|
||||
|
||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
if (it.contents != null) {
|
||||
val scannedUrl = it.contents
|
||||
import(scannedUrl)
|
||||
private val _qrCodeResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult =
|
||||
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
if (it.contents != null) {
|
||||
val scannedUrl = it.contents
|
||||
import(scannedUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _filePickerLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { fileUri ->
|
||||
try {
|
||||
// Check file size before reading
|
||||
val fileSize =
|
||||
contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
|
||||
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
|
||||
|
||||
if (fileSize > maxFileSize) {
|
||||
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
|
||||
return@let
|
||||
}
|
||||
|
||||
if (fileSize == 0L) {
|
||||
UIDialogs.toast(this, "Selected file is empty.")
|
||||
return@let
|
||||
}
|
||||
|
||||
val content =
|
||||
contentResolver
|
||||
.openInputStream(fileUri)
|
||||
?.bufferedReader()
|
||||
?.readText()
|
||||
content?.let { fileContent ->
|
||||
val trimmedContent = fileContent.trim()
|
||||
|
||||
// Check if content is empty after trimming
|
||||
if (trimmedContent.isEmpty()) {
|
||||
UIDialogs.toast(this, "Selected file contains no data.")
|
||||
return@let
|
||||
}
|
||||
|
||||
// Check if content looks like a valid polycentric URL
|
||||
if (!trimmedContent.startsWith("polycentric://")) {
|
||||
UIDialogs.toast(
|
||||
this,
|
||||
"Selected file does not contain a valid polycentric profile URL."
|
||||
)
|
||||
return@let
|
||||
}
|
||||
|
||||
import(trimmedContent)
|
||||
}
|
||||
?: run { UIDialogs.toast(this, "Could not read file content.") }
|
||||
} catch (e: SecurityException) {
|
||||
Logger.e(TAG, "Security exception reading file", e)
|
||||
UIDialogs.toast(this, "Permission denied to read file.")
|
||||
} catch (e: OutOfMemoryError) {
|
||||
Logger.e(TAG, "Out of memory reading file", e)
|
||||
UIDialogs.toast(this, "File too large to process.")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to read file", e)
|
||||
UIDialogs.toast(this, "Failed to read file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_import_profile);
|
||||
setNavigationBarColorAndIcons();
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_polycentric_import_profile)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
_buttonHelp = findViewById(R.id.button_help);
|
||||
_buttonScanProfile = findViewById(R.id.button_scan_profile);
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
_editProfile = findViewById(R.id.edit_profile);
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
_buttonHelp = findViewById(R.id.button_help)
|
||||
_buttonScanProfile = findViewById(R.id.button_scan_profile)
|
||||
_buttonImportFile = findViewById(R.id.button_import_file)
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile)
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay)
|
||||
_editProfile = findViewById(R.id.edit_profile)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
|
||||
|
||||
_buttonHelp.setOnClickListener {
|
||||
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
||||
};
|
||||
startActivity(Intent(this, PolycentricWhyActivity::class.java))
|
||||
}
|
||||
|
||||
_buttonScanProfile.setOnClickListener {
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
integrator.setOrientationLocked(true);
|
||||
integrator.setOrientationLocked(true)
|
||||
integrator.setCameraId(0)
|
||||
integrator.setBeepEnabled(false)
|
||||
integrator.setBarcodeImageEnabled(true)
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java)
|
||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
};
|
||||
}
|
||||
|
||||
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
|
||||
|
||||
_buttonImportProfile.setOnClickListener {
|
||||
if (_editProfile.text.isEmpty()) {
|
||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
|
||||
return@setOnClickListener;
|
||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
import(_editProfile.text.toString());
|
||||
};
|
||||
import(_editProfile.text.toString())
|
||||
}
|
||||
|
||||
val url = intent.getStringExtra("url");
|
||||
val url = intent.getStringExtra("url")
|
||||
if (url != null) {
|
||||
import(url);
|
||||
import(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun import(url: String) {
|
||||
if (!url.startsWith("polycentric://")) {
|
||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
|
||||
return;
|
||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
|
||||
return
|
||||
}
|
||||
|
||||
_loaderOverlay.show()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray();
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data);
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray()
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data)
|
||||
|
||||
if (urlInfo.urlType != 3L) {
|
||||
throw Exception("Expected urlInfo struct of type ExportBundle")
|
||||
}
|
||||
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
|
||||
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
|
||||
if (existingProcessSecret != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
|
||||
UIDialogs.toast(
|
||||
this@PolycentricImportProfileActivity,
|
||||
getString(R.string.this_profile_is_already_imported)
|
||||
)
|
||||
}
|
||||
return@launch;
|
||||
return@launch
|
||||
}
|
||||
|
||||
val processSecret = ProcessSecret(keyPair, Process.random());
|
||||
Store.instance.addProcessSecret(processSecret);
|
||||
val processSecret = ProcessSecret(keyPair, Process.random())
|
||||
Store.instance.addProcessSecret(processSecret)
|
||||
|
||||
try {
|
||||
PolycentricStorage.instance.addProcessSecret(processSecret)
|
||||
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||
}
|
||||
|
||||
val processHandle = processSecret.toProcessHandle();
|
||||
val processHandle = processSecret.toProcessHandle()
|
||||
|
||||
for (e in exportBundle.events.eventsList) {
|
||||
try {
|
||||
val se = SignedEvent.fromProto(e);
|
||||
Store.instance.putSignedEvent(se);
|
||||
val se = SignedEvent.fromProto(e)
|
||||
Store.instance.putSignedEvent(se)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Ignored invalid event", e);
|
||||
Logger.w(TAG, "Ignored invalid event", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER);
|
||||
StatePolycentric.instance.setProcessHandle(processHandle)
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||
withContext(Dispatchers.Main) {
|
||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
||||
finish();
|
||||
startActivity(
|
||||
Intent(
|
||||
this@PolycentricImportProfileActivity,
|
||||
PolycentricProfileActivity::class.java
|
||||
)
|
||||
)
|
||||
finish()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to import profile", e);
|
||||
Logger.w(TAG, "Failed to import profile", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
|
||||
UIDialogs.toast(
|
||||
this@PolycentricImportProfileActivity,
|
||||
getString(R.string.failed_to_import_profile) + " '${e.message}'"
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
_loaderOverlay.hide();
|
||||
}
|
||||
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PolycentricImportProfileActivity";
|
||||
private const val TAG = "PolycentricImportProfileActivity"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
|
||||
class QRCodeFullscreenActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val EXTRA_QR_TEXT = "qr_text"
|
||||
|
||||
fun createIntent(context: Context, qrText: String): android.content.Intent {
|
||||
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
|
||||
putExtra(EXTRA_QR_TEXT, qrText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_qr_code_fullscreen)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
|
||||
|
||||
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
|
||||
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
|
||||
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
|
||||
|
||||
// Generate QR code bitmap from text
|
||||
qrText?.let { text ->
|
||||
try {
|
||||
if (!isContentSuitableForQRCode(text)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val dimension = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
|
||||
).toInt()
|
||||
val qrBitmap = generateQRCode(text, dimension, dimension)
|
||||
imageQR.setImageBitmap(qrBitmap)
|
||||
} catch (e: Exception) {
|
||||
// If QR generation fails, show error or fallback
|
||||
imageQR.setImageResource(R.drawable.ic_qr)
|
||||
}
|
||||
}
|
||||
|
||||
buttonBack.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
buttonClose.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
imageQR.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||
}
|
||||
|
||||
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||
if (!isContentSuitableForQRCode(content)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||
hints[EncodeHintType.MARGIN] = 1
|
||||
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||
return bitMatrixToBitmap(bitMatrix)
|
||||
}
|
||||
|
||||
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||
val width = matrix.width
|
||||
val height = matrix.height
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
return bmp
|
||||
}
|
||||
}
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package com.futo.platformplayer.api.http.server.handlers
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class HttpContentUriHandler(
|
||||
method: String,
|
||||
path: String,
|
||||
private val contentResolver: ContentResolver,
|
||||
private val uri: Uri,
|
||||
private val explicitContentType: String? = null
|
||||
) : HttpHandler(method, path) {
|
||||
|
||||
override fun handle(httpContext: HttpContext) {
|
||||
val resolver = contentResolver
|
||||
val requestHeaders = httpContext.headers
|
||||
val responseHeaders = this.headers.clone()
|
||||
|
||||
val meta = try {
|
||||
queryMetadata(resolver, uri)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to query metadata for $uri", e)
|
||||
httpContext.respondCode(404, responseHeaders)
|
||||
return
|
||||
}
|
||||
|
||||
val contentType = explicitContentType
|
||||
?: resolver.getType(uri)
|
||||
?: "application/octet-stream"
|
||||
responseHeaders["Content-Type"] = contentType
|
||||
|
||||
meta.lastModifiedMillis?.let { lastModified ->
|
||||
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
|
||||
|
||||
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
|
||||
if (ifModifiedSinceHeader != null) {
|
||||
val ifModifiedSince = try {
|
||||
httpDateFormat.parse(ifModifiedSinceHeader)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
|
||||
httpContext.respondCode(304, responseHeaders)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
|
||||
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
|
||||
|
||||
val length = meta.size
|
||||
if (length == null) {
|
||||
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
|
||||
responseHeaders.remove("Content-Length")
|
||||
responseHeaders.remove("Content-Range")
|
||||
responseHeaders.remove("Accept-Ranges")
|
||||
|
||||
stream(
|
||||
httpContext = httpContext,
|
||||
resolver = resolver,
|
||||
uri = uri,
|
||||
statusCode = 200,
|
||||
headers = responseHeaders,
|
||||
start = null,
|
||||
length = null
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
responseHeaders["Accept-Ranges"] = "bytes"
|
||||
|
||||
val rangeHeader = requestHeaders["Range"]
|
||||
if (rangeHeader.isNullOrBlank()) {
|
||||
responseHeaders["Content-Length"] = length.toString()
|
||||
Logger.i(TAG, "Sending full content for $uri, length=$length")
|
||||
|
||||
stream(
|
||||
httpContext = httpContext,
|
||||
resolver = resolver,
|
||||
uri = uri,
|
||||
statusCode = 200,
|
||||
headers = responseHeaders,
|
||||
start = 0L,
|
||||
length = length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val range = parseRange(rangeHeader, length)
|
||||
if (range == null) {
|
||||
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
|
||||
responseHeaders["Content-Range"] = "bytes */$length"
|
||||
httpContext.respondCode(416, responseHeaders)
|
||||
return
|
||||
}
|
||||
|
||||
val start = range.first
|
||||
val endInclusive = range.last
|
||||
val bytesToSend = endInclusive - start + 1
|
||||
|
||||
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
|
||||
responseHeaders["Content-Length"] = bytesToSend.toString()
|
||||
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
|
||||
|
||||
stream(
|
||||
httpContext = httpContext,
|
||||
resolver = resolver,
|
||||
uri = uri,
|
||||
statusCode = 206,
|
||||
headers = responseHeaders,
|
||||
start = start,
|
||||
length = bytesToSend
|
||||
)
|
||||
}
|
||||
|
||||
data class ContentMeta(
|
||||
val displayName: String?,
|
||||
val size: Long?,
|
||||
val lastModifiedMillis: Long?
|
||||
)
|
||||
|
||||
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
|
||||
var displayName: String? = null
|
||||
var size: Long? = null
|
||||
var lastModifiedMillis: Long? = null
|
||||
|
||||
resolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
|
||||
displayName = cursor.getString(nameIndex)
|
||||
}
|
||||
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
|
||||
val s = cursor.getLong(sizeIndex)
|
||||
if (s >= 0) size = s // -1 means unknown
|
||||
}
|
||||
|
||||
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
|
||||
val seconds = cursor.getLong(dateModifiedIndex)
|
||||
if (seconds > 0) {
|
||||
lastModifiedMillis = seconds * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
if (lastModifiedMillis == null) {
|
||||
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
|
||||
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
|
||||
val seconds = cursor.getLong(dateAddedIndex)
|
||||
if (seconds > 0) {
|
||||
lastModifiedMillis = seconds * 1000L
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (displayName == null) {
|
||||
displayName = uri.lastPathSegment
|
||||
}
|
||||
|
||||
if (size == null) {
|
||||
try {
|
||||
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
|
||||
val assetLen = afd.length
|
||||
if (assetLen >= 0) {
|
||||
size = assetLen
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
return ContentMeta(
|
||||
displayName = displayName,
|
||||
size = size,
|
||||
lastModifiedMillis = lastModifiedMillis
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseRange(header: String, totalLength: Long): LongRange? {
|
||||
if (totalLength <= 0L) return null
|
||||
|
||||
val prefix = "bytes="
|
||||
if (!header.startsWith(prefix, ignoreCase = true)) return null
|
||||
|
||||
val spec = header.substring(prefix.length).trim()
|
||||
if (spec.isEmpty()) return null
|
||||
|
||||
if (spec.contains(",")) return null
|
||||
|
||||
val dashIndex = spec.indexOf('-')
|
||||
if (dashIndex < 0) return null
|
||||
|
||||
val startPart = spec.substring(0, dashIndex).trim()
|
||||
val endPart = spec.substring(dashIndex + 1).trim()
|
||||
|
||||
return when {
|
||||
startPart.isNotEmpty() -> {
|
||||
val start = startPart.toLongOrNull() ?: return null
|
||||
if (start < 0 || start >= totalLength) return null
|
||||
|
||||
val end = if (endPart.isNotEmpty()) {
|
||||
val rawEnd = endPart.toLongOrNull() ?: return null
|
||||
if (rawEnd < start) return null
|
||||
rawEnd.coerceAtMost(totalLength - 1)
|
||||
} else {
|
||||
totalLength - 1
|
||||
}
|
||||
|
||||
start..end
|
||||
}
|
||||
|
||||
endPart.isNotEmpty() -> {
|
||||
val suffixLen = endPart.toLongOrNull() ?: return null
|
||||
if (suffixLen <= 0L) return null
|
||||
|
||||
if (suffixLen >= totalLength) {
|
||||
0L..(totalLength - 1)
|
||||
} else {
|
||||
val start = totalLength - suffixLen
|
||||
val end = totalLength - 1
|
||||
start..end
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
|
||||
try {
|
||||
val input = resolver.openInputStream(uri)
|
||||
if (input == null) {
|
||||
Logger.w(TAG, "Content not found: $uri")
|
||||
httpContext.respondCode(404, headers)
|
||||
return
|
||||
}
|
||||
|
||||
input.use { inputStream ->
|
||||
httpContext.respond(statusCode, headers) { outputStream ->
|
||||
try {
|
||||
val offset = start ?: 0L
|
||||
if (offset > 0L) {
|
||||
skipFully(inputStream, offset)
|
||||
}
|
||||
copyStream(inputStream, outputStream, length)
|
||||
outputStream.flush()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: FileNotFoundException) {
|
||||
Logger.w(TAG, "Content not found: $uri", e)
|
||||
httpContext.respondCode(404, headers)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to open stream for $uri", e)
|
||||
httpContext.respondCode(500, headers)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
|
||||
val buffer = ByteArray(8192)
|
||||
if (limit == null) {
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
} else {
|
||||
var remaining = limit
|
||||
while (remaining > 0L) {
|
||||
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
|
||||
val read = input.read(buffer, 0, toRead)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
remaining -= read.toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipFully(input: InputStream, bytesToSkip: Long) {
|
||||
var remaining = bytesToSkip
|
||||
while (remaining > 0L) {
|
||||
val skipped = input.skip(remaining)
|
||||
if (skipped <= 0L) {
|
||||
val b = input.read()
|
||||
if (b == -1) break
|
||||
remaining -= 1L
|
||||
} else {
|
||||
remaining -= skipped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HttpContentUriHandler"
|
||||
|
||||
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT")
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-9
@@ -5,6 +5,7 @@ import android.util.Log
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequest
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.parsers.HttpResponseParser
|
||||
import com.futo.platformplayer.readLine
|
||||
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
private var _injectReferer = false;
|
||||
|
||||
private val _client = ManagedHttpClient();
|
||||
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
|
||||
|
||||
override fun handle(context: HttpContext) {
|
||||
if (useTcp) {
|
||||
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
for (injectHeader in _injectRequestHeader)
|
||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||
|
||||
val parsed = Uri.parse(targetUrl);
|
||||
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||
var url = targetUrl
|
||||
if (req != null) {
|
||||
req.url?.let {
|
||||
url = it
|
||||
}
|
||||
req.headers.let {
|
||||
proxyHeaders.clear()
|
||||
proxyHeaders.putAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
val parsed = Uri.parse(url);
|
||||
if(_injectHost)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
proxyHeaders.put("Referer", url);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
|
||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${url}");
|
||||
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||
|
||||
val resp = when (useMethod) {
|
||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
||||
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
|
||||
"HEAD" -> _client.head(targetUrl, proxyHeaders)
|
||||
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
|
||||
"GET" -> _client.get(url, proxyHeaders);
|
||||
"POST" -> _client.post(url, content ?: "", proxyHeaders);
|
||||
"HEAD" -> _client.head(url, proxyHeaders)
|
||||
else -> _client.requestMethod(useMethod, url, proxyHeaders);
|
||||
};
|
||||
|
||||
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||
@@ -91,11 +105,23 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
for (injectHeader in _injectRequestHeader)
|
||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||
|
||||
val parsed = Uri.parse(targetUrl);
|
||||
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||
var url = targetUrl
|
||||
if (req != null) {
|
||||
req.url?.let {
|
||||
url = it
|
||||
}
|
||||
req.headers.let {
|
||||
proxyHeaders.clear()
|
||||
proxyHeaders.putAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
val parsed = Uri.parse(url);
|
||||
if(_injectHost)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
proxyHeaders.put("Referer", url);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
||||
@@ -242,6 +268,10 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
_ignoreRequestHeaders.add("referer");
|
||||
return this;
|
||||
}
|
||||
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
|
||||
_requestModifier = modifier;
|
||||
return this;
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HttpProxyHandler"
|
||||
|
||||
+2
-2
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
|
||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||
(LocalVideoUnMuxedSourceDescriptor(
|
||||
arrayOf(),
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
|
||||
))
|
||||
else (LocalVideoMuxedSourceDescriptor(
|
||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
||||
LocalVideoContentSource(url, mimeType ?: "", name, duration)
|
||||
))
|
||||
);
|
||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||
|
||||
@@ -153,8 +153,8 @@ open class JSClient : IPlatformClient {
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
@@ -186,8 +186,8 @@ open class JSClient : IPlatformClient {
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
_httpClient = JSHttpClient(this, null, _captcha);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
||||
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||
_plugin.withDependency(context, "scripts/source.js");
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import java.util.Dictionary
|
||||
|
||||
@Serializable
|
||||
@@ -27,7 +28,7 @@ class SourcePluginAuthConfig(
|
||||
val details: String? = null,
|
||||
val once: Boolean? = true
|
||||
) {
|
||||
@Contextual
|
||||
@Transient
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ class SourcePluginConfig(
|
||||
//Script
|
||||
val repositoryUrl: String? = null,
|
||||
val scriptUrl: String = "",
|
||||
val version: Int = -1,
|
||||
var version: Int = -1,
|
||||
|
||||
val iconUrl: String? = null,
|
||||
var id: String = UUID.randomUUID().toString(),
|
||||
|
||||
+71
@@ -23,6 +23,7 @@ import java.util.UUID
|
||||
class JSHttpClient : ManagedHttpClient {
|
||||
private val _jsClient: JSClient?;
|
||||
private val _jsConfig: SourcePluginConfig?;
|
||||
val config get() = _jsConfig
|
||||
private val _auth: SourceAuth?;
|
||||
private val _captcha: SourceCaptchaData?;
|
||||
|
||||
@@ -254,6 +255,76 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
return resp;
|
||||
}
|
||||
fun processRequest(method: String, responseCode: Int, url: Uri, headers: Map<String, List<String>>) {
|
||||
if(doUpdateCookies) {
|
||||
val domain = url.host?.lowercase() ?: return;
|
||||
val domainParts = domain.split(".");
|
||||
val defaultCookieDomain =
|
||||
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
for (header in headers) {
|
||||
if(header.key.lowercase() == "set-cookie") {
|
||||
var domainToUse = domain;
|
||||
val cookie = cookieStringToPair(header.value.first());
|
||||
var cookieValue = cookie.second;
|
||||
|
||||
if (cookie.first.isNotEmpty() && cookie.second.isNotEmpty()) {
|
||||
val cookieParts = cookie.second.split(";");
|
||||
if (cookieParts.size == 0)
|
||||
continue;
|
||||
cookieValue = cookieParts[0].trim();
|
||||
|
||||
val cookieVariables = cookieParts.drop(1).map {
|
||||
val splitIndex = it.indexOf("=");
|
||||
if (splitIndex < 0)
|
||||
return@map Pair(it.trim().lowercase(), "");
|
||||
return@map Pair<String, String>(
|
||||
it.substring(0, splitIndex).lowercase().trim(),
|
||||
it.substring(splitIndex + 1).trim()
|
||||
);
|
||||
}.toMap();
|
||||
domainToUse = if (cookieVariables.containsKey("domain"))
|
||||
cookieVariables["domain"]!!.lowercase();
|
||||
else defaultCookieDomain;
|
||||
//TODO: Make sure this has no negative effect besides apply cookies to root domain
|
||||
if(!domainToUse.startsWith("."))
|
||||
domainToUse = ".${domainToUse}";
|
||||
}
|
||||
|
||||
if ((_auth != null || _currentCookieMap.isNotEmpty())) {
|
||||
val cookieMap = if (_currentCookieMap.containsKey(domainToUse))
|
||||
_currentCookieMap[domainToUse]!!;
|
||||
else {
|
||||
val newMap = hashMapOf<String, String>();
|
||||
_currentCookieMap[domainToUse] = newMap
|
||||
newMap;
|
||||
}
|
||||
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap[cookie.first] = cookieValue;
|
||||
}
|
||||
else {
|
||||
val cookieMap = if (_otherCookieMap.containsKey(domainToUse))
|
||||
_otherCookieMap[domainToUse]!!;
|
||||
else {
|
||||
val newMap = hashMapOf<String, String>();
|
||||
_otherCookieMap[domainToUse] = newMap
|
||||
newMap;
|
||||
}
|
||||
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap[cookie.first] = cookieValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(_jsClient is DevJSClient) {
|
||||
//val peekBody = resp.peekBody(1000 * 1000).string();
|
||||
StateDeveloper.instance.addDevHttpExchange(
|
||||
StateDeveloper.DevHttpExchange(
|
||||
StateDeveloper.DevHttpRequest(method, url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), ""),
|
||||
StateDeveloper.DevHttpRequest("RESP", url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), "", responseCode)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private fun cookieStringToPair(cookie: String): Pair<String, String> {
|
||||
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
||||
|
||||
+30
-4
@@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Base64
|
||||
|
||||
class JSRequestExecutor {
|
||||
class JSRequestExecutor: AutoCloseable {
|
||||
private val _plugin: JSClient;
|
||||
private val _config: IV8PluginConfig;
|
||||
private var _executor: V8ValueObject;
|
||||
@@ -29,6 +32,9 @@ class JSRequestExecutor {
|
||||
|
||||
private val hasCleanup: Boolean;
|
||||
|
||||
private var _cleanLock = Any();
|
||||
private var _cleaned: Boolean = false;
|
||||
|
||||
constructor(plugin: JSClient, executor: V8ValueObject) {
|
||||
this._plugin = plugin;
|
||||
this._executor = executor;
|
||||
@@ -102,8 +108,12 @@ class JSRequestExecutor {
|
||||
|
||||
|
||||
open fun cleanup() {
|
||||
if (!hasCleanup || _executor.isClosed)
|
||||
return;
|
||||
synchronized(_cleanLock) {
|
||||
if (!hasCleanup || _executor.isClosed || _cleaned)
|
||||
return;
|
||||
_cleaned = true;
|
||||
}
|
||||
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
|
||||
_plugin.busy {
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
@@ -125,9 +135,25 @@ class JSRequestExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
override fun close() {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
fun closeAsync() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
try {
|
||||
close();
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("JSRequestExecutor", "Cleanup failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
protected fun finalize() {
|
||||
cleanup();
|
||||
}*/
|
||||
}
|
||||
|
||||
//TODO: are these available..?
|
||||
|
||||
+2
-2
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||
this.name = name ?: "File";
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.duration = duration;
|
||||
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
|
||||
+2
-2
@@ -22,12 +22,12 @@ class LocalVideoContentSource: IVideoSource {
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||
this.name = name ?: "File";
|
||||
width = 0;
|
||||
height = 0;
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.duration = duration;
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
@@ -239,7 +239,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
}
|
||||
|
||||
DeviceConnectionState.Disconnected -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
@@ -268,4 +268,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
companion object {
|
||||
private val TAG = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
@@ -14,9 +15,11 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||
@@ -33,6 +36,8 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.awaitCancelConverted
|
||||
import com.futo.platformplayer.builders.DashBuilder
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
@@ -234,9 +239,9 @@ abstract class StateCasting {
|
||||
Logger.i(TAG, "Connect to device ${device.name}")
|
||||
}
|
||||
|
||||
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
|
||||
fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
|
||||
return Metadata(
|
||||
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
|
||||
title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,20 +300,63 @@ abstract class StateCasting {
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
val videoPath = "/video-$id"
|
||||
val upstreamUrl = videoSource.getVideoUrl()
|
||||
val videoUrl = if (proxyStreams) url + videoPath else upstreamUrl
|
||||
val jsReqMod = (videoSource as? JSSource)?.getRequestModifier()
|
||||
|
||||
if (proxyStreams) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", videoPath, upstreamUrl, true)
|
||||
.withIRequestModifier(jsReqMod)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||
true
|
||||
).withTag("castSingular")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Casting as singular video (proxy=$proxyStreams, url=$videoUrl)")
|
||||
ad.loadVideo(
|
||||
if (video.isLive) "LIVE" else "BUFFERED",
|
||||
videoSource.container,
|
||||
videoUrl,
|
||||
resumePosition,
|
||||
video.duration.toDouble(),
|
||||
speed,
|
||||
metadataFromVideo(video)
|
||||
)
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
val audioPath = "/audio-$id"
|
||||
val upstreamUrl = audioSource.getAudioUrl()
|
||||
val audioUrl = if (proxyStreams) url + audioPath else upstreamUrl
|
||||
val jsReqMod = (audioSource as? JSSource)?.getRequestModifier()
|
||||
|
||||
if (proxyStreams) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", audioPath, upstreamUrl, true)
|
||||
.withIRequestModifier(jsReqMod)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||
true
|
||||
).withTag("castSingular")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Casting as singular audio (proxy=$proxyStreams, url=$audioUrl)")
|
||||
ad.loadVideo(
|
||||
if (video.isLive) "LIVE" else "BUFFERED",
|
||||
audioSource.container,
|
||||
audioUrl,
|
||||
resumePosition,
|
||||
video.duration.toDouble(),
|
||||
speed,
|
||||
metadataFromVideo(video)
|
||||
)
|
||||
} else if (videoSource is IHLSManifestSource) {
|
||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed, (videoSource as JSSource?)?.getRequestModifier());
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
@@ -316,7 +364,7 @@ abstract class StateCasting {
|
||||
} else if (audioSource is IHLSManifestAudioSource) {
|
||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed, (audioSource as JSSource?)?.getRequestModifier());
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||
@@ -327,6 +375,12 @@ abstract class StateCasting {
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||
} else if (videoSource is LocalVideoContentSource) {
|
||||
Logger.i(TAG, "Casting as local video");
|
||||
castLocalVideo(contentResolver, video, videoSource, resumePosition, speed);
|
||||
} else if (audioSource is LocalAudioContentSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(contentResolver, video, audioSource, resumePosition, speed);
|
||||
} else if (videoSource is JSDashManifestRawSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
@@ -347,6 +401,11 @@ abstract class StateCasting {
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpProxyHandler.withIRequestModifier(requestModifier: IRequestModifier?): HttpProxyHandler {
|
||||
if (requestModifier == null) return this
|
||||
return withRequestModifier { url, headers -> requestModifier.modifyRequest(url, headers) }
|
||||
}
|
||||
|
||||
fun resumeVideo(): Boolean {
|
||||
val ad = activeDevice ?: return false;
|
||||
try {
|
||||
@@ -412,6 +471,65 @@ abstract class StateCasting {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
val thumbnailPath = "/thumbnail-${id}"
|
||||
val thumbnailUrl = url + thumbnailPath;
|
||||
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||
|
||||
if (thumbnailContentUrl != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
}
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
||||
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
|
||||
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||
|
||||
return listOf(videoUrl);
|
||||
}
|
||||
|
||||
private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
val thumbnailPath = "/thumbnail-${id}"
|
||||
val thumbnailUrl = url + thumbnailPath;
|
||||
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||
|
||||
if (thumbnailContentUrl != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
}
|
||||
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri())
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
|
||||
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
|
||||
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||
|
||||
return listOf(audioUrl);
|
||||
}
|
||||
|
||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
@@ -665,7 +783,8 @@ abstract class StateCasting {
|
||||
sourceUrl: String,
|
||||
codec: String?,
|
||||
resumePosition: Double,
|
||||
speed: Double?
|
||||
speed: Double?,
|
||||
requestModifier: IRequestModifier?
|
||||
): List<String> {
|
||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
@@ -686,7 +805,9 @@ abstract class StateCasting {
|
||||
val headers = masterContext.headers.clone()
|
||||
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||
|
||||
val masterPlaylistResponse = _client.get(sourceUrl)
|
||||
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
|
||||
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
|
||||
|
||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||
|
||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
@@ -706,7 +827,7 @@ abstract class StateCasting {
|
||||
val variantPlaylist =
|
||||
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
|
||||
val proxiedVariantPlaylist =
|
||||
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
||||
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive, requestModifier)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
return@HttpFunctionHandler
|
||||
@@ -747,7 +868,7 @@ abstract class StateCasting {
|
||||
val variantPlaylist =
|
||||
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
|
||||
val proxiedVariantPlaylist =
|
||||
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
||||
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive, requestModifier)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
@@ -784,7 +905,7 @@ abstract class StateCasting {
|
||||
val variantPlaylist =
|
||||
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
|
||||
val proxiedVariantPlaylist = proxyVariantPlaylist(
|
||||
url, playlistId, variantPlaylist, video.isLive
|
||||
url, playlistId, variantPlaylist, video.isLive, requestModifier
|
||||
)
|
||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||
@@ -826,13 +947,13 @@ abstract class StateCasting {
|
||||
return listOf(hlsUrl);
|
||||
}
|
||||
|
||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, requestModifier: IRequestModifier?, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||
val newSegments = arrayListOf<HLS.Segment>()
|
||||
|
||||
if (proxySegments) {
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber, requestModifier))
|
||||
}
|
||||
} else {
|
||||
newSegments.addAll(variantPlaylist.segments)
|
||||
@@ -850,7 +971,7 @@ abstract class StateCasting {
|
||||
)
|
||||
}
|
||||
|
||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
|
||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long, requestModifier: IRequestModifier?): HLS.Segment {
|
||||
if (segment is HLS.MediaSegment) {
|
||||
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
||||
val newSegmentUrl = url + newSegmentPath;
|
||||
@@ -858,6 +979,7 @@ abstract class StateCasting {
|
||||
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
||||
.withIRequestModifier(requestModifier)
|
||||
.withInjectedHost()
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castProxiedHlsVariant")
|
||||
@@ -1227,10 +1349,14 @@ abstract class StateCasting {
|
||||
}
|
||||
|
||||
if (audioSource != null && audioSource.hasRequestExecutor) {
|
||||
val oldExecutor = _audioExecutor;
|
||||
oldExecutor?.closeAsync();
|
||||
_audioExecutor = audioSource.getRequestExecutor()
|
||||
}
|
||||
|
||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||
val oldExecutor = _videoExecutor;
|
||||
oldExecutor?.closeAsync();
|
||||
_videoExecutor = videoSource.getRequestExecutor()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.dev.V8RemoteObject
|
||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
|
||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
|
||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
@@ -28,6 +29,8 @@ import com.google.gson.FieldAttributes
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.reflect.Field
|
||||
@@ -268,11 +271,17 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(403, "This plugin doesn't support auth");
|
||||
return;
|
||||
}
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
LoginFragment.showLogin(config){
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||
};
|
||||
}
|
||||
/*
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||
|
||||
};
|
||||
}; */
|
||||
context.respondCode(200, "Login started");
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
@@ -16,9 +16,12 @@ import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UpdateDownloadService
|
||||
import com.futo.platformplayer.UpdateNotificationManager
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.copyToOutputStream
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -34,6 +37,8 @@ import java.io.InputStream
|
||||
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
companion object {
|
||||
private val TAG = "AutoUpdateDialog";
|
||||
|
||||
var currentDialog: AutoUpdateDialog? = null
|
||||
}
|
||||
|
||||
private lateinit var _buttonNever: Button;
|
||||
@@ -46,7 +51,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
private var _maxVersion: Int = 0;
|
||||
|
||||
private var _updating: Boolean = false;
|
||||
private var _apkFile: File? = null;
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -61,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
|
||||
|
||||
_buttonNever.setOnClickListener {
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
Settings.instance.autoUpdate.check = 1;
|
||||
Settings.instance.save();
|
||||
dismiss();
|
||||
};
|
||||
|
||||
_buttonClose.setOnClickListener {
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
dismiss();
|
||||
};
|
||||
|
||||
@@ -76,23 +82,32 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
};
|
||||
|
||||
_buttonUpdate.setOnClickListener {
|
||||
UpdateNotificationManager.cancelAll(context)
|
||||
|
||||
if (_updating) {
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
_updating = true;
|
||||
update();
|
||||
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||
val ctx = context.applicationContext;
|
||||
val intent = Intent(ctx, UpdateDownloadService::class.java);
|
||||
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
|
||||
ContextCompat.startForegroundService(ctx, intent);
|
||||
UIDialogs.toast(context, "Downloading update in background");
|
||||
dismiss();
|
||||
} else {
|
||||
_updating = true;
|
||||
update();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fun showPredownloaded(apkFile: File) {
|
||||
_apkFile = apkFile;
|
||||
super.show()
|
||||
currentDialog = this
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
InstallReceiver.onReceiveResult.clear();
|
||||
currentDialog = null
|
||||
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
||||
}
|
||||
|
||||
@@ -118,21 +133,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var inputStream: InputStream? = null;
|
||||
try {
|
||||
val apkFile = _apkFile;
|
||||
if (apkFile != null) {
|
||||
inputStream = apkFile.inputStream();
|
||||
val dataLength = apkFile.length();
|
||||
val client = ManagedHttpClient();
|
||||
val response = client.get(StateUpdate.APK_URL);
|
||||
if (response.isOk && response.body != null) {
|
||||
inputStream = response.body.byteStream();
|
||||
val dataLength = response.body.contentLength();
|
||||
install(inputStream, dataLength);
|
||||
} else {
|
||||
val client = ManagedHttpClient();
|
||||
val response = client.get(StateUpdate.APK_URL);
|
||||
if (response.isOk && response.body != null) {
|
||||
inputStream = response.body.byteStream();
|
||||
val dataLength = response.body.contentLength();
|
||||
install(inputStream, dataLength);
|
||||
} else {
|
||||
throw Exception("Failed to download latest version of app.");
|
||||
}
|
||||
throw Exception("Failed to download latest version of app.");
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
||||
|
||||
@@ -48,6 +48,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
|
||||
private lateinit var _buttonCancel1: Button;
|
||||
private lateinit var _buttonCancel2: Button;
|
||||
private lateinit var _buttonAlways: LinearLayout;
|
||||
private lateinit var _buttonUpdate: LinearLayout;
|
||||
|
||||
private lateinit var _buttonOk: LinearLayout;
|
||||
@@ -58,6 +59,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
private lateinit var _textProgres: TextView;
|
||||
private lateinit var _textError: TextView;
|
||||
private lateinit var _textResult: TextView;
|
||||
private lateinit var _textChangelogResult: TextView;
|
||||
|
||||
private lateinit var _uiChoiceTop: FrameLayout;
|
||||
private lateinit var _uiProgressTop: FrameLayout;
|
||||
@@ -89,6 +91,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
|
||||
_buttonCancel1 = findViewById(R.id.button_cancel_1);
|
||||
_buttonCancel2 = findViewById(R.id.button_cancel_2);
|
||||
_buttonAlways = findViewById(R.id.button_always);
|
||||
_buttonUpdate = findViewById(R.id.button_update);
|
||||
|
||||
_buttonOk = findViewById(R.id.button_ok);
|
||||
@@ -99,6 +102,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
_textProgres = findViewById(R.id.text_progress);
|
||||
_textError = findViewById(R.id.text_error);
|
||||
_textResult = findViewById(R.id.text_result);
|
||||
_textChangelogResult = findViewById(R.id.text_changelog_result);
|
||||
|
||||
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
|
||||
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
|
||||
@@ -119,17 +123,24 @@ class PluginUpdateDialog : AlertDialog {
|
||||
val changelog = _newConfig.changelog!![changelogVersion]!!;
|
||||
if(changelog.size > 1) {
|
||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
|
||||
_textChangelogResult.text = _textChangelog.text;
|
||||
}
|
||||
else if(changelog.size == 1) {
|
||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
|
||||
_textChangelogResult.text = _textChangelog.text;
|
||||
}
|
||||
else
|
||||
else {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
} else
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
}
|
||||
} else {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
Logger.e(TAG, "Invalid changelog? ", ex);
|
||||
}
|
||||
|
||||
@@ -145,6 +156,18 @@ class PluginUpdateDialog : AlertDialog {
|
||||
_isUpdating = true;
|
||||
update();
|
||||
};
|
||||
_buttonAlways.setOnClickListener {
|
||||
if (_isUpdating)
|
||||
return@setOnClickListener;
|
||||
val plugin = StatePlugins.instance.getPlugin(_oldConfig.id);
|
||||
if(plugin != null) {
|
||||
plugin.appSettings.automaticUpdate = true;
|
||||
StatePlugins.instance.savePlugin(_oldConfig.id);
|
||||
UIDialogs.appToast("Automatic update enabled, can be disabled in plugin settings");
|
||||
}
|
||||
_isUpdating = true;
|
||||
update();
|
||||
};
|
||||
|
||||
Glide.with(_iconPlugin)
|
||||
.load(_oldConfig.absoluteIconUrl)
|
||||
@@ -158,7 +181,8 @@ class PluginUpdateDialog : AlertDialog {
|
||||
if (_isUpdating)
|
||||
return;
|
||||
_isUpdating = true;
|
||||
update();
|
||||
|
||||
update(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +191,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
super.dismiss();
|
||||
}
|
||||
|
||||
private fun update() {
|
||||
private fun update(automatic: Boolean = false) {
|
||||
_uiChoiceTop.visibility = View.GONE;
|
||||
_uiRiskTop.visibility = View.GONE;
|
||||
_uiChoiceBot.visibility = View.GONE;
|
||||
@@ -187,9 +211,16 @@ class PluginUpdateDialog : AlertDialog {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
_textProgres.setText("Loading current script file...");
|
||||
}
|
||||
val client = ManagedHttpClient();
|
||||
client.setTimeout(10000);
|
||||
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_textProgres.setText("Requesting new script file...");
|
||||
}
|
||||
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
|
||||
if(newScript.isNullOrEmpty())
|
||||
throw IllegalStateException("No script found");
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaMuxer
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
@@ -8,6 +11,7 @@ import com.arthenica.ffmpegkit.StatisticsCallback
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
@@ -136,6 +140,8 @@ class VideoDownload {
|
||||
|
||||
var hasVideoRequestExecutor: Boolean = false;
|
||||
var hasAudioRequestExecutor: Boolean = false;
|
||||
var hasVideoRequestModifier: Boolean = false;
|
||||
var hasAudioRequestModifier: Boolean = false;
|
||||
|
||||
var progress: Double = 0.0;
|
||||
var isCancelled = false;
|
||||
@@ -203,8 +209,10 @@ class VideoDownload {
|
||||
this.prepareTime = OffsetDateTime.now();
|
||||
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
|
||||
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.targetVideoName = videoSource?.name;
|
||||
this.targetAudioName = audioSource?.name;
|
||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||
@@ -478,8 +486,8 @@ class VideoDownload {
|
||||
|
||||
if(actualVideoSource is IVideoUrlSource)
|
||||
videoFileSize = when (videoSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "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) {
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
||||
@@ -518,8 +526,8 @@ class VideoDownload {
|
||||
|
||||
if(actualAudioSource is IAudioUrlSource)
|
||||
audioFileSize = when (audioSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "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) {
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
||||
@@ -580,83 +588,12 @@ class VideoDownload {
|
||||
return cipher.doFinal(encryptedSegment)
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
||||
var downloadedTotalLength = 0L
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val response = client.get(hlsUrl)
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
|
||||
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
|
||||
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
|
||||
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
if (segment !is HLS.MediaSegment) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Download '$name' segment $index Sequential");
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
val outputStream = segmentFile.outputStream()
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
|
||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
|
||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
}
|
||||
|
||||
downloadedTotalLength += segmentLength
|
||||
} finally {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Combining segments into $targetFile");
|
||||
combineSegments(context, segmentFiles, targetFile)
|
||||
|
||||
Logger.i(TAG, "${name} downloadSource Finished");
|
||||
}
|
||||
catch(ioex: IOException) {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||
throw Exception("Not enough space on device", ioex);
|
||||
else
|
||||
throw ioex;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
throw ex;
|
||||
}
|
||||
finally {
|
||||
for (segmentFile in segmentFiles) {
|
||||
segmentFile.delete()
|
||||
}
|
||||
}
|
||||
return downloadedTotalLength;
|
||||
}
|
||||
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val cmd =
|
||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
}
|
||||
@@ -665,6 +602,7 @@ class VideoDownload {
|
||||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
@@ -672,6 +610,7 @@ class VideoDownload {
|
||||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
@@ -686,6 +625,237 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
|
||||
var downloadedTotalLength = 0L
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier()
|
||||
else
|
||||
null
|
||||
|
||||
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
|
||||
val headers = mutableMapOf<String, String>()
|
||||
|
||||
if (rangeStart != null) {
|
||||
if (rangeLength != null && rangeLength > 0) {
|
||||
val end = rangeStart + rangeLength - 1
|
||||
headers["Range"] = "bytes=$rangeStart-$end"
|
||||
} else {
|
||||
headers["Range"] = "bytes=$rangeStart-"
|
||||
}
|
||||
}
|
||||
|
||||
val modified = modifier?.modifyRequest(url, headers)
|
||||
val finalUrl = modified?.url ?: url
|
||||
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
|
||||
|
||||
val resp = client.get(finalUrl, finalHeaders)
|
||||
if (!resp.isOk) {
|
||||
resp.body?.close()
|
||||
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
|
||||
}
|
||||
|
||||
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
|
||||
val bytes = body.bytes()
|
||||
body.close()
|
||||
return bytes
|
||||
}
|
||||
|
||||
fun buildSequenceIv(sequenceNumber: Long): ByteArray {
|
||||
return ByteBuffer.allocate(16)
|
||||
.putLong(0L)
|
||||
.putLong(sequenceNumber)
|
||||
.array()
|
||||
}
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val playlistHeaders = mutableMapOf<String, String>()
|
||||
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
||||
val playlistResp = client.get(
|
||||
modifiedPlaylistReq?.url ?: hlsUrl,
|
||||
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
|
||||
)
|
||||
|
||||
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
|
||||
|
||||
val vpContent = playlistResp.body?.string()
|
||||
?: throw IllegalStateException("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val hlsDec = variantPlaylist.decryptionInfo
|
||||
val useDecryption = hlsDec != null && !hlsDec.method.equals("NONE", ignoreCase = true)
|
||||
var keyBytes: ByteArray? = null
|
||||
var staticIvBytes: ByteArray? = null
|
||||
|
||||
if (useDecryption) {
|
||||
if (!hlsDec.method.equals("AES-128", ignoreCase = true)) {
|
||||
throw UnsupportedOperationException("HLS decryption method '${hlsDec.method}' is not supported.")
|
||||
}
|
||||
|
||||
val keyUrl = hlsDec.keyUrl ?: throw IllegalStateException("Encrypted HLS playlist without key URI is not supported.")
|
||||
|
||||
keyBytes = downloadBytes(keyUrl)
|
||||
if (!hlsDec.iv.isNullOrEmpty()) {
|
||||
staticIvBytes = hlsDec.iv.hexStringToByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
|
||||
val rangeOffsets = mutableMapOf<String, Long>()
|
||||
|
||||
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
||||
if (isCancelled) throw CancellationException("Cancelled")
|
||||
|
||||
Logger.i(TAG, "Downloading HLS initialization map")
|
||||
|
||||
var mapRangeStart: Long? = null
|
||||
var mapRangeLength: Long? = null
|
||||
|
||||
if (variantPlaylist.mapBytesLength > 0) {
|
||||
mapRangeLength = variantPlaylist.mapBytesLength
|
||||
|
||||
val mapUrl = variantPlaylist.mapUrl
|
||||
if (variantPlaylist.mapBytesStart >= 0) {
|
||||
mapRangeStart = variantPlaylist.mapBytesStart
|
||||
rangeOffsets[mapUrl] =
|
||||
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
|
||||
} else {
|
||||
val offset = rangeOffsets[mapUrl] ?: 0L
|
||||
mapRangeStart = offset
|
||||
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
|
||||
}
|
||||
}
|
||||
|
||||
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
|
||||
|
||||
if (useDecryption) {
|
||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||
val iv = staticIvBytes
|
||||
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
|
||||
mapBytes = decryptSegment(mapBytes, kb, iv)
|
||||
}
|
||||
|
||||
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
|
||||
throw IllegalStateException("HLS MAP segment too large to handle.")
|
||||
}
|
||||
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
val outStr = segmentFile.outputStream()
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
outStr.write(mapBytes)
|
||||
outStr.flush()
|
||||
} finally {
|
||||
outStr.close()
|
||||
}
|
||||
downloadedTotalLength += mapBytes.size
|
||||
}
|
||||
|
||||
val totalSegments = variantPlaylist.segments.size
|
||||
var mediaSegmentIndex = 0
|
||||
|
||||
var bytesSinceLastSpeedUpdate = 0L
|
||||
var lastSpeedUpdateTime = System.currentTimeMillis()
|
||||
var lastSpeed = 0L
|
||||
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
if (segment !is HLS.MediaSegment) return@forEachIndexed
|
||||
if (isCancelled) throw CancellationException("Cancelled")
|
||||
|
||||
Logger.i(TAG, "Download '$name' segment $index sequential")
|
||||
|
||||
var rangeStart: Long? = null
|
||||
var rangeLength: Long? = null
|
||||
|
||||
if (segment.bytesLength > 0) {
|
||||
rangeLength = segment.bytesLength
|
||||
|
||||
val urlKey = segment.uri
|
||||
if (segment.bytesStart >= 0) {
|
||||
rangeStart = segment.bytesStart
|
||||
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
|
||||
} else {
|
||||
val offset = rangeOffsets[urlKey] ?: 0L
|
||||
rangeStart = offset
|
||||
rangeOffsets[urlKey] = offset + segment.bytesLength
|
||||
}
|
||||
}
|
||||
|
||||
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
|
||||
|
||||
if (useDecryption) {
|
||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||
val ivBytes = if (staticIvBytes != null) {
|
||||
staticIvBytes
|
||||
} else {
|
||||
val sequenceNumber = mediaSequence + mediaSegmentIndex
|
||||
buildSequenceIv(sequenceNumber)
|
||||
}
|
||||
|
||||
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
|
||||
}
|
||||
|
||||
val segmentLength = segmentBytes.size.toLong()
|
||||
if (segmentLength > Int.MAX_VALUE) {
|
||||
throw IllegalStateException("HLS media segment too large to handle.")
|
||||
}
|
||||
|
||||
val avgLen = if (index == 0) {
|
||||
segmentLength
|
||||
} else {
|
||||
if (index > 0) downloadedTotalLength / index else segmentLength
|
||||
}
|
||||
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
|
||||
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
val outStr = segmentFile.outputStream()
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
outStr.write(segmentBytes)
|
||||
} finally {
|
||||
outStr.close()
|
||||
}
|
||||
downloadedTotalLength += segmentLength
|
||||
|
||||
bytesSinceLastSpeedUpdate += segmentLength
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastSpeedUpdateTime
|
||||
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
|
||||
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
|
||||
bytesSinceLastSpeedUpdate = 0
|
||||
lastSpeedUpdateTime = now
|
||||
}
|
||||
|
||||
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
|
||||
mediaSegmentIndex++
|
||||
}
|
||||
|
||||
combineSegments(context, segmentFiles, targetFile)
|
||||
Logger.i(TAG, "Finished HLS Source for $name")
|
||||
} catch (ioex: IOException) {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
if (ioex.message?.contains("ENOSPC") == true)
|
||||
throw Exception("Not enough space on device", ioex)
|
||||
else
|
||||
throw ioex
|
||||
} catch (ex: Throwable) {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
throw ex
|
||||
}
|
||||
finally {
|
||||
for (segmentFile in segmentFiles) {
|
||||
segmentFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedTotalLength
|
||||
}
|
||||
|
||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
@@ -695,6 +865,7 @@ class VideoDownload {
|
||||
val sourceLength: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
|
||||
var executor: JSRequestExecutor? = null;
|
||||
try{
|
||||
var manifest = source.manifest;
|
||||
if(source.hasGenerate)
|
||||
@@ -711,10 +882,15 @@ class VideoDownload {
|
||||
if(foundCues.count() <= 0)
|
||||
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
|
||||
|
||||
val executor = if(source is JSSource && source.hasRequestExecutor)
|
||||
executor = if(source is JSSource && source.hasRequestExecutor)
|
||||
source.getRequestExecutor();
|
||||
else
|
||||
null;
|
||||
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier();
|
||||
else
|
||||
null;
|
||||
val speedTracker = SpeedTracker(1000);
|
||||
|
||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||
@@ -726,12 +902,14 @@ class VideoDownload {
|
||||
val t = cue.groupValues[1];
|
||||
val d = cue.groupValues[2];
|
||||
|
||||
|
||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||
val modified = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", url, null, mapOf());
|
||||
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
|
||||
else {
|
||||
val resp = client.get(url, mutableMapOf());
|
||||
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
|
||||
if(!resp.isOk)
|
||||
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||
resp.body!!.bytes()
|
||||
@@ -763,10 +941,11 @@ class VideoDownload {
|
||||
}
|
||||
finally {
|
||||
fileStream.close();
|
||||
executor?.closeAsync()
|
||||
}
|
||||
return sourceLength!!;
|
||||
}
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, 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())
|
||||
targetFile.delete();
|
||||
|
||||
@@ -775,7 +954,12 @@ class VideoDownload {
|
||||
val sourceLength: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
|
||||
try{
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier();
|
||||
else
|
||||
null;
|
||||
|
||||
try {
|
||||
val head = client.tryHead(videoUrl);
|
||||
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
|
||||
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
||||
@@ -786,12 +970,12 @@ class VideoDownload {
|
||||
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency}): " + videoUrl);
|
||||
sourceLength = head["content-length"]!!.toLong();
|
||||
onProgress(sourceLength, 0, 0);
|
||||
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||
downloadSource_Ranges(name, client, modifier, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
|
||||
sourceLength = downloadSource_Sequential(client, modifier, fileStream, videoUrl, null, 0, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
@@ -842,7 +1026,7 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, modifier: IRequestModifier? = null, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -851,7 +1035,12 @@ class VideoDownload {
|
||||
|
||||
var lastSpeed: Long = 0;
|
||||
|
||||
val result = client.get(url);
|
||||
val result = if (modifier != null) {
|
||||
val modified = modifier.modifyRequest(url, mapOf())
|
||||
client.get(modified.url!!, modified.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(url)
|
||||
}
|
||||
if (!result.isOk) {
|
||||
result.body?.close()
|
||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
||||
@@ -988,7 +1177,7 @@ class VideoDownload {
|
||||
onProgress(sourceLength, totalRead, 0)
|
||||
return sourceLength
|
||||
}*/
|
||||
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
|
||||
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -1007,7 +1196,7 @@ class VideoDownload {
|
||||
|
||||
Logger.i(TAG, "Download ${name} Batch #${reqCount} [${concurrency}] (${lastSpeed.toHumanBytesSpeed()})");
|
||||
|
||||
val byteRangeResults = requestByteRangeParallel(client, pool, url, sourceLength, concurrency, totalRead,
|
||||
val byteRangeResults = requestByteRangeParallel(client, pool, modifier, url, sourceLength, concurrency, totalRead,
|
||||
rangeSize, 1024 * 64);
|
||||
|
||||
for(byteRange in byteRangeResults) {
|
||||
@@ -1038,7 +1227,7 @@ class VideoDownload {
|
||||
onProgress(sourceLength, totalRead, 0);
|
||||
}
|
||||
|
||||
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
|
||||
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, modifier: IRequestModifier?, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
|
||||
val tasks = mutableListOf<ForkJoinTask<Triple<ByteArray, Long, Long>>>();
|
||||
var readPosition = rangePosition;
|
||||
for(i in 0 until concurrency) {
|
||||
@@ -1052,21 +1241,25 @@ class VideoDownload {
|
||||
else readPosition + toRead;
|
||||
|
||||
tasks.add(pool.submit<Triple<ByteArray, Long, Long>> {
|
||||
return@submit requestByteRange(client, url, rangeStart, rangeEnd);
|
||||
return@submit requestByteRange(client, modifier, url, rangeStart, rangeEnd);
|
||||
});
|
||||
readPosition = rangeEnd + 1;
|
||||
}
|
||||
|
||||
return tasks.map { it.get() };
|
||||
}
|
||||
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||
private fun requestByteRange(client: ManagedHttpClient, modifier: IRequestModifier?, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||
var retryCount = 0
|
||||
var lastException: Throwable? = null
|
||||
var lastException: Throwable? = null;
|
||||
|
||||
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
|
||||
val modified = modifier?.modifyRequest(url, headers);
|
||||
|
||||
while (retryCount <= 3) {
|
||||
try {
|
||||
val toRead = rangeEnd - rangeStart;
|
||||
val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")));
|
||||
|
||||
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
|
||||
if (!req.isOk) {
|
||||
val bodyString = req.body?.string()
|
||||
req.body?.close()
|
||||
|
||||
@@ -23,10 +23,7 @@ object Libcurl {
|
||||
var body: ByteArray? = null,
|
||||
var impersonateTarget: String = "chrome136",
|
||||
var useBuiltInHeaders: Boolean = true,
|
||||
var timeoutMs: Int = 30_000,
|
||||
var cookieJarPath: String? = null,
|
||||
var sendCookies: Boolean = true,
|
||||
var persistCookies: Boolean = true,
|
||||
var timeoutMs: Int = 30_000
|
||||
)
|
||||
|
||||
@Keep
|
||||
@@ -121,12 +118,6 @@ object Libcurl {
|
||||
if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist))
|
||||
}
|
||||
|
||||
if (req.sendCookies || req.persistCookies) {
|
||||
val jar = (req.cookieJarPath ?: defaultCookieJarPath())
|
||||
if (req.sendCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEFILE, jar))
|
||||
if (req.persistCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEJAR, jar))
|
||||
}
|
||||
|
||||
val method = req.method
|
||||
if (!method.equals("GET", ignoreCase = true)) {
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@ import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||
|
||||
open class MainActivityFragment : Fragment() {
|
||||
protected val currentMain : MainFragment
|
||||
protected val currentMain : MainFragment?
|
||||
get() {
|
||||
isValidMainActivity();
|
||||
return (activity as MainActivity).fragCurrent;
|
||||
|
||||
+210
-20
@@ -8,18 +8,25 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.*
|
||||
@@ -27,6 +34,10 @@ import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.pills.RoundButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.floor
|
||||
@@ -69,9 +80,15 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
private val _inflater: LayoutInflater;
|
||||
private val _subscribedActivity: MainActivity?;
|
||||
|
||||
private val _containerMoreHeader: ConstraintLayout;
|
||||
private val _toggleAirplaneMode: LinearLayout;
|
||||
private val _togglePrivacy: LinearLayout;
|
||||
|
||||
private var _overlayMore: FrameLayout;
|
||||
private var _overlayMoreBackground: FrameLayout;
|
||||
private var _layoutMoreButtons: LinearLayout;
|
||||
private var _layoutMoreButtons: RecyclerView;
|
||||
private val _layoutMoreButtonItems = arrayListOf<MenuButtonItem>();
|
||||
private var _layoutMoreButtonsAdapter: AnyAdapterView<MenuButtonItem, MenuButtonItemViewHolder>;
|
||||
private var _layoutBottomBarButtons: LinearLayout;
|
||||
|
||||
private var _moreVisible = false;
|
||||
@@ -85,15 +102,90 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
|
||||
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
|
||||
|
||||
private var moreColumns = 3;
|
||||
|
||||
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||
_fragment = fragment;
|
||||
_inflater = inflater;
|
||||
inflater.inflate(R.layout.fragment_overview_bottom_bar, this);
|
||||
|
||||
_containerMoreHeader = findViewById(R.id.container_more_options);
|
||||
_toggleAirplaneMode = findViewById(R.id.container_toggle_airplane);
|
||||
_togglePrivacy = findViewById(R.id.container_toggle_privacy);
|
||||
|
||||
_toggleAirplaneMode.isVisible = false //TODO: Remove when airplane mode implemented
|
||||
|
||||
StateApp.instance.airplaneModeChanged.subscribe {
|
||||
if(!StateApp.instance.airplaneMode)
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
}
|
||||
if(!StateApp.instance.airplaneMode)
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
_toggleAirplaneMode.setOnClickListener {
|
||||
if(StateApp.instance.airplaneMode) {
|
||||
StateApp.instance.setAirMode(false);
|
||||
UIDialogs.appToast("Airplane mode disabled");
|
||||
}
|
||||
else {
|
||||
StateApp.instance.setAirMode(true);
|
||||
UIDialogs.appToast("Airplane mode enabled");
|
||||
}
|
||||
}
|
||||
|
||||
StateApp.instance.privateModeChanged.subscribe {
|
||||
if(!StateApp.instance.privateMode)
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
}
|
||||
if(!StateApp.instance.privateMode)
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
|
||||
else
|
||||
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
|
||||
_togglePrivacy.setOnClickListener {
|
||||
if(StateApp.instance.privateMode) {
|
||||
StateApp.instance.setPrivacyMode(false);
|
||||
UIDialogs.appToast("Privacy mode disabled");
|
||||
}
|
||||
else {
|
||||
StateApp.instance.setPrivacyMode(true);
|
||||
UIDialogs.appToast("Privacy mode enabled");
|
||||
|
||||
if(Settings.instance.other.showPrivacyModeDialog)
|
||||
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
|
||||
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||
UIDialogs.Action("Don't show again", {
|
||||
Settings.instance.other.showPrivacyModeDialog = false;
|
||||
Settings.instance.save();
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Understood", {
|
||||
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
|
||||
_overlayMore = findViewById(R.id.more_overlay);
|
||||
_overlayMoreBackground = findViewById(R.id.more_overlay_background);
|
||||
_layoutMoreButtons = findViewById(R.id.more_menu_buttons);
|
||||
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons)
|
||||
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons);
|
||||
|
||||
val totalWidthDp = resources.displayMetrics.widthPixels / resources.displayMetrics.density;
|
||||
val columns = MenuButtonItemViewHolder.getAutoSizeColumns(totalWidthDp);
|
||||
_layoutMoreButtonsAdapter = _layoutMoreButtons.asAny<MenuButtonItem, MenuButtonItemViewHolder>(_layoutMoreButtonItems,
|
||||
RecyclerView.VERTICAL, false, { button ->
|
||||
button.setAutoSize(totalWidthDp);
|
||||
button.parentFragment = this@MenuBottomBarView._fragment;
|
||||
button.onClick.subscribe {
|
||||
setMoreVisible(false);
|
||||
}
|
||||
})
|
||||
moreColumns = columns;
|
||||
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
|
||||
_layoutMoreButtons.layoutManager = layoutManager;
|
||||
|
||||
_overlayMoreBackground.setOnClickListener { setMoreVisible(false); };
|
||||
|
||||
@@ -120,6 +212,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
|
||||
private fun setMoreVisible(visible: Boolean) {
|
||||
|
||||
//TODO: issues with these bools
|
||||
if (_moreVisibleAnimating) {
|
||||
return
|
||||
}
|
||||
@@ -128,9 +222,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
val height = _moreButtons.firstOrNull()?.let {
|
||||
it.height.toFloat() + (it.layoutParams as MarginLayoutParams).bottomMargin
|
||||
} ?: return
|
||||
*/
|
||||
|
||||
_moreVisibleAnimating = true
|
||||
val moreOverlayBackground = _overlayMoreBackground
|
||||
@@ -142,14 +239,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
moreOverlay.visibility = VISIBLE
|
||||
val animations = arrayListOf<Animator>()
|
||||
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
|
||||
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 0.0f, 1.0f).setDuration(duration))
|
||||
animations.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 0.0f, 1.0f).setDuration(duration))
|
||||
_bottomButtons.find { it.definition.id == 99 }?.let {
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.4f, 1.0f)
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.5f, 1.0f)
|
||||
.setDuration(duration));
|
||||
}
|
||||
|
||||
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", resources.displayMetrics.heightPixels.toFloat(), 0.0f).setDuration(duration))
|
||||
for ((index, button) in _moreButtons.withIndex()) {
|
||||
val i = _moreButtons.size - index
|
||||
animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
|
||||
//animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
|
||||
}
|
||||
|
||||
val animatorSet = AnimatorSet()
|
||||
@@ -164,14 +264,21 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
animations
|
||||
.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f)
|
||||
.setDuration(duration))
|
||||
animations
|
||||
.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 1.0f, 0.0f)
|
||||
.setDuration(duration))
|
||||
animations
|
||||
.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 1.0f, 0.0f)
|
||||
.setDuration(duration))
|
||||
_bottomButtons.find { it.definition.id == 99 }?.let {
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.4f)
|
||||
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.5f)
|
||||
.setDuration(duration));
|
||||
}
|
||||
|
||||
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", 0.0f, resources.displayMetrics.heightPixels.toFloat()).setDuration(duration))
|
||||
for ((index, button) in _moreButtons.withIndex()) {
|
||||
val i = _moreButtons.size - index
|
||||
animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
|
||||
//animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
|
||||
}
|
||||
|
||||
val animatorSet = AnimatorSet()
|
||||
@@ -183,11 +290,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
animatorSet.playTogether(animations)
|
||||
animatorSet.start()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun updateBottomMenuButtons(buttons: MutableList<ButtonDefinition>, hasMore: Boolean) {
|
||||
if (hasMore) {
|
||||
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(true) }))
|
||||
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(!_moreVisible) }))
|
||||
}
|
||||
|
||||
_bottomButtons.clear();
|
||||
@@ -227,32 +335,42 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
_layoutMoreButtons.removeAllViews();
|
||||
|
||||
var insertedButtons = 0;
|
||||
//Force settings to be first
|
||||
val settingsIndex = buttons.indexOfFirst { b -> b.id == 7 };
|
||||
if (settingsIndex != -1) {
|
||||
val button = buttons[settingsIndex]
|
||||
buttons.removeAt(settingsIndex)
|
||||
buttons.add(0, button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force buy to be on top for more buttons
|
||||
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
|
||||
if (buyIndex != -1) {
|
||||
val button = buttons[buyIndex]
|
||||
buttons.removeAt(buyIndex)
|
||||
buttons.add(0, button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force faq to be second
|
||||
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
|
||||
if (faqIndex != -1) {
|
||||
val button = buttons[faqIndex]
|
||||
buttons.removeAt(faqIndex)
|
||||
buttons.add(if (insertedButtons == 1) 1 else 0, button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
//Force privacy to be third
|
||||
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
|
||||
if (privacyIndex != -1) {
|
||||
val button = buttons[privacyIndex]
|
||||
buttons.removeAt(privacyIndex)
|
||||
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
|
||||
insertedButtons++;
|
||||
buttons.add(button)
|
||||
//insertedButtons++;
|
||||
}
|
||||
|
||||
val newButtons = mutableListOf<MenuButtonItem>();
|
||||
for (data in buttons) {
|
||||
/*
|
||||
val button = MenuButton(context, data, _fragment, true);
|
||||
button.setOnClickListener {
|
||||
updateMenuIcons()
|
||||
@@ -262,7 +380,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
|
||||
_moreButtons.add(button);
|
||||
_layoutMoreButtons.addView(button);
|
||||
*/
|
||||
val buttonItem = MenuButtonItem(data);
|
||||
newButtons.add(buttonItem);
|
||||
}
|
||||
_layoutMoreButtonsAdapter.setData(newButtons);
|
||||
_layoutMoreButtonsAdapter.notifyContentChanged();
|
||||
}
|
||||
|
||||
private fun updateMenuIcons() {
|
||||
@@ -350,6 +473,71 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
|
||||
|
||||
class MenuButtonItem(val def: ButtonDefinition);
|
||||
class MenuButtonItemViewHolder(private val _viewGroup: ViewGroup): AnyAdapter.AnyViewHolder<MenuButtonItem>(
|
||||
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_menu_tile,
|
||||
_viewGroup, false)) {
|
||||
|
||||
val onClick = Event1<MenuButtonItem>();
|
||||
|
||||
val root: ConstraintLayout;
|
||||
val imageIcon: ImageView;
|
||||
val textName: TextView;
|
||||
|
||||
|
||||
var button: MenuButtonItem? = null;
|
||||
|
||||
var parentFragment: MenuBottomBarFragment? = null;
|
||||
|
||||
init {
|
||||
root = _view.findViewById(R.id.root);
|
||||
imageIcon = _view.findViewById(R.id.image_icon);
|
||||
textName = _view.findViewById(R.id.text_name);
|
||||
|
||||
root.setOnClickListener {
|
||||
button?.let {
|
||||
it.def.action(parentFragment ?: return@let);
|
||||
onClick.emit(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun bind(value: MenuButtonItem) {
|
||||
button = value;
|
||||
textName.text = _view.context.getString(value.def.string);
|
||||
imageIcon.setImageResource(value.def.iconActive);
|
||||
}
|
||||
|
||||
|
||||
fun setWidth(dp: Int) {
|
||||
root.updateLayoutParams {
|
||||
this.width = (dp - 6).dp(_viewGroup.context.resources);
|
||||
this.height = (dp - 6).dp(_viewGroup.context.resources);
|
||||
}
|
||||
imageIcon.updateLayoutParams {
|
||||
this.width = (dp - 54).dp(_viewGroup.context.resources);
|
||||
this.height = (dp - 54).dp(_viewGroup.context.resources);
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoSize(totalWidth: Float) {
|
||||
val dpWidth = totalWidth;
|
||||
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
|
||||
val remainder = dpWidth - columns * viewWidthDp;
|
||||
val targetSize = viewWidthDp + (remainder / columns).toInt();
|
||||
setWidth(targetSize);
|
||||
}
|
||||
|
||||
companion object {
|
||||
val viewWidthDp = 90;
|
||||
fun getAutoSizeColumns(totalWidth: Float): Int {
|
||||
val dpWidth = totalWidth;
|
||||
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
class MenuButton: LinearLayout {
|
||||
val definition: ButtonDefinition;
|
||||
|
||||
@@ -369,7 +557,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
this.alpha = 1f;
|
||||
}
|
||||
else {
|
||||
this.alpha = 0.4f;
|
||||
this.alpha = 0.5f;
|
||||
}
|
||||
|
||||
_textButton = findViewById(R.id.text_button);
|
||||
@@ -389,7 +577,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
this.alpha = 1f;
|
||||
}
|
||||
else {
|
||||
this.alpha = 0.4f;
|
||||
this.alpha = 0.5f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,7 +601,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
}
|
||||
}),
|
||||
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
|
||||
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(withHistory = false) }),
|
||||
//if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
|
||||
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(withHistory = false) })
|
||||
,//else null,
|
||||
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
|
||||
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
|
||||
@@ -423,7 +613,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
|
||||
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
|
||||
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
|
||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
|
||||
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { it.currentMain is SettingsFragment }, {
|
||||
it.navigate<SettingsFragment>();
|
||||
/*
|
||||
val c = it.context ?: return@ButtonDefinition;
|
||||
@@ -434,7 +624,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
if (c is Activity) {
|
||||
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
|
||||
}*/
|
||||
}),
|
||||
}),/*
|
||||
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
|
||||
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
|
||||
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
|
||||
@@ -444,14 +634,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
UIDialogs.Action("Enable", {
|
||||
StateApp.instance.setPrivacyMode(true);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}),
|
||||
}),*/
|
||||
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
|
||||
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
|
||||
})
|
||||
//96 is reserved for privacy button
|
||||
//98 is reserved for buy button
|
||||
//99 is reserved for more button
|
||||
);
|
||||
).filterNotNull();
|
||||
}
|
||||
|
||||
data class ButtonDefinition(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.states.Album
|
||||
import com.futo.platformplayer.states.Artist
|
||||
import com.futo.platformplayer.states.ArtistOrdering
|
||||
import com.futo.platformplayer.states.FileEntry
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
|
||||
import com.futo.platformplayer.views.LibrarySection
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
|
||||
class BaseFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val newView = FragView(this);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown();
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = BaseFragment().apply {}
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: BaseFragment;
|
||||
|
||||
constructor(fragment: BaseFragment) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.fragview_library, this);
|
||||
this.fragment = fragment;
|
||||
}
|
||||
|
||||
|
||||
fun onShown() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
@@ -55,6 +56,7 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.withTimestamp
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.PolycentricProfile
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
@@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() {
|
||||
adapter.onContentClicked.subscribe { v, _ ->
|
||||
when (v) {
|
||||
is IPlatformVideo -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
|
||||
//StatePlayer.instance.clearQueue()
|
||||
if (StatePlayer.instance.hasQueue) {
|
||||
StatePlayer.instance.insertToQueue(v, true);
|
||||
} else {
|
||||
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
|
||||
}
|
||||
}
|
||||
|
||||
is IPlatformPlaylist -> {
|
||||
@@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() {
|
||||
adapter.onContentUrlClicked.subscribe { url, contentType ->
|
||||
when (contentType) {
|
||||
ContentType.MEDIA -> {
|
||||
StatePlayer.instance.clearQueue()
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
|
||||
}
|
||||
|
||||
@@ -403,7 +409,7 @@ class ChannelFragment : MainFragment() {
|
||||
_fragment.topBar?.onShown(channel)
|
||||
|
||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
||||
UIDialogs.showConfirmationDialog(context,
|
||||
val dialog = UIDialogs.showConfirmationDialog(context,
|
||||
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
|
||||
.replace("{channelName}", channel.name),
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -39,7 +40,7 @@ import java.time.OffsetDateTime
|
||||
import kotlin.math.max
|
||||
|
||||
abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
|
||||
protected val _feedRoot: FrameLayout;
|
||||
protected val _feedRoot: ConstraintLayout;
|
||||
protected val _recyclerResults: RecyclerView;
|
||||
protected val _overlayContainer: FrameLayout;
|
||||
protected val _swipeRefresh: SwipeRefreshLayout;
|
||||
@@ -52,8 +53,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
private val _emptyPagerContainer: FrameLayout;
|
||||
|
||||
protected val _toolbarContentView: LinearLayout;
|
||||
protected val _bottomContentView: LinearLayout;
|
||||
|
||||
private var _loading: Boolean = true;
|
||||
private var _loading: Boolean = false;
|
||||
|
||||
private val _pagerLock = Object();
|
||||
private var _cache: ItemCache<TResult>? = null;
|
||||
@@ -136,6 +138,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
setActiveTags(null);
|
||||
|
||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
_bottomContentView = findViewById(R.id.container_bottom);
|
||||
|
||||
_nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, {
|
||||
if (it is IAsyncPager<*>)
|
||||
@@ -177,10 +180,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
|
||||
val visibleItemCount = _recyclerResults.childCount;
|
||||
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
|
||||
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
|
||||
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
|
||||
|
||||
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
|
||||
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
|
||||
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size) {
|
||||
loadNextPage();
|
||||
}
|
||||
}
|
||||
@@ -194,57 +196,44 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
}
|
||||
|
||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||
val canScroll = if (recyclerData.results.isEmpty()) false else {
|
||||
val height = resources.displayMetrics.heightPixels;
|
||||
_recyclerResults.post {
|
||||
val canScroll = _recyclerResults.canScrollVertically(1)
|
||||
Logger.i(
|
||||
TAG,
|
||||
"ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter"
|
||||
)
|
||||
if (!canScroll || filteredResults.isEmpty()) {
|
||||
_automaticNextPageCounter++
|
||||
if (_automaticNextPageCounter < _automaticBackoff.size) {
|
||||
if (_automaticNextPageCounter > 0) {
|
||||
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
||||
val backoff = _automaticBackoff[Math.min(
|
||||
_automaticBackoff.size - 1,
|
||||
_automaticNextPageCounter
|
||||
)];
|
||||
|
||||
val layoutManager = recyclerData.layoutManager
|
||||
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
||||
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
|
||||
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
|
||||
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
|
||||
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
|
||||
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
|
||||
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
|
||||
false;
|
||||
}
|
||||
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
|
||||
false;
|
||||
} else {
|
||||
true;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
|
||||
if (!canScroll || filteredResults.isEmpty()) {
|
||||
_automaticNextPageCounter++
|
||||
if(_automaticNextPageCounter < _automaticBackoff.size) {
|
||||
if(_automaticNextPageCounter > 0) {
|
||||
val automaticNextPageCounterSaved = _automaticNextPageCounter;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Default) {
|
||||
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(true);
|
||||
}
|
||||
delay(backoff.toLong());
|
||||
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
||||
withContext(Dispatchers.Main) {
|
||||
loadNextPage();
|
||||
setLoading(true);
|
||||
}
|
||||
delay(backoff.toLong());
|
||||
if (automaticNextPageCounterSaved == _automaticNextPageCounter) {
|
||||
withContext(Dispatchers.Main) {
|
||||
loadNextPage();
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else
|
||||
loadNextPage();
|
||||
}
|
||||
else
|
||||
loadNextPage();
|
||||
} else {
|
||||
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||
_automaticNextPageCounter = 0;
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
|
||||
_automaticNextPageCounter = 0;
|
||||
}
|
||||
}
|
||||
fun resetAutomaticNextPageCounter(){
|
||||
@@ -481,7 +470,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
recyclerData.resultsUnfiltered.addAll(toAdd);
|
||||
recyclerData.adapter.notifyDataSetChanged();
|
||||
recyclerData.loadedFeedStyle = feedStyle;
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
setLoading(false)
|
||||
if(pager.hasMorePages())
|
||||
ensureEnoughContentVisible(filteredResults)
|
||||
}
|
||||
|
||||
private fun detachPagerEvents() {
|
||||
|
||||
+15
-3
@@ -26,6 +26,7 @@ import com.futo.platformplayer.models.HistoryVideo
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.views.ToggleBar
|
||||
import com.futo.platformplayer.views.adapters.HistoryListViewHolder
|
||||
@@ -243,12 +244,23 @@ class HistoryFragment : MainFragment() {
|
||||
return;
|
||||
}
|
||||
|
||||
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
||||
val diff = v.video.duration - v.position;
|
||||
val vid: Any = if (diff > 5) { v.video.withTimestamp(v.position) } else { v.video };
|
||||
StatePlayer.instance.clearQueue();
|
||||
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
|
||||
|
||||
val playlistId = v.playlistId
|
||||
val playlist = playlistId?.let { StatePlaylists.instance.getPlaylist(it) }
|
||||
val playlistIndex = playlist?.videos?.indexOfFirst { it.url == v.video.url }
|
||||
if (playlist != null && playlistIndex != null && playlistIndex >= 0) {
|
||||
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
|
||||
StatePlayer.instance.setPlaylist(playlist, playlistIndex)
|
||||
|
||||
} else {
|
||||
StatePlayer.instance.clearQueue();
|
||||
_fragment.navigate<VideoDetailFragment>(vid).maximizeVideoDetail();
|
||||
}
|
||||
|
||||
_editSearch.clearFocus();
|
||||
val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
||||
inputMethodManager.hideSoftInputFromWindow(_editSearch.windowToken, 0);
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
|
||||
+3
-1
@@ -365,8 +365,10 @@ class HomeFragment : MainFragment() {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
setPager(pager);
|
||||
if(pager.getResults().isEmpty() && !pager.hasMorePages())
|
||||
if(pager.getResults().isEmpty() && !pager.hasMorePages()) {
|
||||
setLoading(false);
|
||||
setEmptyPager(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-6
@@ -14,6 +14,7 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -22,6 +23,7 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
@@ -319,8 +321,7 @@ class LibraryArtistFragment : MainFragment() {
|
||||
|
||||
_fragment.topBar?.onShown(channel)
|
||||
|
||||
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
|
||||
})
|
||||
val buttons = arrayListOf<Pair<Int, ()->Unit>>();
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch)
|
||||
@@ -337,8 +338,7 @@ class LibraryArtistFragment : MainFragment() {
|
||||
}
|
||||
|
||||
_buttonSubscribe.visibility = GONE;
|
||||
_buttonSubscriptionSettings.visibility =
|
||||
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
|
||||
_buttonSubscriptionSettings.visibility = View.GONE
|
||||
_textChannel.text = channel.name
|
||||
_textChannelSub.text = "${channel.countTracks} songs, ${channel.countAlbums} albums";
|
||||
|
||||
@@ -361,7 +361,21 @@ class LibraryArtistFragment : MainFragment() {
|
||||
(_viewPager.adapter as ArtistViewPagerAdapter).artist = channel
|
||||
|
||||
|
||||
_viewPager.adapter!!.notifyDataSetChanged()
|
||||
_viewPager.adapter!!.notifyDataSetChanged();
|
||||
|
||||
val artistThumbnail = channel.getThumbnailOrAlbum();
|
||||
if(artistThumbnail != null) {
|
||||
_creatorThumbnail.isVisible = true;
|
||||
_creatorThumbnail.setThumbnail(channel.getThumbnailOrAlbum(), true, true);
|
||||
Glide.with(_imageBanner)
|
||||
.load(artistThumbnail)
|
||||
.into(_imageBanner);
|
||||
}
|
||||
else {
|
||||
_creatorThumbnail.isVisible = false;
|
||||
Glide.with(_imageBanner).clear(_imageBanner);
|
||||
}
|
||||
|
||||
|
||||
this.channel = channel
|
||||
}
|
||||
@@ -506,7 +520,10 @@ class LibraryArtistFragment : MainFragment() {
|
||||
|
||||
val playlist = _artist?.toPlaylist();
|
||||
if (playlist != null) {
|
||||
val index = playlist.videos.indexOf(c);
|
||||
val sameVideo = playlist.videos.find { it.name == c.name };
|
||||
val index = sameVideo?.let {
|
||||
playlist.videos.indexOf(sameVideo)
|
||||
} ?: -1;
|
||||
if (index == -1)
|
||||
return@subscribe;
|
||||
|
||||
|
||||
+35
-5
@@ -8,25 +8,32 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.AdhocPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.states.FileEntry
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.buttons.ButtonsContainer
|
||||
|
||||
class LibraryFilesFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
@@ -70,6 +77,7 @@ class LibraryFilesFragment : MainFragment() {
|
||||
private var root: FileEntry? = null;
|
||||
|
||||
constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
disableRefreshLayout();
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any? = null) {
|
||||
@@ -78,6 +86,7 @@ class LibraryFilesFragment : MainFragment() {
|
||||
}
|
||||
fun loadTop() {
|
||||
var initialDirectories = listOf<FileEntry>();
|
||||
var path = "";
|
||||
if(root == null) {
|
||||
initialDirectories = StateLibrary.instance.getFileDirectories();
|
||||
if (initialDirectories.size == 0) {
|
||||
@@ -101,9 +110,10 @@ class LibraryFilesFragment : MainFragment() {
|
||||
it.isVisible = false;
|
||||
}
|
||||
initialDirectories = root?.getSubFiles() ?: listOf();
|
||||
path = root?.path ?: "";
|
||||
}
|
||||
navStack.clear();
|
||||
val entry = FileStack("", initialDirectories);
|
||||
val entry = FileStack(path, initialDirectories);
|
||||
navStack.add(entry);
|
||||
openDirectory(navStack.last());
|
||||
fragment.topBar?.let {
|
||||
@@ -114,11 +124,10 @@ class LibraryFilesFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
fun leaveDirectory() {
|
||||
if(navStack.size > 1) {
|
||||
navStack.removeLast();
|
||||
openDirectory(navStack.last());
|
||||
if (navStack.size > 1) {
|
||||
navStack.removeAt(navStack.size - 1)
|
||||
openDirectory(navStack.last())
|
||||
}
|
||||
else {}
|
||||
}
|
||||
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
|
||||
if(addToStack)
|
||||
@@ -139,6 +148,27 @@ class LibraryFilesFragment : MainFragment() {
|
||||
setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files));
|
||||
setLoading(false);
|
||||
|
||||
val allSongs = stack.files.filter { !it.isDirectory };
|
||||
if(allSongs.any()) {
|
||||
_bottomContentView.addView(ButtonsContainer(context,
|
||||
listOf(
|
||||
ButtonsContainer.Button("Play All", R.drawable.background_button_primary) {
|
||||
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
|
||||
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
|
||||
}), focus = true, shuffle = false)
|
||||
},
|
||||
ButtonsContainer.Button("Shuffle", R.drawable.background_button_accent) {
|
||||
StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map {
|
||||
SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path))
|
||||
}), focus = true, shuffle = true)
|
||||
}
|
||||
)).apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
})
|
||||
}
|
||||
else
|
||||
_bottomContentView.removeAllViews();
|
||||
|
||||
fragment.topBar?.let {
|
||||
if(it is FilesTopBarFragment) {
|
||||
if(navStack.size > 1)
|
||||
|
||||
+141
-45
@@ -2,6 +2,7 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.AttributeSet
|
||||
@@ -11,11 +12,13 @@ import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.collection.emptyLongSet
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -34,6 +37,7 @@ import com.futo.platformplayer.views.AnyInsertedAdapterView
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
|
||||
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
|
||||
import com.futo.platformplayer.views.LibrarySection
|
||||
import com.futo.platformplayer.views.NoResultsView
|
||||
import com.futo.platformplayer.views.adapters.AnyAdapter
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
|
||||
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
|
||||
@@ -41,6 +45,9 @@ import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
|
||||
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Dispatcher
|
||||
|
||||
|
||||
class LibraryFragment : MainFragment() {
|
||||
@@ -93,14 +100,18 @@ class LibraryFragment : MainFragment() {
|
||||
UIDialogs.showDialog(requireContext(), R.drawable.ic_library,
|
||||
"Music permissions", "We require permissions to see your on-device music, denying this will hide the option to see local music.", null, 1,
|
||||
UIDialogs.Action("Ok", {
|
||||
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
||||
StateApp?.instance?.activity?.requestPermissionAudio {
|
||||
setPermissionResultAudio(it);
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action("Cancel", {
|
||||
|
||||
}, UIDialogs.ActionStyle.NONE));
|
||||
}
|
||||
else -> {
|
||||
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
||||
StateApp?.instance?.activity?.requestPermissionAudio {
|
||||
setPermissionResultAudio(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,24 +124,22 @@ class LibraryFragment : MainFragment() {
|
||||
UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false,
|
||||
"Videos permissions", "We require permissions to see your on-device videos, denying this will hide the option to see local videos.", null, 1,
|
||||
UIDialogs.Action("Ok", {
|
||||
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
||||
StateApp?.instance?.activity?.requestPermissionVideo {
|
||||
setPermissionResultVideo(it);
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action("Cancel", {
|
||||
|
||||
}, UIDialogs.ActionStyle.NONE));
|
||||
}
|
||||
else -> {
|
||||
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
||||
StateApp?.instance?.activity?.requestPermissionVideo {
|
||||
setPermissionResultVideo(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
setPermissionResultAudio(isGranted);
|
||||
});
|
||||
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||
setPermissionResultVideo(isGranted);
|
||||
});
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LibraryFragment().apply {}
|
||||
@@ -144,11 +153,12 @@ class LibraryFragment : MainFragment() {
|
||||
var sectionAlbums: LibrarySection;
|
||||
var sectionVideos: LibrarySection;
|
||||
var sectionFiles: LibrarySection;
|
||||
var noContent: NoResultsView;
|
||||
//var buttonFiles: BigButton;
|
||||
|
||||
val recycler: RecyclerView;
|
||||
|
||||
val adapterFiles: AnyInsertedAdapterView<FileEntry, FileViewHolder>;
|
||||
var adapterFiles: AnyInsertedAdapterView<FileEntry, FileViewHolder>? = null;
|
||||
|
||||
//var metaInfo: TextView;
|
||||
|
||||
@@ -184,6 +194,9 @@ class LibraryFragment : MainFragment() {
|
||||
//buttonFiles = findViewById<BigButton>(R.id.button_files);
|
||||
//metaInfo = findViewById(R.id.meta_info);
|
||||
|
||||
noContent = NoResultsView(context, "No directories", "No directories have been added.\nAdd them using the (+) icon.", -1, listOf());
|
||||
noContent.isVisible = false;
|
||||
|
||||
this.allowMusic = allowMusic ?: false;
|
||||
this.allowVideo = allowVideo ?: false;
|
||||
|
||||
@@ -193,14 +206,6 @@ class LibraryFragment : MainFragment() {
|
||||
else
|
||||
fragment.requestPermissionMusic();
|
||||
});
|
||||
val adapterArtists = sectionArtists.getAnyAdapter<Artist, ArtistTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryArtistFragment>(it);
|
||||
}
|
||||
});
|
||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
||||
adapterArtists.setData(artists);
|
||||
|
||||
sectionAlbums.setSection("Albums", {
|
||||
if(this.allowMusic)
|
||||
@@ -208,14 +213,6 @@ class LibraryFragment : MainFragment() {
|
||||
else
|
||||
fragment.requestPermissionMusic();
|
||||
});
|
||||
val adapterAlbums = sectionAlbums.getAnyAdapter<Album, AlbumTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryAlbumFragment>(it);
|
||||
}
|
||||
});
|
||||
val albums = StateLibrary.instance.getAlbums();
|
||||
adapterAlbums.setData(albums);
|
||||
|
||||
|
||||
sectionVideos.setSection("Videos", {
|
||||
@@ -224,21 +221,118 @@ class LibraryFragment : MainFragment() {
|
||||
else
|
||||
fragment.requestPermissionVideo();
|
||||
});
|
||||
|
||||
reloadLibraryUI();
|
||||
|
||||
|
||||
/*
|
||||
buttonFiles.onClick.subscribe {
|
||||
fragment.navigate<LibraryFilesFragment>()
|
||||
} */
|
||||
//buttonFiles.setButtonEnabled(false);
|
||||
setMusicPermissions(allowMusic ?: false);
|
||||
setVideoPermissions(allowVideo ?: false);
|
||||
}
|
||||
|
||||
fun reloadFiles() {
|
||||
val files = StateLibrary.instance.getFileDirectories();
|
||||
adapterFiles?.setData(files);
|
||||
if(files.size == 0) {
|
||||
noContent.isVisible = true;
|
||||
}
|
||||
else
|
||||
noContent.isVisible = false;
|
||||
}
|
||||
|
||||
fun reloadLibraryUI() {
|
||||
|
||||
val adapterAlbums = sectionAlbums.getAnyAdapter<Album, AlbumTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryAlbumFragment>(it);
|
||||
}
|
||||
});
|
||||
val adapterArtists = sectionArtists.getAnyAdapter<Artist, ArtistTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<LibraryArtistFragment>(it);
|
||||
}
|
||||
});
|
||||
val adapterVideos = sectionVideos.getAnyAdapter<IPlatformVideo, LocalVideoTileViewHolder>({
|
||||
it.onClick.subscribe {
|
||||
if(it != null)
|
||||
fragment.navigate<VideoDetailFragment>(it);
|
||||
}
|
||||
});
|
||||
val videos = StateLibrary.instance.getRecentVideos(null, 20);
|
||||
adapterVideos.setData(videos);
|
||||
|
||||
if(this.allowMusic) {
|
||||
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
|
||||
adapterArtists.setData(artists);
|
||||
if (artists.size == 0)
|
||||
sectionArtists.setEmpty(
|
||||
"No artists",
|
||||
"No artists were found on your device",
|
||||
-1
|
||||
);
|
||||
else
|
||||
sectionArtists.clearEmpty();
|
||||
}
|
||||
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
sectionAlbums.isVisible = false;
|
||||
}
|
||||
else {
|
||||
sectionArtists.setEmpty(
|
||||
"No Music Permissions",
|
||||
"You have not granted music access permissions to Grayjay",
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
if(this.allowMusic) {
|
||||
val albums = StateLibrary.instance.getAlbums();
|
||||
adapterAlbums.setData(albums);
|
||||
if (albums.size == 0)
|
||||
sectionAlbums.setEmpty("No albums", "No albums were found on your device", -1);
|
||||
else
|
||||
sectionAlbums.clearEmpty();
|
||||
}
|
||||
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
sectionArtists.isVisible = false;
|
||||
}
|
||||
else {
|
||||
sectionAlbums.setEmpty(
|
||||
"No Music Permissions",
|
||||
"You have not granted music access permissions to Grayjay",
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
if(this.allowVideo) {
|
||||
val videos = StateLibrary.instance.getRecentVideos(null, 20);
|
||||
adapterVideos.setData(videos);
|
||||
if (videos.size == 0)
|
||||
sectionVideos.setEmpty("No videos", "No videos were found on your device", -1);
|
||||
else
|
||||
sectionVideos.clearEmpty();
|
||||
}
|
||||
else if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
sectionVideos.isVisible = false;
|
||||
}
|
||||
else {
|
||||
sectionVideos.setEmpty(
|
||||
"No Video Permissions",
|
||||
"You have not granted video access permissions to Grayjay",
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
adapterFiles = recycler.asAnyWithViews<FileEntry, FileViewHolder>(
|
||||
arrayListOf(
|
||||
sectionArtists,
|
||||
sectionAlbums,
|
||||
sectionVideos,
|
||||
sectionFiles
|
||||
sectionFiles,
|
||||
noContent
|
||||
),
|
||||
arrayListOf(View(context).apply { this.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 20.dp(resources)) }),
|
||||
RecyclerView.VERTICAL, false, {
|
||||
@@ -255,23 +349,8 @@ class LibraryFragment : MainFragment() {
|
||||
}
|
||||
);
|
||||
reloadFiles();
|
||||
|
||||
|
||||
/*
|
||||
buttonFiles.onClick.subscribe {
|
||||
fragment.navigate<LibraryFilesFragment>()
|
||||
} */
|
||||
//buttonFiles.setButtonEnabled(false);
|
||||
setMusicPermissions(allowMusic ?: false);
|
||||
setVideoPermissions(allowVideo ?: false);
|
||||
}
|
||||
|
||||
fun reloadFiles() {
|
||||
val files = StateLibrary.instance.getFileDirectories();
|
||||
adapterFiles.setData(files);
|
||||
}
|
||||
|
||||
|
||||
fun setMusicPermissions(access: Boolean) {
|
||||
allowMusic = access;
|
||||
sectionAlbums.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
|
||||
@@ -281,6 +360,10 @@ class LibraryFragment : MainFragment() {
|
||||
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
|
||||
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
|
||||
//).filterNotNull().joinToString("\n");
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
reloadLibraryUI();
|
||||
}
|
||||
}
|
||||
fun setVideoPermissions(access: Boolean) {
|
||||
allowVideo = access;
|
||||
@@ -289,9 +372,22 @@ class LibraryFragment : MainFragment() {
|
||||
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
|
||||
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
|
||||
//).filterNotNull().joinToString("\n");
|
||||
// }
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
reloadLibraryUI();
|
||||
}
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
if(didShowAlpha)
|
||||
return;
|
||||
didShowAlpha = true;
|
||||
UIDialogs.appToast("Library is in alpha\nImprovements are coming to local media playback.")
|
||||
}
|
||||
companion object {
|
||||
var didShowAlpha: Boolean = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
-1
@@ -96,7 +96,6 @@ class LibraryVideosFragment : MainFragment() {
|
||||
fun onShown() {
|
||||
val initialAlbums = StateLibrary.instance.getAlbums();
|
||||
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
|
||||
val buckets = StateLibrary.instance.getVideoBucketNames();
|
||||
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
|
||||
}
|
||||
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.text.matches
|
||||
|
||||
|
||||
class LoginFragment : MainFragment() {
|
||||
override val isMainView : Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var view: FragView? = null;
|
||||
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val newView = FragView(this);
|
||||
view = newView;
|
||||
return newView;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
view?.onShown(parameter ?: throw IllegalArgumentException("No parameter for login"));
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
view = null;
|
||||
super.onDestroyMainView();
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = LoginFragment().apply {}
|
||||
|
||||
private var _callback: ((SourceAuth?) -> Unit)? = null;
|
||||
fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) {
|
||||
if(_callback != null) _callback?.invoke(null);
|
||||
_callback = callback;
|
||||
StateApp.instance.activity?.navigate<LoginFragment>(config, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FragView: ConstraintLayout {
|
||||
val fragment: LoginFragment;
|
||||
|
||||
private val _webView: WebView;
|
||||
private val _textUrl: TextView;
|
||||
private val _buttonClose: ImageButton;
|
||||
|
||||
constructor(fragment: LoginFragment) : super(fragment.requireContext()) {
|
||||
inflate(context, R.layout.activity_login, this);
|
||||
this.fragment = fragment;
|
||||
|
||||
_textUrl = findViewById(R.id.text_url);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonClose.setOnClickListener {
|
||||
UIDialogs.toast("Login cancelled", false);
|
||||
fragment.close(true);
|
||||
}
|
||||
|
||||
|
||||
_webView = findViewById(R.id.web_view);
|
||||
_webView.settings.javaScriptEnabled = true;
|
||||
CookieManager.getInstance().setAcceptCookie(true);
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any) {
|
||||
|
||||
|
||||
val config = parameter as? SourcePluginConfig;
|
||||
|
||||
val authConfig = if(config != null)
|
||||
config.authentication ?: throw IllegalStateException("Plugin has no authentication support");
|
||||
else if(parameter is SourcePluginAuthConfig)
|
||||
parameter
|
||||
else throw IllegalStateException("No valid configuration?");
|
||||
//TODO: Backwards compat removal?
|
||||
|
||||
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
||||
_webView.settings.useWideViewPort = true;
|
||||
_webView.settings.loadWithOverviewMode = true;
|
||||
|
||||
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
|
||||
|
||||
webViewClient.onLogin.subscribe { auth ->
|
||||
_callback?.let {
|
||||
_callback = null;
|
||||
it.invoke(auth);
|
||||
}
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
fragment.close(true);
|
||||
}catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to close login", ex);
|
||||
}
|
||||
}
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
|
||||
var currentScale = 100;
|
||||
var currentDesktop = false;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(loginWarnings.size > 0 && url != null) {
|
||||
synchronized(loginWarnings) {
|
||||
val warning = loginWarnings.find { url.matches(it.getRegex()) };
|
||||
if(warning != null) {
|
||||
if(warning.once == true)
|
||||
loginWarnings.remove(warning);
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||
UIDialogs.Action("Understood", {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
|
||||
if(!authConfig.loginButton.isNullOrEmpty() && authConfig.loginButton.matches(REGEX_LOGIN_BUTTON)) {
|
||||
Logger.i(TAG, "Clicking login button [${authConfig.loginButton}]");
|
||||
//TODO: Find most reliable way to wait for page js to finish
|
||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||
}
|
||||
}
|
||||
_webView.settings.domStorageEnabled = true;
|
||||
|
||||
_webView.webViewClient = webViewClient;
|
||||
_webView.loadUrl(authConfig.loginUrl);
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "LoginFragment";
|
||||
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
@@ -363,6 +364,7 @@ class RemotePlaylistFragment : MainFragment() {
|
||||
_imagePlaylistThumbnail.let {
|
||||
Glide.with(it)
|
||||
.load(video.thumbnails.getHQThumbnail())
|
||||
.withMaxSizePx()
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.crossfade()
|
||||
.into(it);
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.WindowManager
|
||||
@@ -13,10 +15,15 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -71,6 +78,7 @@ import com.futo.platformplayer.views.video.FutoShortPlayer
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.Models
|
||||
@@ -851,9 +859,8 @@ class ShortView : FrameLayout {
|
||||
}
|
||||
|
||||
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
|
||||
/*
|
||||
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
|
||||
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
|
||||
.load(thumbnail).withMaxSizePx().into(object : CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
player.setArtwork(resource.toDrawable(resources))
|
||||
}
|
||||
@@ -863,7 +870,6 @@ class ShortView : FrameLayout {
|
||||
}
|
||||
})
|
||||
else player.setArtwork(null)
|
||||
*/
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
|
||||
+26
-4
@@ -453,7 +453,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
}.apply {
|
||||
this.alpha = 0.5f;
|
||||
},*/
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Reinstall the original version that was embedded with this version of Grayjay", R.drawable.ic_refresh) {
|
||||
val embeddedConfig = StatePlugins.instance.getEmbeddedPluginConfigFromID(context, config.id);
|
||||
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>${embeddedConfig?.version})?",
|
||||
@@ -467,7 +467,29 @@ class SourceDetailFragment : MainFragment() {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
} else null
|
||||
} else
|
||||
BigButton(c, "Reinstall", "Reinstall the current version from the remote repository", R.drawable.ic_refresh) {
|
||||
var newConfig: SourcePluginConfig? = null;
|
||||
try {
|
||||
newConfig = StatePlugins.instance.requestConfig(config?.sourceUrl ?: throw IllegalArgumentException("No config"));
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to fetch new plugin config", ex);
|
||||
}
|
||||
UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>)?",
|
||||
"This will revert the plugin back to the originally embedded version.\nVersion change: ${config.version}=>${newConfig?.version}", null,
|
||||
0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Reinstall", {
|
||||
val url = config.sourceUrl ?: return@Action;
|
||||
StatePlugins.instance.installPlugin(context, fragment.lifecycleScope, url) {
|
||||
reloadSource(config.id);
|
||||
UIDialogs.toast(context, "Plugin reinstalled, may require refresh");
|
||||
}
|
||||
}, UIDialogs.ActionStyle.DANGEROUS));
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
}
|
||||
)
|
||||
|
||||
_sourceAdvancedButtons.removeAllViews();
|
||||
@@ -486,7 +508,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
config.authentication.loginWarning, null, 0,
|
||||
UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action("Login", {
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
reloadSource(config.id);
|
||||
@@ -500,7 +522,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
}, UIDialogs.ActionStyle.PRIMARY))
|
||||
}
|
||||
else
|
||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
LoginFragment.showLogin(config) {//LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||
try {
|
||||
StatePlugins.instance.setPluginAuth(config.id, it);
|
||||
reloadSource(config.id);
|
||||
|
||||
+36
-27
@@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
|
||||
private var _isActive: Boolean = false;
|
||||
|
||||
private var _viewDetail : VideoDetailView? = null;
|
||||
var _viewDetail : VideoDetailView? = null;
|
||||
private var _view : SingleViewTouchableMotionLayout? = null;
|
||||
|
||||
var isFullscreen : Boolean = false;
|
||||
@@ -356,38 +356,46 @@ class VideoDetailFragment() : MainFragment() {
|
||||
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
|
||||
_viewDetail?.stopAllGestures()
|
||||
|
||||
if (state != State.MINIMIZED && progress < 0.1) {
|
||||
state = State.MINIMIZED;
|
||||
isMinimizingFromFullScreen = false
|
||||
onMinimize.emit();
|
||||
}
|
||||
else if (state != State.MAXIMIZED && progress > 0.9) {
|
||||
if (_isInitialMaximize) {
|
||||
state = State.CLOSED;
|
||||
_isInitialMaximize = false;
|
||||
}
|
||||
else {
|
||||
state = State.MAXIMIZED;
|
||||
onMaximized.emit();
|
||||
}
|
||||
}
|
||||
|
||||
if (isTransitioning && (progress > 0.95 || progress < 0.05)) {
|
||||
isTransitioning = false;
|
||||
onTransitioning.emit(isTransitioning);
|
||||
|
||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||
}
|
||||
else if (!isTransitioning && (progress < 0.95 && progress > 0.05)) {
|
||||
if (!isTransitioning && (progress < 0.9 && progress > 0.1)) {
|
||||
isTransitioning = true;
|
||||
onTransitioning.emit(isTransitioning);
|
||||
|
||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||
}
|
||||
}
|
||||
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { }
|
||||
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
|
||||
val progress = motionLayout?.progress ?: return;
|
||||
|
||||
if (state != State.MINIMIZED && progress < 0.1) {
|
||||
state = State.MINIMIZED;
|
||||
isMinimizingFromFullScreen = false
|
||||
onMinimize.emit();
|
||||
}
|
||||
else if (state != State.MAXIMIZED && progress > 0.9) {
|
||||
state = State.MAXIMIZED;
|
||||
onMaximized.emit();
|
||||
/*
|
||||
if (_isInitialMaximize) {
|
||||
//state = State.CLOSED; Causes issues? might no longer be needed
|
||||
_isInitialMaximize = false;
|
||||
}
|
||||
else {
|
||||
state = State.MAXIMIZED;
|
||||
onMaximized.emit();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
if (isTransitioning && (progress > 0.6 || progress < 0.4)) {
|
||||
isTransitioning = false;
|
||||
onTransitioning.emit(isTransitioning);
|
||||
|
||||
if(isInPictureInPicture) leavePictureInPictureMode(false); //Workaround to prevent getting stuck in p2p
|
||||
}
|
||||
}
|
||||
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) { }
|
||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) { }
|
||||
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {
|
||||
}
|
||||
});
|
||||
|
||||
_view?.let {
|
||||
@@ -446,7 +454,8 @@ class VideoDetailFragment() : MainFragment() {
|
||||
if (viewDetail.shouldEnterPictureInPicture) {
|
||||
_leavingPiP = false
|
||||
}
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
|
||||
val shouldPiP = Settings.instance.playback.isBackgroundPictureInPicture()
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && shouldPiP && !viewDetail.isAudioOnlyUserAction) {
|
||||
val params = _viewDetail?.getPictureInPictureParams();
|
||||
if(params != null) {
|
||||
Logger.i(TAG, "enterPictureInPictureMode")
|
||||
|
||||
+81
-20
@@ -42,6 +42,7 @@ import androidx.media3.datasource.HttpDataSource
|
||||
import androidx.media3.ui.PlayerControlView
|
||||
import androidx.media3.ui.TimeBar
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
@@ -55,6 +56,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
@@ -77,6 +79,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
@@ -159,6 +162,7 @@ import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayer
|
||||
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
|
||||
import com.futo.platformplayer.views.videometa.UpNextView
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.Models
|
||||
@@ -175,6 +179,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import userpackage.Protocol
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
@@ -549,12 +554,12 @@ class VideoDetailView : ConstraintLayout {
|
||||
_buttonMore = buttonMore;
|
||||
updateMoreButtons();
|
||||
|
||||
val handleLoaderGameVisibilityChanged = { b: Boolean ->
|
||||
val handleLoaderGameVisibilityChanged: (Boolean) -> Unit = { b: Boolean ->
|
||||
_loaderGameVisible = b
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||
}
|
||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||
}
|
||||
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
|
||||
@@ -563,6 +568,18 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (video is TutorialFragment.TutorialVideo) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
if(video is LocalVideoDetails) {
|
||||
video?.author?.let {
|
||||
if(it.url.startsWith("content://media/external/audio/artists")) {
|
||||
fragment.navigate<LibraryArtistFragment>(it.url);
|
||||
fragment.lifecycleScope.launch {
|
||||
delay(100);
|
||||
fragment.minimizeVideoDetail();
|
||||
};
|
||||
}
|
||||
}
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
(video?.author ?: _searchVideo?.author)?.let {
|
||||
fragment.navigate<ChannelFragment>(it);
|
||||
@@ -625,6 +642,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
_player.onSourceChanged.subscribe(::onSourceChanged);
|
||||
_player.onSourceEnded.subscribe {
|
||||
if (_isCasting) {
|
||||
Logger.i(TAG, "Ignoring onSourceEnded because casting is active")
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
if (!fragment.isInPictureInPicture) {
|
||||
_player.gestureControl.showControls(false);
|
||||
}
|
||||
@@ -704,6 +726,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
val v = video;
|
||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
||||
Log.i(TAG, "Next video (loop?)")
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
@@ -1035,7 +1058,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_slideUpOverlay?.hide();
|
||||
}
|
||||
else null,
|
||||
if(!isLimitedVersion && !(video?.isLive ?: false))
|
||||
if(!isLimitedVersion && !(video?.isLive ?: false) && !(video is LocalVideoDetails))
|
||||
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
|
||||
video?.let {
|
||||
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
|
||||
@@ -1058,15 +1081,16 @@ class VideoDetailView : ConstraintLayout {
|
||||
_slideUpOverlay?.hide();
|
||||
}
|
||||
else null,
|
||||
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
|
||||
if(!(video is LocalVideoDetails))
|
||||
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
|
||||
video?.let {
|
||||
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
|
||||
fragment.navigate<BrowserFragment>(url);
|
||||
fragment.minimizeVideoDetail();
|
||||
};
|
||||
_slideUpOverlay?.hide();
|
||||
},
|
||||
if (StateSync.instance.hasAuthorizedDevice()) {
|
||||
} else null,
|
||||
if (StateSync.instance.hasAuthorizedDevice() && !(video is LocalVideoDetails)) {
|
||||
RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) {
|
||||
val devices = StateSync.instance.getAuthorizedSessions();
|
||||
val videoToSend = video ?: return@RoundButton;
|
||||
@@ -1089,10 +1113,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
})
|
||||
}
|
||||
}} else null,
|
||||
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
|
||||
if(!(video is LocalVideoDetails))
|
||||
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
|
||||
reloadVideo();
|
||||
_slideUpOverlay?.hide();
|
||||
}).filterNotNull();
|
||||
} else null).filterNotNull();
|
||||
if(!_buttonPinStore.getAllValues().any())
|
||||
_buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray());
|
||||
else {
|
||||
@@ -1327,7 +1352,22 @@ class VideoDetailView : ConstraintLayout {
|
||||
return;
|
||||
//Loop workaround
|
||||
if(bypassSameVideoCheck && this.video?.url == video.url && StatePlayer.instance.loopVideo) {
|
||||
_player.seekTo(0);
|
||||
Log.i(TAG, "Loop")
|
||||
if (_isCasting) {
|
||||
Log.i(TAG, "Loop casting")
|
||||
StateCasting.instance.activeDevice?.seekTo(0.0)
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
delay(300)
|
||||
StateCasting.instance.activeDevice?.resumePlayback()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to resume", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Loop player")
|
||||
_player.seekTo(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1355,6 +1395,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_minimize_title.text = video.name;
|
||||
_minimize_meta.text = video.author.name;
|
||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||
Log.i(TAG, "setCurrentlyPlaying (setVideoOverview) ${video.url} (${video.name})")
|
||||
|
||||
val subTitleSegments : ArrayList<String> = ArrayList();
|
||||
if(video.viewCount > 0)
|
||||
@@ -1624,7 +1665,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_buttonSubscribe.setSubscribeChannel(video.author.url);
|
||||
setDescription(video.description.fixHtmlLinks());
|
||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false);
|
||||
_creatorThumbnail.setThumbnail(video.author.thumbnail, false,
|
||||
video is LocalVideoDetails
|
||||
);
|
||||
setPolycentricProfile(null, animate = false);
|
||||
_taskLoadPolycentricProfile.run(video.author.id);
|
||||
|
||||
@@ -1652,7 +1695,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_rating.visibility = View.GONE;
|
||||
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
if (StatePolycentric.instance.enabled && !(video is LocalVideoDetails)) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(
|
||||
@@ -1712,7 +1755,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1777,7 +1822,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
false,
|
||||
(toResume.toFloat() / 1000.0f).toLong(),
|
||||
null,
|
||||
true
|
||||
true,
|
||||
StatePlayer.instance.playlistId
|
||||
);
|
||||
Logger.i(
|
||||
TAG,
|
||||
@@ -1789,6 +1835,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
Log.i(TAG, "setCurrentlyPlaying (nextVideo) ${video.url} (${video.name})")
|
||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||
|
||||
_liveChat?.stop();
|
||||
@@ -1810,17 +1857,19 @@ class VideoDetailView : ConstraintLayout {
|
||||
_player.updateNextPrevious();
|
||||
updateMoreButtons();
|
||||
|
||||
if (videoDetail is TutorialFragment.TutorialVideo) {
|
||||
if (videoDetail is TutorialFragment.TutorialVideo || videoDetail is LocalVideoDetails) {
|
||||
_buttonSubscribe.visibility = View.GONE
|
||||
_buttonMore.visibility = View.GONE
|
||||
_buttonPins.visibility = View.GONE
|
||||
_buttonMore.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
|
||||
_buttonPins.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE;
|
||||
_layoutRating.visibility = View.GONE
|
||||
_rating.visibility = View.GONE;
|
||||
_layoutChangeBottomSection.visibility = View.GONE
|
||||
} else {
|
||||
_buttonSubscribe.visibility = View.VISIBLE
|
||||
_buttonMore.visibility = View.VISIBLE
|
||||
_buttonPins.visibility = View.VISIBLE
|
||||
_layoutRating.visibility = View.VISIBLE
|
||||
_rating.visibility = View.VISIBLE;
|
||||
_layoutChangeBottomSection.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
@@ -2002,7 +2051,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
} else {
|
||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
||||
Glide.with(context).asBitmap().load(thumbnail)
|
||||
Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx()
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
_player.setArtwork(BitmapDrawable(resources, resource));
|
||||
@@ -2287,6 +2336,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
checkAndRemoveWatchLater();
|
||||
|
||||
var next = StatePlayer.instance.nextQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9, bypassVideoLoop);
|
||||
Log.i(TAG, "next queue item ${next?.url} (${next?.name})")
|
||||
|
||||
val autoplayVideo = _autoplayVideo
|
||||
if (next == null && autoplayVideo != null && StatePlayer.instance.autoplay) {
|
||||
Logger.i(TAG, "Found autoplay video!")
|
||||
@@ -2299,11 +2350,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(next == null && forceLoop)
|
||||
next = StatePlayer.instance.restartQueue();
|
||||
if(next != null) {
|
||||
Logger.i(TAG, "Set video overview (next = ${next.url} (${next.name}))")
|
||||
setVideoOverview(next, true, 0, true);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
else {
|
||||
Log.i(TAG, "setCurrentlyPlaying (nextVideo) null")
|
||||
StatePlayer.instance.setCurrentlyPlaying(null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2684,7 +2738,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
private fun fetchComments() {
|
||||
Logger.i(TAG, "fetchComments")
|
||||
video?.let {
|
||||
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
|
||||
if(video is LocalVideoDetails) {
|
||||
_commentsList.clearComments();
|
||||
}
|
||||
else
|
||||
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
|
||||
}
|
||||
}
|
||||
private fun fetchPolycentricComments() {
|
||||
@@ -2971,6 +3029,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
onChannelClicked.subscribe {
|
||||
Logger.i(TAG, "Opening channel url: ${it.url}");
|
||||
if(it.url.isNotBlank()) {
|
||||
fragment.minimizeVideoDetail()
|
||||
fragment.navigate<ChannelFragment>(it)
|
||||
@@ -3095,7 +3154,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (v !is TutorialFragment.TutorialVideo) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val history = getHistoryIndex(v) ?: return@launch;
|
||||
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong(), null, true);
|
||||
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong(), null, true, StatePlayer.instance.playlistId);
|
||||
}
|
||||
}
|
||||
_lastPositionSaveTime = currentTime;
|
||||
@@ -3300,9 +3359,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
false
|
||||
else {
|
||||
isLoginStop = true;
|
||||
onMinimize.emit();
|
||||
StatePlugins.instance.loginPlugin(context, id) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
fetchVideo();
|
||||
onMaximize.emit(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -14,6 +14,7 @@ import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.setPadding
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
@@ -28,6 +29,7 @@ import com.futo.platformplayer.toHumanDuration
|
||||
import com.futo.platformplayer.toHumanTime
|
||||
import com.futo.platformplayer.views.SearchView
|
||||
import com.futo.platformplayer.views.lists.VideoListEditorView
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
abstract class VideoListEditorView : LinearLayout {
|
||||
private var _videoListEditorView: VideoListEditorView;
|
||||
@@ -211,6 +213,7 @@ abstract class VideoListEditorView : LinearLayout {
|
||||
_imagePlaylistThumbnail.let {
|
||||
Glide.with(it)
|
||||
.load(video.thumbnails.getHQThumbnail())
|
||||
.withMaxSizePx()
|
||||
.placeholder(R.drawable.placeholder_video_thumbnail)
|
||||
.crossfade()
|
||||
.into(it);
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.fragment.mainactivity.topbar
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() {
|
||||
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
|
||||
} else if (currentMain is LibraryFragment) {
|
||||
navigate<LibrarySearchFragment>();
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
UIDialogs.toast("Your Android version is too old for Mediastore search", true);
|
||||
}
|
||||
else
|
||||
navigate<LibrarySearchFragment>();
|
||||
} else {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.withMaxSizePx
|
||||
|
||||
class GlideHelper {
|
||||
|
||||
@@ -14,7 +16,7 @@ class GlideHelper {
|
||||
fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder<Drawable>) -> Unit)? = null) {
|
||||
val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail();
|
||||
|
||||
val req = Glide.with(this).load(url);
|
||||
val req = Glide.with(this).load(url).withMaxSizePx()
|
||||
|
||||
if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads?
|
||||
val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail();
|
||||
|
||||
@@ -14,15 +14,17 @@ import java.time.ZoneOffset
|
||||
class HistoryVideo {
|
||||
var video: SerializedPlatformVideo;
|
||||
var position: Long;
|
||||
var playlistId: String? = null
|
||||
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
|
||||
var date: OffsetDateTime;
|
||||
|
||||
|
||||
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime) {
|
||||
constructor(video: SerializedPlatformVideo, position: Long, date: OffsetDateTime, playlistId: String?) {
|
||||
this.video = video;
|
||||
this.position = position;
|
||||
this.date = date;
|
||||
this.playlistId = playlistId
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +61,7 @@ class HistoryVideo {
|
||||
viewCount = -1
|
||||
);
|
||||
|
||||
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC));
|
||||
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,6 @@ data class Telemetry(
|
||||
val brand: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val sdkVersion: Int
|
||||
val sdkVersion: Int,
|
||||
val plugins: List<String>? = null
|
||||
) { }
|
||||
@@ -29,14 +29,25 @@ class HLS {
|
||||
val mediaRenditions = mutableListOf<MediaRendition>()
|
||||
val sessionDataList = mutableListOf<SessionData>()
|
||||
var independentSegments = false
|
||||
var version: Int? = null
|
||||
var mediaSequence: Long? = null
|
||||
val unhandled = mutableListOf<String>()
|
||||
|
||||
masterPlaylistContent.lines().forEachIndexed { index, line ->
|
||||
val lines = masterPlaylistContent.lines()
|
||||
lines.forEachIndexed { index, line ->
|
||||
when {
|
||||
line.startsWith("#EXT-X-VERSION:") -> {
|
||||
version = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
|
||||
mediaSequence = line.substringAfter(":").toLongOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-STREAM-INF") -> {
|
||||
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
|
||||
val nextLine = lines.getOrNull(index + 1)
|
||||
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
|
||||
val url = resolveUrl(baseUrl, nextLine)
|
||||
|
||||
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
|
||||
}
|
||||
|
||||
@@ -52,10 +63,14 @@ class HLS {
|
||||
val sessionData = parseSessionData(line)
|
||||
sessionDataList.add(sessionData)
|
||||
}
|
||||
|
||||
else -> {
|
||||
unhandled.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
|
||||
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments, version = version, mediaSequence = mediaSequence, unhandled = unhandled)
|
||||
}
|
||||
|
||||
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
|
||||
@@ -83,62 +98,189 @@ class HLS {
|
||||
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
|
||||
}
|
||||
|
||||
private fun parseByteRange(value: String): Pair<Long, Long> {
|
||||
val trimmed = value.trim()
|
||||
require(trimmed.isNotEmpty()) { "Empty BYTERANGE value" }
|
||||
|
||||
val parts = trimmed.split('@')
|
||||
val length = parts[0].toLong()
|
||||
require(length >= 0) { "Invalid BYTERANGE length '$value'" }
|
||||
|
||||
val start = if (parts.size > 1) {
|
||||
val s = parts[1].toLong()
|
||||
require(s >= 0) { "Invalid BYTERANGE offset '$value'" }
|
||||
s
|
||||
} else {
|
||||
-1L
|
||||
}
|
||||
|
||||
return length to start
|
||||
}
|
||||
|
||||
|
||||
private fun parseAttributes(content: String): Map<String, String> {
|
||||
val index = content.indexOf(':')
|
||||
if (index < 0 || index == content.length - 1) return emptyMap()
|
||||
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
val maybeAttributePairs = content.substring(index + 1).splitToSequence(',')
|
||||
|
||||
var currentPair = StringBuilder()
|
||||
for (pair in maybeAttributePairs) {
|
||||
currentPair.append(pair)
|
||||
if (currentPair.count { it == '\"' } % 2 == 0) {
|
||||
val full = currentPair.toString()
|
||||
val key = full.substringBefore("=")
|
||||
val value = full.substringAfter("=")
|
||||
attributes[key.trim()] = value.trim().removeSurrounding("\"")
|
||||
currentPair = StringBuilder()
|
||||
} else {
|
||||
currentPair.append(',')
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
|
||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||
val lines = content.lines()
|
||||
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull()
|
||||
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull()
|
||||
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
|
||||
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
|
||||
}
|
||||
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
|
||||
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
|
||||
|
||||
val keyInfo =
|
||||
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
|
||||
|
||||
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
|
||||
val iv =
|
||||
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
|
||||
|
||||
val decryptionInfo: DecryptionInfo? = key?.let { k ->
|
||||
DecryptionInfo(k, iv)
|
||||
}
|
||||
|
||||
val initSegment =
|
||||
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
|
||||
?.substringAfter("=")?.trim('"')
|
||||
var version: Int? = null
|
||||
var targetDuration: Int? = null
|
||||
var mediaSequence: Long? = null
|
||||
var discontinuitySequence: Int? = null
|
||||
var programDateTime: ZonedDateTime? = null
|
||||
var playlistType: String? = null
|
||||
var streamInfo: StreamInfo? = null
|
||||
var decryptionInfo: DecryptionInfo? = null
|
||||
var mapUrl: String? = null
|
||||
var mapBytesStart: Long = -1
|
||||
var mapBytesLength: Long = -1
|
||||
val segments = mutableListOf<Segment>()
|
||||
if (initSegment != null) {
|
||||
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||
}
|
||||
val unhandled = mutableListOf<String>()
|
||||
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEach { line ->
|
||||
|
||||
for (rawLine in lines) {
|
||||
val line = rawLine.trim()
|
||||
if (line.isEmpty()) continue
|
||||
|
||||
when {
|
||||
line.startsWith("#EXT-X-VERSION:") -> {
|
||||
version = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-TARGETDURATION:") -> {
|
||||
targetDuration = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
|
||||
mediaSequence = line.substringAfter(":").toLongOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") -> {
|
||||
discontinuitySequence = line.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-PROGRAM-DATE-TIME:") -> {
|
||||
programDateTime = ZonedDateTime.parse(
|
||||
line.substringAfter(":"),
|
||||
DateTimeFormatter.ISO_DATE_TIME
|
||||
)
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-PLAYLIST-TYPE:") -> {
|
||||
playlistType = line.substringAfter(":")
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-STREAM-INF:") -> {
|
||||
streamInfo = parseStreamInfo(line)
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-KEY:") -> {
|
||||
val attrs = parseAttributes(line)
|
||||
val method = attrs["METHOD"]?.ifEmpty { "AES-128" } ?: "AES-128"
|
||||
val keyUri = attrs["URI"]?.removeSurrounding("\"")
|
||||
val keyUrl = keyUri?.let { resolveUrl(baseUrl, it) }
|
||||
val ivRaw = attrs["IV"]
|
||||
val iv = ivRaw
|
||||
?.removePrefix("0x")
|
||||
?.removePrefix("0X")
|
||||
val keyFormat = attrs["KEYFORMAT"]
|
||||
val keyFormatVersions = attrs["KEYFORMATVERSIONS"]
|
||||
decryptionInfo = DecryptionInfo(method, keyUrl, iv, keyFormat, keyFormatVersions)
|
||||
}
|
||||
|
||||
line.startsWith("#EXT-X-MAP:") -> {
|
||||
val attrs = parseAttributes(line)
|
||||
attrs["URI"]?.let { uri ->
|
||||
mapUrl = resolveUrl(baseUrl, uri)
|
||||
}
|
||||
attrs["BYTERANGE"]?.let { br ->
|
||||
val (len, start) = parseByteRange(br)
|
||||
mapBytesLength = len
|
||||
mapBytesStart = start
|
||||
}
|
||||
}
|
||||
|
||||
line.startsWith("#EXTINF:") -> {
|
||||
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
|
||||
?: throw Exception("Invalid segment duration format")
|
||||
val durationText = line.substringAfter(":").substringBefore(",")
|
||||
val duration = durationText.toDoubleOrNull()
|
||||
?: throw IllegalArgumentException("Invalid segment duration: '$line'")
|
||||
currentSegment = MediaSegment(duration = duration)
|
||||
}
|
||||
|
||||
line == "#EXT-X-DISCONTINUITY" -> {
|
||||
segments.add(DiscontinuitySegment())
|
||||
}
|
||||
line =="#EXT-X-ENDLIST" -> {
|
||||
|
||||
line == "#EXT-X-ENDLIST" -> {
|
||||
segments.add(EndListSegment())
|
||||
}
|
||||
else -> {
|
||||
|
||||
currentSegment != null && line.startsWith("#EXT-X-BYTERANGE:") -> {
|
||||
val br = line.substringAfter(":").trim()
|
||||
val (len, start) = parseByteRange(br)
|
||||
currentSegment!!.bytesLength = len
|
||||
currentSegment!!.bytesStart = start
|
||||
}
|
||||
|
||||
currentSegment != null && line.startsWith("#") -> {
|
||||
currentSegment!!.unhandled.add(line)
|
||||
}
|
||||
|
||||
!line.startsWith("#") -> {
|
||||
currentSegment?.let {
|
||||
it.uri = resolveUrl(sourceUrl, line)
|
||||
it.uri = resolveUrl(baseUrl, line)
|
||||
segments.add(it)
|
||||
currentSegment = null
|
||||
} ?: run {
|
||||
unhandled.add(line)
|
||||
}
|
||||
currentSegment = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
unhandled.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
|
||||
return VariantPlaylist(
|
||||
version = version,
|
||||
targetDuration = targetDuration,
|
||||
mediaSequence = mediaSequence,
|
||||
discontinuitySequence = discontinuitySequence,
|
||||
programDateTime = programDateTime,
|
||||
playlistType = playlistType,
|
||||
streamInfo = streamInfo,
|
||||
segments = segments,
|
||||
decryptionInfo = decryptionInfo,
|
||||
mapUrl = mapUrl,
|
||||
mapBytesStart = mapBytesStart,
|
||||
mapBytesLength = mapBytesLength,
|
||||
unhandled = unhandled
|
||||
)
|
||||
}
|
||||
|
||||
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
|
||||
@@ -232,26 +374,6 @@ class HLS {
|
||||
return SessionData(dataId, value)
|
||||
}
|
||||
|
||||
private fun parseAttributes(content: String): Map<String, String> {
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
val maybeAttributePairs = content.substringAfter(":").splitToSequence(',')
|
||||
|
||||
var currentPair = StringBuilder()
|
||||
for (pair in maybeAttributePairs) {
|
||||
currentPair.append(pair)
|
||||
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even
|
||||
val key = currentPair.toString().substringBefore("=")
|
||||
val value = currentPair.toString().substringAfter("=")
|
||||
attributes[key.trim()] = value.trim().removeSurrounding("\"")
|
||||
currentPair = StringBuilder() // Reset for the next attribute
|
||||
} else {
|
||||
currentPair.append(',') // Continue building the current attribute pair
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
|
||||
private fun shouldQuote(key: String, value: String?): Boolean {
|
||||
if (value == null)
|
||||
@@ -345,11 +467,22 @@ class HLS {
|
||||
val variantPlaylistsRefs: List<VariantPlaylistReference>,
|
||||
val mediaRenditions: List<MediaRendition>,
|
||||
val sessionDataList: List<SessionData>,
|
||||
val independentSegments: Boolean
|
||||
val independentSegments: Boolean,
|
||||
val version: Int? = null,
|
||||
val mediaSequence: Long? = null,
|
||||
val unhandled: List<String> = emptyList()
|
||||
) {
|
||||
fun buildM3U8(): String {
|
||||
val builder = StringBuilder()
|
||||
builder.append("#EXTM3U\n")
|
||||
|
||||
version?.let {
|
||||
builder.append("#EXT-X-VERSION:$it\n")
|
||||
}
|
||||
mediaSequence?.let {
|
||||
builder.append("#EXT-X-MEDIA-SEQUENCE:$it\n")
|
||||
}
|
||||
|
||||
if (independentSegments) {
|
||||
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||
}
|
||||
@@ -404,9 +537,15 @@ class HLS {
|
||||
}
|
||||
|
||||
data class DecryptionInfo(
|
||||
val keyUrl: String,
|
||||
val iv: String?
|
||||
)
|
||||
val method: String,
|
||||
val keyUrl: String?,
|
||||
val iv: String?,
|
||||
val keyFormat: String?,
|
||||
val keyFormatVersions: String?
|
||||
) {
|
||||
val isEncrypted: Boolean
|
||||
get() = !method.equals("NONE", ignoreCase = true)
|
||||
}
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int?,
|
||||
@@ -417,7 +556,11 @@ class HLS {
|
||||
val playlistType: String?,
|
||||
val streamInfo: StreamInfo?,
|
||||
val segments: List<Segment>,
|
||||
val decryptionInfo: DecryptionInfo? = null
|
||||
val decryptionInfo: DecryptionInfo? = null,
|
||||
val mapUrl: String? = null,
|
||||
val mapBytesStart: Long = -1,
|
||||
val mapBytesLength: Long = -1,
|
||||
val unhandled: List<String> = emptyList()
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
append("#EXTM3U\n")
|
||||
@@ -426,9 +569,50 @@ class HLS {
|
||||
mediaSequence?.let { append("#EXT-X-MEDIA-SEQUENCE:$it\n") }
|
||||
discontinuitySequence?.let { append("#EXT-X-DISCONTINUITY-SEQUENCE:$it\n") }
|
||||
playlistType?.let { append("#EXT-X-PLAYLIST-TYPE:$it\n") }
|
||||
programDateTime?.let { append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n") }
|
||||
programDateTime?.let {
|
||||
append(
|
||||
"#EXT-X-PROGRAM-DATE-TIME:${
|
||||
it.withZoneSameInstant(java.time.ZoneOffset.UTC)
|
||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"))
|
||||
}\n"
|
||||
)
|
||||
}
|
||||
streamInfo?.let { append(it.toM3U8Line()) }
|
||||
|
||||
decryptionInfo?.let { dec ->
|
||||
val sb = StringBuilder()
|
||||
sb.append("#EXT-X-KEY:METHOD=").append(dec.method)
|
||||
if (!dec.method.equals("NONE", ignoreCase = true)) {
|
||||
dec.keyUrl?.let { url ->
|
||||
sb.append(",URI=\"").append(url).append("\"")
|
||||
}
|
||||
dec.iv?.let { iv ->
|
||||
sb.append(",IV=0x").append(iv)
|
||||
}
|
||||
dec.keyFormat?.let { kf ->
|
||||
sb.append(",KEYFORMAT=\"").append(kf).append("\"")
|
||||
}
|
||||
dec.keyFormatVersions?.let { kfv ->
|
||||
sb.append(",KEYFORMATVERSIONS=\"").append(kfv).append("\"")
|
||||
}
|
||||
}
|
||||
append(sb.append("\n").toString())
|
||||
}
|
||||
|
||||
if (!mapUrl.isNullOrEmpty()) {
|
||||
val sb = StringBuilder()
|
||||
sb.append("#EXT-X-MAP:URI=\"").append(mapUrl).append("\"")
|
||||
if (mapBytesLength > 0) {
|
||||
if (mapBytesStart >= 0) {
|
||||
sb.append(",BYTERANGE=\"").append(mapBytesLength)
|
||||
.append("@").append(mapBytesStart).append("\"")
|
||||
} else {
|
||||
sb.append(",BYTERANGE=\"").append(mapBytesLength).append("\"")
|
||||
}
|
||||
}
|
||||
append(sb.append("\n").toString())
|
||||
}
|
||||
|
||||
segments.forEach { segment ->
|
||||
append(segment.toM3U8Line())
|
||||
}
|
||||
@@ -439,13 +623,25 @@ class HLS {
|
||||
abstract fun toM3U8Line(): String
|
||||
}
|
||||
|
||||
data class MediaSegment (
|
||||
data class MediaSegment(
|
||||
val duration: Double,
|
||||
var uri: String = ""
|
||||
var uri: String = "",
|
||||
var bytesStart: Long = -1,
|
||||
var bytesLength: Long = -1,
|
||||
val unhandled: MutableList<String> = mutableListOf()
|
||||
) : Segment() {
|
||||
override fun toM3U8Line(): String = buildString {
|
||||
append("#EXTINF:${duration},\n")
|
||||
append(uri + "\n")
|
||||
|
||||
if (bytesLength > 0) {
|
||||
if (bytesStart >= 0) {
|
||||
append("#EXT-X-BYTERANGE:${bytesLength}@${bytesStart}\n")
|
||||
} else {
|
||||
append("#EXT-X-BYTERANGE:${bytesLength}\n")
|
||||
}
|
||||
}
|
||||
|
||||
append(uri).append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.receivers
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -26,14 +27,24 @@ class InstallReceiver : BroadcastReceiver() {
|
||||
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
}
|
||||
|
||||
if (activityIntent == null) {
|
||||
Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
|
||||
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
|
||||
return;
|
||||
}
|
||||
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
|
||||
activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
try {
|
||||
context.startActivity(activityIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Logger.e(TAG, "System installer cannot handle CONFIRM_INSTALL intent. ROM is broken; falling back / reporting error.", e)
|
||||
onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken))
|
||||
}
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null);
|
||||
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure));
|
||||
@@ -45,6 +56,7 @@ class InstallReceiver : BroadcastReceiver() {
|
||||
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage));
|
||||
else -> {
|
||||
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
||||
Logger.w(TAG, "Received unknown install status $status, message=$msg")
|
||||
onReceiveResult.emit(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
class CombinedQueryString implements UrlQueryString {
|
||||
private final List<UrlQueryString> mQueryStrings = new ArrayList<>();
|
||||
|
||||
public CombinedQueryString(String url) {
|
||||
UrlQueryString urlQueryString = UrlEncodedQueryString.parse(url);
|
||||
|
||||
if (urlQueryString.isValid()) {
|
||||
mQueryStrings.add(urlQueryString);
|
||||
}
|
||||
|
||||
UrlQueryString pathQueryString = PathQueryString.parse(url);
|
||||
|
||||
if (pathQueryString.isValid()) {
|
||||
mQueryStrings.add(pathQueryString);
|
||||
}
|
||||
|
||||
if (mQueryStrings.isEmpty()) {
|
||||
mQueryStrings.add(NullQueryString.parse(url));
|
||||
}
|
||||
}
|
||||
|
||||
public static UrlQueryString parse(String url) {
|
||||
return new CombinedQueryString(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
queryString.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
String value = queryString.get(key);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
float value = queryString.getFloat(key);
|
||||
if (value != 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, String value) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
queryString.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, int value) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
queryString.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, float value) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
queryString.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
return queryString.isEmpty();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
return queryString.isValid();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
boolean contains = queryString.contains(key);
|
||||
if (contains) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
for (UrlQueryString queryString : mQueryStrings) {
|
||||
return queryString.toString();
|
||||
}
|
||||
|
||||
return super.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import static androidx.media3.common.util.Util.addWithOverflowDefault;
|
||||
import static androidx.media3.common.util.Util.subtractWithOverflowDefault;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.CheckResult;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.exoplayer.LoadingInfo;
|
||||
import androidx.media3.exoplayer.SeekParameters;
|
||||
import androidx.media3.exoplayer.source.chunk.BundledChunkExtractor;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkExtractor;
|
||||
import androidx.media3.extractor.ChunkIndex;
|
||||
import androidx.media3.extractor.Extractor;
|
||||
import androidx.media3.extractor.SeekMap;
|
||||
import androidx.media3.extractor.TrackOutput;
|
||||
import androidx.media3.exoplayer.source.BehindLiveWindowException;
|
||||
import androidx.media3.exoplayer.source.chunk.BaseMediaChunkIterator;
|
||||
import androidx.media3.exoplayer.source.chunk.Chunk;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkHolder;
|
||||
import androidx.media3.exoplayer.source.chunk.ContainerMediaChunk;
|
||||
import androidx.media3.exoplayer.source.chunk.InitializationChunk;
|
||||
import androidx.media3.exoplayer.source.chunk.MediaChunk;
|
||||
import androidx.media3.exoplayer.source.chunk.MediaChunkIterator;
|
||||
import androidx.media3.exoplayer.source.chunk.SingleSampleMediaChunk;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerTrackEmsgHandler;
|
||||
import com.futo.platformplayer.sabr.manifest.AdaptationSet;
|
||||
import com.futo.platformplayer.sabr.manifest.RangedUri;
|
||||
import com.futo.platformplayer.sabr.manifest.Representation;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import com.futo.platformplayer.sabr.parser.SabrExtractor;
|
||||
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.datasource.DataSpec;
|
||||
import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException;
|
||||
import androidx.media3.exoplayer.upstream.LoaderErrorThrower;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@UnstableApi
|
||||
public class DefaultSabrChunkSource implements SabrChunkSource {
|
||||
public static final class Factory implements SabrChunkSource.Factory {
|
||||
|
||||
private final DataSource.Factory dataSourceFactory;
|
||||
private final int maxSegmentsPerLoad;
|
||||
|
||||
public Factory(DataSource.Factory dataSourceFactory) {
|
||||
this(dataSourceFactory, 1);
|
||||
}
|
||||
|
||||
public Factory(DataSource.Factory dataSourceFactory, int maxSegmentsPerLoad) {
|
||||
this.dataSourceFactory = dataSourceFactory;
|
||||
this.maxSegmentsPerLoad = maxSegmentsPerLoad;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SabrChunkSource createSabrChunkSource(
|
||||
LoaderErrorThrower manifestLoaderErrorThrower,
|
||||
SabrManifest manifest,
|
||||
int periodIndex,
|
||||
int[] adaptationSetIndices,
|
||||
ExoTrackSelection trackSelection,
|
||||
int type,
|
||||
long elapsedRealtimeOffsetMs,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
@Nullable PlayerTrackEmsgHandler playerEmsgHandler,
|
||||
@Nullable TransferListener transferListener) {
|
||||
DataSource dataSource = dataSourceFactory.createDataSource();
|
||||
if (transferListener != null) {
|
||||
dataSource.addTransferListener(transferListener);
|
||||
}
|
||||
return new DefaultSabrChunkSource(
|
||||
manifestLoaderErrorThrower,
|
||||
manifest,
|
||||
periodIndex,
|
||||
adaptationSetIndices,
|
||||
trackSelection,
|
||||
type,
|
||||
dataSource,
|
||||
elapsedRealtimeOffsetMs,
|
||||
maxSegmentsPerLoad,
|
||||
enableEventMessageTrack,
|
||||
closedCaptionFormats,
|
||||
playerEmsgHandler);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final LoaderErrorThrower manifestLoaderErrorThrower;
|
||||
private final int[] adaptationSetIndices;
|
||||
private final int trackType;
|
||||
private final DataSource dataSource;
|
||||
private final long elapsedRealtimeOffsetMs;
|
||||
private final int maxSegmentsPerLoad;
|
||||
@Nullable private final PlayerTrackEmsgHandler playerTrackEmsgHandler;
|
||||
|
||||
protected final RepresentationHolder[] representationHolders;
|
||||
|
||||
private ExoTrackSelection trackSelection;
|
||||
private SabrManifest manifest;
|
||||
private int periodIndex;
|
||||
private IOException fatalError;
|
||||
private boolean missingLastSegment;
|
||||
private long liveEdgeTimeUs;
|
||||
|
||||
/**
|
||||
* @param manifestLoaderErrorThrower Throws errors affecting loading of manifests.
|
||||
* @param manifest The initial manifest.
|
||||
* @param periodIndex The index of the period in the manifest.
|
||||
* @param adaptationSetIndices The indices of the adaptation sets in the period.
|
||||
* @param trackSelection The track selection.
|
||||
* @param trackType The type of the tracks in the selection.
|
||||
* @param dataSource A {@link DataSource} suitable for loading the media data.
|
||||
* @param elapsedRealtimeOffsetMs If known, an estimate of the instantaneous difference between
|
||||
* server-side unix time and {@link SystemClock#elapsedRealtime()} in milliseconds, specified
|
||||
* as the server's unix time minus the local elapsed time. If unknown, set to 0.
|
||||
* @param maxSegmentsPerLoad The maximum number of segments to combine into a single request. Note
|
||||
* that segments will only be combined if their {@link Uri}s are the same and if their data
|
||||
* ranges are adjacent.
|
||||
* @param enableEventMessageTrack Whether to output an event message track.
|
||||
* @param closedCaptionFormats The {@link Format Formats} of closed caption tracks to be output.
|
||||
* @param playerTrackEmsgHandler The {@link PlayerTrackEmsgHandler} instance to handle emsg
|
||||
* messages targeting the player. Maybe null if this is not necessary.
|
||||
*/
|
||||
public DefaultSabrChunkSource(
|
||||
LoaderErrorThrower manifestLoaderErrorThrower,
|
||||
SabrManifest manifest,
|
||||
int periodIndex,
|
||||
int[] adaptationSetIndices,
|
||||
ExoTrackSelection trackSelection,
|
||||
int trackType,
|
||||
DataSource dataSource,
|
||||
long elapsedRealtimeOffsetMs,
|
||||
int maxSegmentsPerLoad,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
@Nullable PlayerTrackEmsgHandler playerTrackEmsgHandler) {
|
||||
this.manifestLoaderErrorThrower = manifestLoaderErrorThrower;
|
||||
this.manifest = manifest;
|
||||
this.adaptationSetIndices = adaptationSetIndices;
|
||||
this.trackSelection = trackSelection;
|
||||
this.trackType = trackType;
|
||||
this.dataSource = dataSource;
|
||||
this.periodIndex = periodIndex;
|
||||
this.elapsedRealtimeOffsetMs = elapsedRealtimeOffsetMs;
|
||||
this.maxSegmentsPerLoad = maxSegmentsPerLoad;
|
||||
this.playerTrackEmsgHandler = playerTrackEmsgHandler;
|
||||
|
||||
long periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
|
||||
liveEdgeTimeUs = C.TIME_UNSET;
|
||||
|
||||
List<Representation> representations = getRepresentations();
|
||||
representationHolders = new RepresentationHolder[trackSelection.length()];
|
||||
for (int i = 0; i < representationHolders.length; i++) {
|
||||
Representation representation = representations.get(trackSelection.getIndexInTrackGroup(i));
|
||||
representationHolders[i] =
|
||||
new RepresentationHolder(
|
||||
periodDurationUs,
|
||||
trackType,
|
||||
representation,
|
||||
enableEventMessageTrack,
|
||||
closedCaptionFormats,
|
||||
playerTrackEmsgHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateManifest(SabrManifest newManifest, int newPeriodIndex) {
|
||||
try {
|
||||
manifest = newManifest;
|
||||
periodIndex = newPeriodIndex;
|
||||
long periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
|
||||
List<Representation> representations = getRepresentations();
|
||||
for (int i = 0; i < representationHolders.length; i++) {
|
||||
Representation representation = representations.get(trackSelection.getIndexInTrackGroup(i));
|
||||
representationHolders[i] =
|
||||
representationHolders[i].copyWithNewRepresentation(periodDurationUs, representation);
|
||||
}
|
||||
} catch (BehindLiveWindowException e) {
|
||||
fatalError = e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateTrackSelection(ExoTrackSelection trackSelection) {
|
||||
this.trackSelection = trackSelection;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolves a seek given the requested seek position, a {@link SeekParameters} and two candidate
|
||||
* sync points.
|
||||
*
|
||||
* @param positionUs The requested seek position, in microseocnds.
|
||||
* @param seekParameters The {@link SeekParameters}.
|
||||
* @param firstSyncUs The first candidate seek point, in micrseconds.
|
||||
* @param secondSyncUs The second candidate seek point, in microseconds. May equal {@code
|
||||
* firstSyncUs} if there's only one candidate.
|
||||
* @return The resolved seek position, in microseconds.
|
||||
*/
|
||||
public static long resolveSeekPositionUs(
|
||||
long positionUs, SeekParameters seekParameters, long firstSyncUs, long secondSyncUs) {
|
||||
if (SeekParameters.EXACT.equals(seekParameters)) {
|
||||
return positionUs;
|
||||
}
|
||||
long minPositionUs = subtractWithOverflowDefault(positionUs, seekParameters.toleranceBeforeUs, Long.MIN_VALUE);
|
||||
long maxPositionUs = addWithOverflowDefault(positionUs, seekParameters.toleranceAfterUs, Long.MAX_VALUE);
|
||||
boolean firstSyncPositionValid = minPositionUs <= firstSyncUs && firstSyncUs <= maxPositionUs;
|
||||
boolean secondSyncPositionValid =
|
||||
minPositionUs <= secondSyncUs && secondSyncUs <= maxPositionUs;
|
||||
if (firstSyncPositionValid && secondSyncPositionValid) {
|
||||
if (Math.abs(firstSyncUs - positionUs) <= Math.abs(secondSyncUs - positionUs)) {
|
||||
return firstSyncUs;
|
||||
} else {
|
||||
return secondSyncUs;
|
||||
}
|
||||
} else if (firstSyncPositionValid) {
|
||||
return firstSyncUs;
|
||||
} else if (secondSyncPositionValid) {
|
||||
return secondSyncUs;
|
||||
} else {
|
||||
return minPositionUs;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
|
||||
// Segments are aligned across representations, so any segment index will do.
|
||||
for (RepresentationHolder representationHolder : representationHolders) {
|
||||
if (representationHolder.segmentIndex != null) {
|
||||
long segmentNum = representationHolder.getSegmentNum(positionUs);
|
||||
long firstSyncUs = representationHolder.getSegmentStartTimeUs(segmentNum);
|
||||
long secondSyncUs =
|
||||
firstSyncUs < positionUs && segmentNum < representationHolder.getSegmentCount() - 1
|
||||
? representationHolder.getSegmentStartTimeUs(segmentNum + 1)
|
||||
: firstSyncUs;
|
||||
return resolveSeekPositionUs(positionUs, seekParameters, firstSyncUs, secondSyncUs);
|
||||
}
|
||||
}
|
||||
// We don't have a segment index to adjust the seek position with yet.
|
||||
return positionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowError() throws IOException {
|
||||
if (fatalError != null) {
|
||||
throw fatalError;
|
||||
} else {
|
||||
manifestLoaderErrorThrower.maybeThrowError();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPreferredQueueSize(long playbackPositionUs, List<? extends MediaChunk> queue) {
|
||||
if (fatalError != null || trackSelection.length() < 2) {
|
||||
return queue.size();
|
||||
}
|
||||
return trackSelection.evaluateQueueSize(playbackPositionUs, queue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void getNextChunk(LoadingInfo loadingInfo, long loadPositionUs, List<? extends MediaChunk> queue, ChunkHolder out) {
|
||||
//public void getNextChunk(long playbackPositionUs, long loadPositionUs, List<? extends MediaChunk> queue, ChunkHolder out) {
|
||||
if (fatalError != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long bufferedDurationUs = loadPositionUs - loadingInfo.playbackPositionUs;
|
||||
long timeToLiveEdgeUs = resolveTimeToLiveEdgeUs(loadingInfo.playbackPositionUs);
|
||||
long presentationPositionUs = C.msToUs(manifest.availabilityStartTimeMs)
|
||||
+ C.msToUs(manifest.getPeriod(periodIndex).startMs)
|
||||
+ loadPositionUs;
|
||||
|
||||
if (playerTrackEmsgHandler != null
|
||||
&& playerTrackEmsgHandler.maybeRefreshManifestBeforeLoadingNextChunk(
|
||||
presentationPositionUs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
long nowUnixTimeUs = getNowUnixTimeUs();
|
||||
MediaChunk previous = queue.isEmpty() ? null : queue.get(queue.size() - 1);
|
||||
MediaChunkIterator[] chunkIterators = new MediaChunkIterator[trackSelection.length()];
|
||||
for (int i = 0; i < chunkIterators.length; i++) {
|
||||
RepresentationHolder representationHolder = representationHolders[i];
|
||||
if (representationHolder.segmentIndex == null) {
|
||||
chunkIterators[i] = MediaChunkIterator.EMPTY;
|
||||
} else {
|
||||
long firstAvailableSegmentNum =
|
||||
representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
|
||||
long lastAvailableSegmentNum =
|
||||
representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
|
||||
long segmentNum =
|
||||
getSegmentNum(
|
||||
representationHolder,
|
||||
previous,
|
||||
loadPositionUs,
|
||||
firstAvailableSegmentNum,
|
||||
lastAvailableSegmentNum);
|
||||
if (segmentNum < firstAvailableSegmentNum) {
|
||||
chunkIterators[i] = MediaChunkIterator.EMPTY;
|
||||
} else {
|
||||
chunkIterators[i] =
|
||||
new RepresentationSegmentIterator(
|
||||
representationHolder, segmentNum, lastAvailableSegmentNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trackSelection.updateSelectedTrack(
|
||||
loadingInfo.playbackPositionUs, bufferedDurationUs, timeToLiveEdgeUs, queue, chunkIterators);
|
||||
|
||||
RepresentationHolder representationHolder =
|
||||
representationHolders[trackSelection.getSelectedIndex()];
|
||||
|
||||
if (representationHolder.extractorWrapper != null) {
|
||||
Representation selectedRepresentation = representationHolder.representation;
|
||||
RangedUri pendingInitializationUri = null;
|
||||
RangedUri pendingIndexUri = null;
|
||||
if (representationHolder.extractorWrapper.getSampleFormats() == null) {
|
||||
pendingInitializationUri = selectedRepresentation.getInitializationUri();
|
||||
}
|
||||
if (representationHolder.segmentIndex == null) {
|
||||
pendingIndexUri = selectedRepresentation.getIndexUri();
|
||||
}
|
||||
if (pendingInitializationUri != null || pendingIndexUri != null) {
|
||||
// We have initialization and/or index requests to make.
|
||||
out.chunk = newInitializationChunk(representationHolder, dataSource,
|
||||
trackSelection.getSelectedFormat(), trackSelection.getSelectionReason(),
|
||||
trackSelection.getSelectionData(), pendingInitializationUri, pendingIndexUri);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
long periodDurationUs = representationHolder.periodDurationUs;
|
||||
boolean periodEnded = periodDurationUs != C.TIME_UNSET;
|
||||
|
||||
if (representationHolder.getSegmentCount() == 0) {
|
||||
// The index doesn't define any segments.
|
||||
out.endOfStream = periodEnded;
|
||||
return;
|
||||
}
|
||||
|
||||
long firstAvailableSegmentNum =
|
||||
representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
|
||||
long lastAvailableSegmentNum =
|
||||
representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs);
|
||||
|
||||
updateLiveEdgeTimeUs(representationHolder, lastAvailableSegmentNum);
|
||||
|
||||
long segmentNum =
|
||||
getSegmentNum(
|
||||
representationHolder,
|
||||
previous,
|
||||
loadPositionUs,
|
||||
firstAvailableSegmentNum,
|
||||
lastAvailableSegmentNum);
|
||||
if (segmentNum < firstAvailableSegmentNum) {
|
||||
// This is before the first chunk in the current manifest.
|
||||
fatalError = new BehindLiveWindowException();
|
||||
return;
|
||||
}
|
||||
|
||||
if (segmentNum > lastAvailableSegmentNum
|
||||
|| (missingLastSegment && segmentNum >= lastAvailableSegmentNum)) {
|
||||
// The segment is beyond the end of the period.
|
||||
out.endOfStream = periodEnded;
|
||||
return;
|
||||
}
|
||||
|
||||
if (periodEnded && representationHolder.getSegmentStartTimeUs(segmentNum) >= periodDurationUs) {
|
||||
// The period duration clips the period to a position before the segment.
|
||||
out.endOfStream = true;
|
||||
return;
|
||||
}
|
||||
|
||||
int maxSegmentCount =
|
||||
(int) Math.min(maxSegmentsPerLoad, lastAvailableSegmentNum - segmentNum + 1);
|
||||
if (periodDurationUs != C.TIME_UNSET) {
|
||||
while (maxSegmentCount > 1
|
||||
&& representationHolder.getSegmentStartTimeUs(segmentNum + maxSegmentCount - 1)
|
||||
>= periodDurationUs) {
|
||||
// The period duration clips the period to a position before the last segment in the range
|
||||
// [segmentNum, segmentNum + maxSegmentCount - 1]. Reduce maxSegmentCount.
|
||||
maxSegmentCount--;
|
||||
}
|
||||
}
|
||||
|
||||
long seekTimeUs = queue.isEmpty() ? loadPositionUs : C.TIME_UNSET;
|
||||
out.chunk =
|
||||
newMediaChunk(
|
||||
representationHolder,
|
||||
dataSource,
|
||||
trackType,
|
||||
trackSelection.getSelectedFormat(),
|
||||
trackSelection.getSelectionReason(),
|
||||
trackSelection.getSelectionData(),
|
||||
segmentNum,
|
||||
maxSegmentCount,
|
||||
seekTimeUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldCancelLoad(
|
||||
long playbackPositionUs, Chunk loadingChunk, List<? extends MediaChunk> queue) {
|
||||
if (fatalError != null || trackSelection.length() < 2) {
|
||||
return false;
|
||||
}
|
||||
// Let the selection decide (Media3 exposes this).
|
||||
return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, (List<MediaChunk>) queue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChunkLoadCompleted(Chunk chunk) {
|
||||
// If the init chunk just finished, try to grab a parsed ChunkIndex from the extractor.
|
||||
if (chunk instanceof InitializationChunk) {
|
||||
final int trackIndex = trackSelection.indexOf(chunk.trackFormat);
|
||||
if (trackIndex != C.INDEX_UNSET) {
|
||||
RepresentationHolder holder = representationHolders[trackIndex];
|
||||
|
||||
// Don't overwrite a manifest-defined index. Only adopt stream-provided index if needed.
|
||||
if (holder.segmentIndex == null && holder.extractorWrapper != null) {
|
||||
// Media3 exposes the parsed index via ChunkExtractor.getChunkIndex() now.
|
||||
ChunkIndex chunkIndex = holder.extractorWrapper.getChunkIndex();
|
||||
if (chunkIndex != null) {
|
||||
representationHolders[trackIndex] =
|
||||
holder.copyWithNewSegmentIndex(
|
||||
new SabrWrappingSegmentIndex(
|
||||
chunkIndex,
|
||||
holder.representation.presentationTimeOffsetUs));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (playerTrackEmsgHandler != null) {
|
||||
playerTrackEmsgHandler.onChunkLoadCompleted(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onChunkLoadError(
|
||||
Chunk chunk,
|
||||
boolean cancelable,
|
||||
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.LoadErrorInfo loadErrorInfo,
|
||||
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
|
||||
if (!cancelable) return false;
|
||||
|
||||
// Manifest-driven refresh (same behavior you had before).
|
||||
if (playerTrackEmsgHandler != null && playerTrackEmsgHandler.maybeRefreshManifestOnLoadingError(chunk)) {
|
||||
return true; // cancel & re-resolve next chunk
|
||||
}
|
||||
|
||||
// Workaround for a missing last segment on VOD (404) — unchanged logic, updated signature.
|
||||
if (!manifest.dynamic
|
||||
&& chunk instanceof MediaChunk
|
||||
&& loadErrorInfo.exception instanceof InvalidResponseCodeException
|
||||
&& ((InvalidResponseCodeException) loadErrorInfo.exception).responseCode == 404) {
|
||||
RepresentationHolder holder = representationHolders[trackSelection.indexOf(chunk.trackFormat)];
|
||||
int count = holder.getSegmentCount();
|
||||
if (count != SabrSegmentIndex.INDEX_UNBOUNDED && count != 0) {
|
||||
long lastAvailable = holder.getFirstSegmentNum() + count - 1;
|
||||
if (((MediaChunk) chunk).getNextChunkIndex() > lastAvailable) {
|
||||
missingLastSegment = true;
|
||||
return true; // cancel; we’ll end the period gracefully
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modern fallback track exclusion using LoadErrorHandlingPolicy
|
||||
int excluded = 0;
|
||||
long nowMs = SystemClock.elapsedRealtime();
|
||||
for (int i = 0; i < trackSelection.length(); i++) {
|
||||
if (trackSelection.isTrackExcluded(i, nowMs)) excluded++;
|
||||
}
|
||||
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackOptions options =
|
||||
new androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackOptions(
|
||||
/* numberOfLocations= */ 1, /* numberOfExcludedLocations= */ 0,
|
||||
/* numberOfTracks= */ trackSelection.length(), /* numberOfExcludedTracks= */ excluded);
|
||||
|
||||
androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackSelection sel =
|
||||
loadErrorHandlingPolicy.getFallbackSelectionFor(options, loadErrorInfo);
|
||||
|
||||
if (sel != null
|
||||
&& sel.type == androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) {
|
||||
int trackIdx = trackSelection.indexOf(chunk.trackFormat);
|
||||
return trackSelection.excludeTrack(trackIdx, sel.exclusionDurationMs);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private ArrayList<Representation> getRepresentations() {
|
||||
List<AdaptationSet> manifestAdaptationSets = manifest.getPeriod(periodIndex).adaptationSets;
|
||||
ArrayList<Representation> representations = new ArrayList<>();
|
||||
for (int adaptationSetIndex : adaptationSetIndices) {
|
||||
representations.addAll(manifestAdaptationSets.get(adaptationSetIndex).representations);
|
||||
}
|
||||
return representations;
|
||||
}
|
||||
|
||||
private long resolveTimeToLiveEdgeUs(long playbackPositionUs) {
|
||||
boolean resolveTimeToLiveEdgePossible = manifest.dynamic && liveEdgeTimeUs != C.TIME_UNSET;
|
||||
return resolveTimeToLiveEdgePossible ? liveEdgeTimeUs - playbackPositionUs : C.TIME_UNSET;
|
||||
}
|
||||
|
||||
private long getNowUnixTimeUs() {
|
||||
if (elapsedRealtimeOffsetMs != 0) {
|
||||
return (SystemClock.elapsedRealtime() + elapsedRealtimeOffsetMs) * 1000;
|
||||
} else {
|
||||
return System.currentTimeMillis() * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release() {
|
||||
// Forward-looking: free extractor resources if needed.
|
||||
for (RepresentationHolder h : representationHolders) {
|
||||
if (h != null && h.extractorWrapper != null) {
|
||||
h.extractorWrapper.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long getSegmentNum(
|
||||
RepresentationHolder representationHolder,
|
||||
@Nullable MediaChunk previousChunk,
|
||||
long loadPositionUs,
|
||||
long firstAvailableSegmentNum,
|
||||
long lastAvailableSegmentNum) {
|
||||
return previousChunk != null
|
||||
? previousChunk.getNextChunkIndex()
|
||||
: Util.constrainValue(representationHolder.getSegmentNum(loadPositionUs), firstAvailableSegmentNum, lastAvailableSegmentNum);
|
||||
}
|
||||
|
||||
private void updateLiveEdgeTimeUs(
|
||||
RepresentationHolder representationHolder, long lastAvailableSegmentNum) {
|
||||
liveEdgeTimeUs = manifest.dynamic
|
||||
? representationHolder.getSegmentEndTimeUs(lastAvailableSegmentNum) : C.TIME_UNSET;
|
||||
}
|
||||
|
||||
protected Chunk newInitializationChunk(
|
||||
RepresentationHolder representationHolder,
|
||||
DataSource dataSource,
|
||||
Format trackFormat,
|
||||
int trackSelectionReason,
|
||||
Object trackSelectionData,
|
||||
RangedUri initializationUri,
|
||||
RangedUri indexUri) {
|
||||
RangedUri requestUri;
|
||||
String baseUrl = representationHolder.representation.baseUrl;
|
||||
if (initializationUri != null) {
|
||||
// It's common for initialization and index data to be stored adjacently. Attempt to merge
|
||||
// the two requests together to request both at once.
|
||||
requestUri = initializationUri.attemptMerge(indexUri, baseUrl);
|
||||
if (requestUri == null) {
|
||||
requestUri = initializationUri;
|
||||
}
|
||||
} else {
|
||||
requestUri = indexUri;
|
||||
}
|
||||
// TODO: first protobuf request (before the video start off)
|
||||
DataSpec dataSpec = new DataSpec(requestUri.resolveUri(baseUrl), requestUri.start,
|
||||
requestUri.length, representationHolder.representation.getCacheKey());
|
||||
return new InitializationChunk(dataSource, dataSpec, trackFormat,
|
||||
trackSelectionReason, trackSelectionData, representationHolder.extractorWrapper);
|
||||
}
|
||||
|
||||
protected Chunk newMediaChunk(
|
||||
RepresentationHolder representationHolder,
|
||||
DataSource dataSource,
|
||||
int trackType,
|
||||
Format trackFormat,
|
||||
int trackSelectionReason,
|
||||
Object trackSelectionData,
|
||||
long firstSegmentNum,
|
||||
int maxSegmentCount,
|
||||
long seekTimeUs) {
|
||||
Representation representation = representationHolder.representation;
|
||||
long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum);
|
||||
RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum);
|
||||
String baseUrl = representation.baseUrl;
|
||||
if (representationHolder.extractorWrapper == null) {
|
||||
long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum);
|
||||
DataSpec dataSpec = new DataSpec(segmentUri.resolveUri(baseUrl),
|
||||
segmentUri.start, segmentUri.length, representation.getCacheKey());
|
||||
return new SingleSampleMediaChunk(dataSource, dataSpec, trackFormat, trackSelectionReason,
|
||||
trackSelectionData, startTimeUs, endTimeUs, firstSegmentNum, trackType, trackFormat);
|
||||
} else {
|
||||
int segmentCount = 1;
|
||||
for (int i = 1; i < maxSegmentCount; i++) {
|
||||
RangedUri nextSegmentUri = representationHolder.getSegmentUrl(firstSegmentNum + i);
|
||||
RangedUri mergedSegmentUri = segmentUri.attemptMerge(nextSegmentUri, baseUrl);
|
||||
if (mergedSegmentUri == null) {
|
||||
// Unable to merge segment fetches because the URIs do not merge.
|
||||
break;
|
||||
}
|
||||
segmentUri = mergedSegmentUri;
|
||||
segmentCount++;
|
||||
}
|
||||
long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum + segmentCount - 1);
|
||||
long periodDurationUs = representationHolder.periodDurationUs;
|
||||
long clippedEndTimeUs =
|
||||
periodDurationUs != C.TIME_UNSET && periodDurationUs <= endTimeUs
|
||||
? periodDurationUs
|
||||
: C.TIME_UNSET;
|
||||
// TODO: next protobuf requests (during the playback)
|
||||
DataSpec dataSpec = new DataSpec(segmentUri.resolveUri(baseUrl),
|
||||
segmentUri.start, segmentUri.length, representation.getCacheKey());
|
||||
long sampleOffsetUs = -representation.presentationTimeOffsetUs;
|
||||
return new ContainerMediaChunk(
|
||||
dataSource,
|
||||
dataSpec,
|
||||
trackFormat,
|
||||
trackSelectionReason,
|
||||
trackSelectionData,
|
||||
startTimeUs,
|
||||
endTimeUs,
|
||||
seekTimeUs,
|
||||
clippedEndTimeUs,
|
||||
firstSegmentNum,
|
||||
segmentCount,
|
||||
sampleOffsetUs,
|
||||
representationHolder.extractorWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link MediaChunkIterator} wrapping a {@link RepresentationHolder}. */
|
||||
protected static final class RepresentationSegmentIterator extends BaseMediaChunkIterator {
|
||||
|
||||
private final RepresentationHolder representationHolder;
|
||||
|
||||
/**
|
||||
* Creates iterator.
|
||||
*
|
||||
* @param representation The {@link RepresentationHolder} to wrap.
|
||||
* @param firstAvailableSegmentNum The number of the first available segment.
|
||||
* @param lastAvailableSegmentNum The number of the last available segment.
|
||||
*/
|
||||
public RepresentationSegmentIterator(
|
||||
RepresentationHolder representation,
|
||||
long firstAvailableSegmentNum,
|
||||
long lastAvailableSegmentNum) {
|
||||
super(/* fromIndex= */ firstAvailableSegmentNum, /* toIndex= */ lastAvailableSegmentNum);
|
||||
this.representationHolder = representation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataSpec getDataSpec() {
|
||||
checkInBounds();
|
||||
Representation representation = representationHolder.representation;
|
||||
RangedUri segmentUri = representationHolder.getSegmentUrl(getCurrentIndex());
|
||||
Uri resolvedUri = segmentUri.resolveUri(representation.baseUrl);
|
||||
String cacheKey = representation.getCacheKey();
|
||||
return new DataSpec(resolvedUri, segmentUri.start, segmentUri.length, cacheKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getChunkStartTimeUs() {
|
||||
checkInBounds();
|
||||
return representationHolder.getSegmentStartTimeUs(getCurrentIndex());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getChunkEndTimeUs() {
|
||||
checkInBounds();
|
||||
return representationHolder.getSegmentEndTimeUs(getCurrentIndex());
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds information about a snapshot of a single {@link Representation}. */
|
||||
protected static final class RepresentationHolder {
|
||||
|
||||
/* package */ final @Nullable ChunkExtractor extractorWrapper;
|
||||
|
||||
public final Representation representation;
|
||||
public final @Nullable SabrSegmentIndex segmentIndex;
|
||||
|
||||
private final long periodDurationUs;
|
||||
private final long segmentNumShift;
|
||||
|
||||
/* package */ RepresentationHolder(
|
||||
long periodDurationUs,
|
||||
int trackType,
|
||||
Representation representation,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
TrackOutput playerEmsgTrackOutput) {
|
||||
this(
|
||||
periodDurationUs,
|
||||
representation,
|
||||
createExtractorWrapper(
|
||||
trackType,
|
||||
representation,
|
||||
enableEventMessageTrack,
|
||||
closedCaptionFormats,
|
||||
playerEmsgTrackOutput),
|
||||
/* segmentNumShift= */ 0,
|
||||
representation.getIndex());
|
||||
}
|
||||
|
||||
private RepresentationHolder(
|
||||
long periodDurationUs,
|
||||
Representation representation,
|
||||
@Nullable ChunkExtractor extractorWrapper,
|
||||
long segmentNumShift,
|
||||
@Nullable SabrSegmentIndex segmentIndex) {
|
||||
this.periodDurationUs = periodDurationUs;
|
||||
this.representation = representation;
|
||||
this.segmentNumShift = segmentNumShift;
|
||||
this.extractorWrapper = extractorWrapper;
|
||||
this.segmentIndex = segmentIndex;
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
/* package */ RepresentationHolder copyWithNewRepresentation(
|
||||
long newPeriodDurationUs, Representation newRepresentation)
|
||||
throws BehindLiveWindowException {
|
||||
SabrSegmentIndex oldIndex = representation.getIndex();
|
||||
SabrSegmentIndex newIndex = newRepresentation.getIndex();
|
||||
|
||||
if (oldIndex == null) {
|
||||
// Segment numbers cannot shift if the index isn't defined by the manifest.
|
||||
return new RepresentationHolder(
|
||||
newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, oldIndex);
|
||||
}
|
||||
|
||||
if (!oldIndex.isExplicit()) {
|
||||
// Segment numbers cannot shift if the index isn't explicit.
|
||||
return new RepresentationHolder(
|
||||
newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex);
|
||||
}
|
||||
|
||||
int oldIndexSegmentCount = oldIndex.getSegmentCount(newPeriodDurationUs);
|
||||
if (oldIndexSegmentCount == 0) {
|
||||
// Segment numbers cannot shift if the old index was empty.
|
||||
return new RepresentationHolder(
|
||||
newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex);
|
||||
}
|
||||
|
||||
long oldIndexFirstSegmentNum = oldIndex.getFirstSegmentNum();
|
||||
long oldIndexStartTimeUs = oldIndex.getTimeUs(oldIndexFirstSegmentNum);
|
||||
long oldIndexLastSegmentNum = oldIndexFirstSegmentNum + oldIndexSegmentCount - 1;
|
||||
long oldIndexEndTimeUs =
|
||||
oldIndex.getTimeUs(oldIndexLastSegmentNum)
|
||||
+ oldIndex.getDurationUs(oldIndexLastSegmentNum, newPeriodDurationUs);
|
||||
long newIndexFirstSegmentNum = newIndex.getFirstSegmentNum();
|
||||
long newIndexStartTimeUs = newIndex.getTimeUs(newIndexFirstSegmentNum);
|
||||
long newSegmentNumShift = segmentNumShift;
|
||||
if (oldIndexEndTimeUs == newIndexStartTimeUs) {
|
||||
// The new index continues where the old one ended, with no overlap.
|
||||
newSegmentNumShift += oldIndexLastSegmentNum + 1 - newIndexFirstSegmentNum;
|
||||
} else if (oldIndexEndTimeUs < newIndexStartTimeUs) {
|
||||
// There's a gap between the old index and the new one which means we've slipped behind the
|
||||
// live window and can't proceed.
|
||||
throw new BehindLiveWindowException();
|
||||
} else if (newIndexStartTimeUs < oldIndexStartTimeUs) {
|
||||
// The new index overlaps with (but does not have a start position contained within) the old
|
||||
// index. This can only happen if extra segments have been added to the start of the index.
|
||||
newSegmentNumShift -=
|
||||
newIndex.getSegmentNum(oldIndexStartTimeUs, newPeriodDurationUs)
|
||||
- oldIndexFirstSegmentNum;
|
||||
} else {
|
||||
// The new index overlaps with (and has a start position contained within) the old index.
|
||||
newSegmentNumShift +=
|
||||
oldIndex.getSegmentNum(newIndexStartTimeUs, newPeriodDurationUs)
|
||||
- newIndexFirstSegmentNum;
|
||||
}
|
||||
return new RepresentationHolder(
|
||||
newPeriodDurationUs, newRepresentation, extractorWrapper, newSegmentNumShift, newIndex);
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
/* package */ RepresentationHolder copyWithNewSegmentIndex(SabrSegmentIndex segmentIndex) {
|
||||
return new RepresentationHolder(
|
||||
periodDurationUs, representation, extractorWrapper, segmentNumShift, segmentIndex);
|
||||
}
|
||||
|
||||
public long getFirstSegmentNum() {
|
||||
return segmentIndex.getFirstSegmentNum() + segmentNumShift;
|
||||
}
|
||||
|
||||
public int getSegmentCount() {
|
||||
return segmentIndex.getSegmentCount(periodDurationUs);
|
||||
}
|
||||
|
||||
public long getSegmentStartTimeUs(long segmentNum) {
|
||||
return segmentIndex.getTimeUs(segmentNum - segmentNumShift);
|
||||
}
|
||||
|
||||
public long getSegmentEndTimeUs(long segmentNum) {
|
||||
return getSegmentStartTimeUs(segmentNum)
|
||||
+ segmentIndex.getDurationUs(segmentNum - segmentNumShift, periodDurationUs);
|
||||
}
|
||||
|
||||
public long getSegmentNum(long positionUs) {
|
||||
return segmentIndex.getSegmentNum(positionUs, periodDurationUs) + segmentNumShift;
|
||||
}
|
||||
|
||||
public RangedUri getSegmentUrl(long segmentNum) {
|
||||
return segmentIndex.getSegmentUrl(segmentNum - segmentNumShift);
|
||||
}
|
||||
|
||||
public long getFirstAvailableSegmentNum(
|
||||
SabrManifest manifest, int periodIndex, long nowUnixTimeUs) {
|
||||
if (getSegmentCount() == SabrSegmentIndex.INDEX_UNBOUNDED
|
||||
&& manifest.timeShiftBufferDepthMs != C.TIME_UNSET) {
|
||||
// The index is itself unbounded. We need to use the current time to calculate the range of
|
||||
// available segments.
|
||||
long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs);
|
||||
long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs);
|
||||
long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs;
|
||||
long bufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs);
|
||||
return Math.max(
|
||||
getFirstSegmentNum(), getSegmentNum(liveEdgeTimeInPeriodUs - bufferDepthUs));
|
||||
}
|
||||
return getFirstSegmentNum();
|
||||
}
|
||||
|
||||
public long getLastAvailableSegmentNum(
|
||||
SabrManifest manifest, int periodIndex, long nowUnixTimeUs) {
|
||||
int availableSegmentCount = getSegmentCount();
|
||||
if (availableSegmentCount == SabrSegmentIndex.INDEX_UNBOUNDED) {
|
||||
// The index is itself unbounded. We need to use the current time to calculate the range of
|
||||
// available segments.
|
||||
long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs);
|
||||
long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs);
|
||||
long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs;
|
||||
// getSegmentNum(liveEdgeTimeInPeriodUs) will not be completed yet, so subtract one to get
|
||||
// the index of the last completed segment.
|
||||
return getSegmentNum(liveEdgeTimeInPeriodUs) - 1;
|
||||
}
|
||||
return getFirstSegmentNum() + availableSegmentCount - 1;
|
||||
}
|
||||
|
||||
private static boolean mimeTypeIsWebm(String mimeType) {
|
||||
return mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM)
|
||||
|| mimeType.startsWith(MimeTypes.APPLICATION_WEBM);
|
||||
}
|
||||
|
||||
private static boolean mimeTypeIsRawText(String mimeType) {
|
||||
return MimeTypes.isText(mimeType) || MimeTypes.APPLICATION_TTML.equals(mimeType);
|
||||
}
|
||||
|
||||
|
||||
private static @Nullable ChunkExtractor createExtractorWrapper(
|
||||
int trackType,
|
||||
Representation representation,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
TrackOutput playerEmsgTrackOutput) {
|
||||
|
||||
String containerMimeType = representation.format.containerMimeType;
|
||||
if (mimeTypeIsRawText(containerMimeType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Extractor extractor = new SabrExtractor(trackType, representation.format);
|
||||
return new BundledChunkExtractor(extractor, trackType, representation.format);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.FormatHolder;
|
||||
import androidx.media3.decoder.DecoderInputBuffer;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessage;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessageEncoder;
|
||||
import androidx.media3.exoplayer.source.SampleStream;
|
||||
import com.futo.platformplayer.sabr.manifest.EventStream;
|
||||
import androidx.media3.common.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* A {@link SampleStream} consisting of serialized {@link EventMessage}s read from an
|
||||
* {@link EventStream}.
|
||||
*/
|
||||
@UnstableApi
|
||||
/* package */ final class EventSampleStream implements SampleStream {
|
||||
|
||||
private final Format upstreamFormat;
|
||||
private final EventMessageEncoder eventMessageEncoder;
|
||||
|
||||
private long[] eventTimesUs;
|
||||
private boolean eventStreamAppendable;
|
||||
private EventStream eventStream;
|
||||
|
||||
private boolean isFormatSentDownstream;
|
||||
private int currentIndex;
|
||||
private long pendingSeekPositionUs;
|
||||
|
||||
public EventSampleStream(
|
||||
EventStream eventStream, Format upstreamFormat, boolean eventStreamAppendable) {
|
||||
this.upstreamFormat = upstreamFormat;
|
||||
this.eventStream = eventStream;
|
||||
eventMessageEncoder = new EventMessageEncoder();
|
||||
pendingSeekPositionUs = C.TIME_UNSET;
|
||||
eventTimesUs = eventStream.presentationTimesUs;
|
||||
updateEventStream(eventStream, eventStreamAppendable);
|
||||
}
|
||||
|
||||
public String eventStreamId() {
|
||||
return eventStream.id();
|
||||
}
|
||||
|
||||
public void updateEventStream(EventStream eventStream, boolean eventStreamAppendable) {
|
||||
long lastReadPositionUs = currentIndex == 0 ? C.TIME_UNSET : eventTimesUs[currentIndex - 1];
|
||||
|
||||
this.eventStreamAppendable = eventStreamAppendable;
|
||||
this.eventStream = eventStream;
|
||||
this.eventTimesUs = eventStream.presentationTimesUs;
|
||||
if (pendingSeekPositionUs != C.TIME_UNSET) {
|
||||
seekToUs(pendingSeekPositionUs);
|
||||
} else if (lastReadPositionUs != C.TIME_UNSET) {
|
||||
currentIndex =
|
||||
Util.binarySearchCeil(
|
||||
eventTimesUs, lastReadPositionUs, /* inclusive= */ false, /* stayInBounds= */ false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks to the specified position in microseconds.
|
||||
*
|
||||
* @param positionUs The seek position in microseconds.
|
||||
*/
|
||||
public void seekToUs(long positionUs) {
|
||||
currentIndex =
|
||||
Util.binarySearchCeil(
|
||||
eventTimesUs, positionUs, /* inclusive= */ true, /* stayInBounds= */ false);
|
||||
boolean isPendingSeek = eventStreamAppendable && currentIndex == eventTimesUs.length;
|
||||
pendingSeekPositionUs = isPendingSeek ? positionUs : C.TIME_UNSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowError() throws IOException {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
@Override
|
||||
public int readData(
|
||||
FormatHolder formatHolder,
|
||||
DecoderInputBuffer buffer,
|
||||
@SampleStream.ReadFlags int readFlags) {
|
||||
|
||||
final boolean requireFormat = (readFlags & SampleStream.FLAG_REQUIRE_FORMAT) != 0;
|
||||
final boolean omitSampleData = (readFlags & SampleStream.FLAG_OMIT_SAMPLE_DATA) != 0;
|
||||
|
||||
if (requireFormat || !isFormatSentDownstream) {
|
||||
formatHolder.format = upstreamFormat;
|
||||
isFormatSentDownstream = true;
|
||||
return C.RESULT_FORMAT_READ;
|
||||
}
|
||||
|
||||
if (currentIndex == eventTimesUs.length) {
|
||||
if (!eventStreamAppendable) {
|
||||
buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM);
|
||||
return C.RESULT_BUFFER_READ;
|
||||
} else {
|
||||
return C.RESULT_NOTHING_READ;
|
||||
}
|
||||
}
|
||||
|
||||
final int sampleIndex = currentIndex++;
|
||||
final byte[] serializedEvent = eventMessageEncoder.encode(eventStream.events[sampleIndex]);
|
||||
if (serializedEvent == null) {
|
||||
return C.RESULT_NOTHING_READ;
|
||||
}
|
||||
|
||||
buffer.setFlags(C.BUFFER_FLAG_KEY_FRAME);
|
||||
buffer.timeUs = eventTimesUs[sampleIndex];
|
||||
|
||||
if (!omitSampleData) {
|
||||
buffer.ensureSpaceForWrite(serializedEvent.length);
|
||||
buffer.data.put(serializedEvent);
|
||||
}
|
||||
|
||||
return C.RESULT_BUFFER_READ;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int skipData(long positionUs) {
|
||||
int newIndex =
|
||||
Math.max(currentIndex, Util.binarySearchCeil(eventTimesUs, positionUs, true, false));
|
||||
int skipped = newIndex - currentIndex;
|
||||
currentIndex = newIndex;
|
||||
return skipped;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public final class ITagUtils {
|
||||
public final static String AUDIO_68K_WEBM = "249";
|
||||
public final static String AUDIO_89K_WEBM = "250";
|
||||
public final static String AUDIO_133K_WEBM = "171";
|
||||
public final static String AUDIO_156K_WEBM = "251";
|
||||
public final static String AUDIO_48K_AAC = "139";
|
||||
public final static String AUDIO_128K_AAC = "140";
|
||||
public final static String VIDEO_144P_WEBM = "278";
|
||||
public final static String VIDEO_144P_AVC = "160";
|
||||
public final static String VIDEO_240P_WEBM = "242";
|
||||
public final static String VIDEO_240P_AVC = "133";
|
||||
public final static String VIDEO_360P_WEBM = "243";
|
||||
public final static String VIDEO_360P_AVC = "134";
|
||||
public final static String VIDEO_480P_WEBM = "244";
|
||||
public final static String VIDEO_480P_AVC = "135";
|
||||
public final static String VIDEO_720P_WEBM = "247";
|
||||
public final static String VIDEO_720P_WEBM_60FPS_HDR = "334";
|
||||
public final static String VIDEO_720P_AVC = "136";
|
||||
public final static String VIDEO_720P_AVC_60FPS = "298";
|
||||
public final static String VIDEO_1080P_WEBM = "248";
|
||||
public final static String VIDEO_1080P_WEBM_60FPS_HDR = "335";
|
||||
public final static String VIDEO_1080P_AVC = "137";
|
||||
public final static String VIDEO_1080P_AVC_60FPS = "299";
|
||||
public final static String VIDEO_1440P_WEBM = "271";
|
||||
public final static String VIDEO_1440P_WEBM_60FPS_HDR = "336";
|
||||
public final static String VIDEO_1440P_WEBM_60FPS = "308";
|
||||
public final static String VIDEO_1440P_AVC = "264";
|
||||
public final static String VIDEO_2160P_WEBM = "313";
|
||||
public final static String VIDEO_2160P_WEBM_60FPS_HDR = "337";
|
||||
public final static String VIDEO_2160P_WEBM_60FPS = "315";
|
||||
public final static String VIDEO_2160P_AVC = "266";
|
||||
public final static String VIDEO_2160P_AVC_HQ = "138";
|
||||
|
||||
public final static String MUXED_360P_WEBM = "43";
|
||||
public final static String MUXED_360P_AVC = "18";
|
||||
public final static String MUXED_720P_AVC = "22";
|
||||
|
||||
private final static List<String> sOrderedITagsAVC = Arrays.asList(
|
||||
MUXED_360P_AVC, MUXED_720P_AVC,
|
||||
AUDIO_48K_AAC, AUDIO_128K_AAC,
|
||||
VIDEO_144P_AVC, VIDEO_240P_AVC,
|
||||
VIDEO_360P_AVC, VIDEO_480P_AVC, VIDEO_720P_AVC, VIDEO_720P_AVC_60FPS,
|
||||
VIDEO_1080P_AVC, VIDEO_1080P_AVC_60FPS, VIDEO_1440P_AVC, VIDEO_2160P_AVC, VIDEO_2160P_AVC_HQ);
|
||||
|
||||
private final static List<String> sOrderedITagsWEBM = Arrays.asList(
|
||||
MUXED_360P_WEBM,
|
||||
AUDIO_68K_WEBM, AUDIO_89K_WEBM, AUDIO_133K_WEBM, AUDIO_156K_WEBM,
|
||||
VIDEO_144P_WEBM, VIDEO_240P_WEBM,
|
||||
VIDEO_360P_WEBM, VIDEO_480P_WEBM, VIDEO_720P_WEBM, VIDEO_720P_WEBM_60FPS_HDR,
|
||||
VIDEO_1080P_WEBM, VIDEO_1080P_WEBM_60FPS_HDR, VIDEO_1440P_WEBM, VIDEO_1440P_WEBM_60FPS_HDR, VIDEO_1440P_WEBM_60FPS,
|
||||
VIDEO_2160P_WEBM, VIDEO_2160P_WEBM_60FPS_HDR, VIDEO_2160P_WEBM_60FPS);
|
||||
|
||||
private final static List<List<String>> sITagsContainer = Arrays.asList(sOrderedITagsAVC, sOrderedITagsWEBM);
|
||||
public static final String AVC = "AVC";
|
||||
public static final String WEBM = "VP9";
|
||||
|
||||
public static int compare(String leftITag, String rightITag) {
|
||||
for (List<String> iTags : sITagsContainer) {
|
||||
int left = iTags.indexOf(leftITag);
|
||||
int right = iTags.indexOf(rightITag);
|
||||
if (left != -1 && right != -1) {
|
||||
return left - right;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: we can't be here
|
||||
return 99;
|
||||
}
|
||||
|
||||
public static boolean belongsToType(String type, String iTag) {
|
||||
String realType = getRealType(iTag);
|
||||
return type.equals(realType);
|
||||
}
|
||||
|
||||
public static boolean belongsToType(String type, int iTag) {
|
||||
String realType = getRealType(String.valueOf(iTag));
|
||||
return type.equals(realType);
|
||||
}
|
||||
|
||||
private static String getRealType(String iTag) {
|
||||
if (sOrderedITagsAVC.contains(iTag)) {
|
||||
return AVC;
|
||||
}
|
||||
return WEBM;
|
||||
}
|
||||
|
||||
public static String getAudioRateByTag(String iTag) {
|
||||
switch (iTag) {
|
||||
case AUDIO_128K_AAC:
|
||||
return "44100";
|
||||
case AUDIO_48K_AAC:
|
||||
return "22050";
|
||||
case AUDIO_156K_WEBM:
|
||||
return "48000";
|
||||
case AUDIO_133K_WEBM:
|
||||
return "44100";
|
||||
case AUDIO_89K_WEBM:
|
||||
return "48000";
|
||||
case AUDIO_68K_WEBM:
|
||||
return "48000";
|
||||
}
|
||||
return "44100";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface MediaFormat extends Comparable<MediaFormat> {
|
||||
int FORMAT_TYPE_DASH = 0;
|
||||
int FORMAT_TYPE_REGULAR = 1;
|
||||
int FORMAT_TYPE_SABR = 2;
|
||||
// Common
|
||||
int getFormatType();
|
||||
String getUrl();
|
||||
String getMimeType();
|
||||
String getITag();
|
||||
boolean isDrc();
|
||||
|
||||
// DASH
|
||||
String getClen();
|
||||
String getBitrate();
|
||||
String getProjectionType();
|
||||
String getXtags();
|
||||
int getWidth();
|
||||
int getHeight();
|
||||
String getIndex();
|
||||
String getInit();
|
||||
String getFps();
|
||||
String getLmt();
|
||||
String getQualityLabel();
|
||||
String getFormat();
|
||||
boolean isOtf();
|
||||
String getOtfInitUrl();
|
||||
String getOtfTemplateUrl();
|
||||
String getLanguage();
|
||||
// DASH LIVE
|
||||
String getTargetDurationSec();
|
||||
String getLastModified();
|
||||
String getMaxDvrDurationSec();
|
||||
|
||||
// Other/Regular
|
||||
String getQuality();
|
||||
String getSignature();
|
||||
String getAudioSamplingRate();
|
||||
String getSourceUrl();
|
||||
List<String> getSegmentUrlList();
|
||||
List<String> getGlobalSegmentList();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
public class MediaFormatComparator implements Comparator<MediaFormat> {
|
||||
public static final int ORDER_DESCENDANT = 0;
|
||||
public static final int ORDER_ASCENDANT = 1;
|
||||
private int mOrderType = ORDER_DESCENDANT;
|
||||
|
||||
public MediaFormatComparator() {
|
||||
|
||||
}
|
||||
|
||||
public MediaFormatComparator(int orderType) {
|
||||
mOrderType = orderType;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: Descendant sorting (better on top). High quality playback on external player.
|
||||
*/
|
||||
@Override
|
||||
public int compare(MediaFormat leftItem, MediaFormat rightItem) {
|
||||
if (leftItem.getGlobalSegmentList() != null ||
|
||||
rightItem.getGlobalSegmentList() != null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (mOrderType == ORDER_ASCENDANT) {
|
||||
MediaFormat tmpItem = leftItem;
|
||||
leftItem = rightItem;
|
||||
rightItem = tmpItem;
|
||||
}
|
||||
|
||||
int leftItemBitrate = leftItem.getBitrate() == null ? 0 : parseInt(leftItem.getBitrate());
|
||||
int rightItemBitrate = rightItem.getBitrate() == null ? 0 : parseInt(rightItem.getBitrate());
|
||||
|
||||
int leftItemHeight = leftItem.getHeight();
|
||||
int rightItemHeight = rightItem.getHeight();
|
||||
|
||||
int delta = rightItemHeight - leftItemHeight;
|
||||
|
||||
if (delta == 0) {
|
||||
delta = rightItemBitrate - leftItemBitrate;
|
||||
}
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
public static boolean isNumeric(String s) {
|
||||
return s != null && s.matches("^[-+]?\\d*\\.?\\d+$");
|
||||
}
|
||||
|
||||
private int parseInt(String num) {
|
||||
if (!isNumeric(num)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Integer.parseInt(num);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class MediaFormatUtils {
|
||||
public static final String MIME_WEBM_AUDIO = "audio/webm";
|
||||
public static final String MIME_WEBM_VIDEO = "video/webm";
|
||||
public static final String MIME_MP4_AUDIO = "audio/mp4";
|
||||
public static final String MIME_MP4_VIDEO = "video/mp4";
|
||||
private static final Pattern CODECS_PATTERN = Pattern.compile(".*codecs=\\\"(.*)\\\"");
|
||||
|
||||
public static boolean isNumeric(String s) {
|
||||
return s != null && s.matches("^[-+]?\\d*\\.?\\d+$");
|
||||
}
|
||||
|
||||
public static boolean isDash(String id) {
|
||||
if (!isNumeric(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int maxRegularITag = 50;
|
||||
int itag = Integer.parseInt(id);
|
||||
|
||||
return itag > maxRegularITag;
|
||||
}
|
||||
|
||||
public static boolean isDash(MediaFormat format) {
|
||||
if (format.getITag() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (format.getGlobalSegmentList() != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String id = format.getITag();
|
||||
|
||||
return isDash(id);
|
||||
}
|
||||
|
||||
public static boolean checkMediaUrl(MediaFormat format) {
|
||||
return format != null && format.getUrl() != null;
|
||||
}
|
||||
|
||||
public static String extractMimeType(MediaFormat format) {
|
||||
if (format.getGlobalSegmentList() != null) {
|
||||
return format.getMimeType();
|
||||
}
|
||||
|
||||
String codecs = extractCodecs(format);
|
||||
|
||||
if (codecs.startsWith("vorbis") ||
|
||||
codecs.startsWith("opus")) {
|
||||
return MIME_WEBM_AUDIO;
|
||||
}
|
||||
|
||||
if (codecs.startsWith("vp9") ||
|
||||
codecs.startsWith("vp09")) {
|
||||
return MIME_WEBM_VIDEO;
|
||||
}
|
||||
|
||||
if (codecs.startsWith("mp4a") ||
|
||||
codecs.startsWith("ec-3") ||
|
||||
codecs.startsWith("ac-3")) {
|
||||
return MIME_MP4_AUDIO;
|
||||
}
|
||||
|
||||
if (codecs.startsWith("avc") ||
|
||||
codecs.startsWith("av01")) {
|
||||
return MIME_MP4_VIDEO;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String extractCodecs(MediaFormat format) {
|
||||
// input example: video/mp4;+codecs="avc1.640033"
|
||||
Matcher matcher = CODECS_PATTERN.matcher(format.getMimeType());
|
||||
matcher.find();
|
||||
return matcher.group(1);
|
||||
}
|
||||
|
||||
public static boolean isLiveMedia(MediaFormat format) {
|
||||
boolean isLive =
|
||||
format.getUrl().contains("live=1") ||
|
||||
format.getUrl().contains("yt_live_broadcast");
|
||||
|
||||
return isLive;
|
||||
}
|
||||
|
||||
private static String normalize(String word) {
|
||||
if (word == null || word.isEmpty()) {
|
||||
return word;
|
||||
}
|
||||
|
||||
return word.toLowerCase().replace("ё", "е");
|
||||
}
|
||||
|
||||
public static boolean startsWith(String word, String prefix) {
|
||||
if (word == null && prefix == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (word == null || prefix == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
word = normalize(word);
|
||||
prefix = normalize(prefix);
|
||||
|
||||
return word.startsWith(prefix);
|
||||
}
|
||||
|
||||
public static boolean isAudio(String mimeType) {
|
||||
return startsWith(mimeType, "audio");
|
||||
}
|
||||
|
||||
public static boolean isVideo(String mimeType) {
|
||||
return startsWith(mimeType, "video");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
public interface MediaItemFormatInfo {
|
||||
List<MediaFormat> getAdaptiveFormats();
|
||||
List<MediaFormat> getUrlFormats();
|
||||
List<MediaSubtitle> getSubtitles();
|
||||
String getHlsManifestUrl();
|
||||
String getDashManifestUrl();
|
||||
// video metadata
|
||||
String getLengthSeconds();
|
||||
String getTitle();
|
||||
String getAuthor();
|
||||
String getViewCount();
|
||||
String getDescription();
|
||||
String getVideoId();
|
||||
String getChannelId();
|
||||
boolean isLive();
|
||||
boolean isLiveContent();
|
||||
boolean containsMedia();
|
||||
boolean containsSabrFormats();
|
||||
boolean containsDashFormats();
|
||||
boolean containsHlsUrl();
|
||||
boolean containsDashUrl();
|
||||
boolean containsUrlFormats();
|
||||
boolean hasExtendedHlsFormats();
|
||||
float getVolumeLevel();
|
||||
InputStream createMpdStream();
|
||||
//Observable<InputStream> createMpdStreamObservable();
|
||||
List<String> createUrlList();
|
||||
MediaItemStoryboard createStoryboard();
|
||||
boolean isUnplayable();
|
||||
boolean isUnknownError();
|
||||
String getPlayabilityStatus();
|
||||
boolean isStreamSeekable();
|
||||
/**
|
||||
* Stream start time in UTC (!!!).<br/>
|
||||
* E.g.: <b>2021-10-06T13:36:25+00:00</b>
|
||||
*/
|
||||
String getStartTimestamp();
|
||||
String getUploadDate();
|
||||
/**
|
||||
* Stream start time in UNIX format.<br/>
|
||||
*/
|
||||
long getStartTimeMs();
|
||||
/**
|
||||
* Number of the stream first segment
|
||||
*/
|
||||
int getStartSegmentNum();
|
||||
/**
|
||||
* Precise segment duration.<br/>
|
||||
* Used inside live streams
|
||||
*/
|
||||
int getSegmentDurationUs();
|
||||
String getPaidContentText();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
public interface MediaItemStoryboard {
|
||||
int getGroupDurationMS();
|
||||
Size getGroupSize();
|
||||
String getGroupUrl(int imgNum);
|
||||
interface Size {
|
||||
int getDurationEachMS();
|
||||
int getStartNum();
|
||||
int getWidth();
|
||||
int getHeight();
|
||||
int getRowCount();
|
||||
int getColCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
public interface MediaSubtitle {
|
||||
String getBaseUrl();
|
||||
void setBaseUrl(String baseUrl);
|
||||
boolean isTranslatable();
|
||||
void setTranslatable(boolean translatable);
|
||||
String getLanguageCode();
|
||||
void setLanguageCode(String languageCode);
|
||||
String getVssId();
|
||||
void setVssId(String vssId);
|
||||
String getName();
|
||||
void setName(String name);
|
||||
String getMimeType();
|
||||
void setMimeType(String mimeType);
|
||||
String getCodecs();
|
||||
void setCodecs(String codecs);
|
||||
String getType();
|
||||
void setType(String type);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
class NullQueryString implements UrlQueryString {
|
||||
private final String mUrl;
|
||||
|
||||
private NullQueryString(String url) {
|
||||
mUrl = url;
|
||||
}
|
||||
|
||||
public static UrlQueryString parse(String url) {
|
||||
return new NullQueryString(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, String value) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, int value) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, float value) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return mUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Example: http://myurl.com/key1/value1/key2/value2/key3/value3<br/>
|
||||
* Should contain at least one key/value pair: http://myurl.com/key/value/<br/>
|
||||
* Regex: \/key\/([^\/]*)
|
||||
*/
|
||||
class PathQueryString implements UrlQueryString {
|
||||
private static final Pattern VALIDATION_PATTERN = Pattern.compile("\\/[^\\/]+\\/[^\\/]+\\/[^\\/]+");
|
||||
private static final Pattern ENDING_PATTERN = Pattern.compile("\\?.*");
|
||||
private String mUrl;
|
||||
|
||||
public static String replace(String content, Pattern oldVal, String newVal) {
|
||||
if (content == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return oldVal.matcher(content).replaceFirst(newVal);
|
||||
}
|
||||
|
||||
public PathQueryString(String url) {
|
||||
mUrl = replace(url, ENDING_PATTERN, "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
if (mUrl == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String template = "\\/%s\\/([^\\/]*)";
|
||||
Pattern pattern = Pattern.compile(String.format(template, key));
|
||||
Matcher matcher = pattern.matcher(mUrl);
|
||||
boolean result = matcher.find();
|
||||
return result ? matcher.group(1) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key) {
|
||||
String val = get(key);
|
||||
return val != null ? Float.parseFloat(val) : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, String value) {
|
||||
if (mUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!replace(key, value)) {
|
||||
String pattern = mUrl.endsWith("/") ? "%s/%s" : "/%s/%s";
|
||||
mUrl += String.format(pattern, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, float value) {
|
||||
set(key, String.valueOf(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, int value) {
|
||||
set(key, String.valueOf(value));
|
||||
}
|
||||
|
||||
private boolean replace(String key, String newValue) {
|
||||
if (mUrl == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String originUrl = mUrl;
|
||||
|
||||
final String template = "\\/%s\\/[^\\/]*";
|
||||
mUrl = mUrl.replaceAll(
|
||||
String.format(template, key),
|
||||
String.format("\\/%s\\/%s", key, newValue));
|
||||
|
||||
return !mUrl.equals(originUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
if (mUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String template = "\\/%s\\/[^\\/]*";
|
||||
mUrl = mUrl.replaceAll(String.format(template, key), "");
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return mUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return mUrl == null || mUrl.isEmpty();
|
||||
}
|
||||
|
||||
public static PathQueryString parse(String url) {
|
||||
return new PathQueryString(url);
|
||||
}
|
||||
|
||||
public static boolean matchAll(String input, Pattern... patterns) {
|
||||
for (Pattern pattern : patterns) {
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
if (mUrl == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return matchAll(mUrl, VALIDATION_PATTERN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return get(key) != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.DataReader;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.FormatHolder;
|
||||
import androidx.media3.common.ParserException;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
import androidx.media3.extractor.TrackOutput;
|
||||
import androidx.media3.common.Metadata;
|
||||
import androidx.media3.extractor.metadata.MetadataInputBuffer;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessage;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessageDecoder;
|
||||
import androidx.media3.exoplayer.source.SampleQueue;
|
||||
import androidx.media3.exoplayer.source.chunk.Chunk;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import androidx.media3.exoplayer.upstream.Allocator;
|
||||
import androidx.media3.common.util.ParsableByteArray;
|
||||
import androidx.media3.common.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.TreeMap;
|
||||
|
||||
@UnstableApi
|
||||
public final class PlayerEmsgHandler implements Handler.Callback {
|
||||
/** Callbacks for player emsg events encountered during DASH live stream. */
|
||||
public interface PlayerEmsgCallback {
|
||||
|
||||
/** Called when the current manifest should be refreshed. */
|
||||
void onDashManifestRefreshRequested();
|
||||
|
||||
/**
|
||||
* Called when the manifest with the publish time has been expired.
|
||||
*
|
||||
* @param expiredManifestPublishTimeUs The manifest publish time that has been expired.
|
||||
*/
|
||||
void onDashManifestPublishTimeExpired(long expiredManifestPublishTimeUs);
|
||||
}
|
||||
|
||||
private final Allocator allocator;
|
||||
private final PlayerEmsgCallback playerEmsgCallback;
|
||||
private final EventMessageDecoder decoder;
|
||||
private SabrManifest manifest;
|
||||
private final Handler handler;
|
||||
private final TreeMap<Long, Long> manifestPublishTimeToExpiryTimeUs;
|
||||
|
||||
private long expiredManifestPublishTimeUs;
|
||||
private long lastLoadedChunkEndTimeUs;
|
||||
private long lastLoadedChunkEndTimeBeforeRefreshUs;
|
||||
private boolean isWaitingForManifestRefresh;
|
||||
private boolean released;
|
||||
|
||||
/**
|
||||
* @param manifest The initial manifest.
|
||||
* @param playerEmsgCallback The callback that this event handler can invoke when handling emsg
|
||||
* messages that generate DASH media source events.
|
||||
* @param allocator An {@link Allocator} from which allocations can be obtained.
|
||||
*/
|
||||
public PlayerEmsgHandler(
|
||||
SabrManifest manifest, PlayerEmsgCallback playerEmsgCallback, Allocator allocator) {
|
||||
this.manifest = manifest;
|
||||
this.playerEmsgCallback = playerEmsgCallback;
|
||||
this.allocator = allocator;
|
||||
|
||||
manifestPublishTimeToExpiryTimeUs = new TreeMap<>();
|
||||
handler = Util.createHandlerForCurrentLooper(/* callback= */ this);
|
||||
decoder = new EventMessageDecoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the {@link SabrManifest} that this handler works on.
|
||||
*
|
||||
* @param newManifest The updated manifest.
|
||||
*/
|
||||
public void updateManifest(SabrManifest newManifest) {
|
||||
isWaitingForManifestRefresh = false;
|
||||
expiredManifestPublishTimeUs = C.TIME_UNSET;
|
||||
this.manifest = newManifest;
|
||||
removePreviouslyExpiredManifestPublishTimeValues();
|
||||
}
|
||||
|
||||
/* package */ boolean maybeRefreshManifestBeforeLoadingNextChunk(long presentationPositionUs) {
|
||||
if (!manifest.dynamic) {
|
||||
return false;
|
||||
}
|
||||
if (isWaitingForManifestRefresh) {
|
||||
return true;
|
||||
}
|
||||
boolean manifestRefreshNeeded = false;
|
||||
// Find the smallest publishTime (greater than or equal to the current manifest's publish time)
|
||||
// that has a corresponding expiry time.
|
||||
Map.Entry<Long, Long> expiredEntry = ceilingExpiryEntryForPublishTime(manifest.publishTimeMs);
|
||||
if (expiredEntry != null) {
|
||||
long expiredPointUs = expiredEntry.getValue();
|
||||
if (expiredPointUs < presentationPositionUs) {
|
||||
expiredManifestPublishTimeUs = expiredEntry.getKey();
|
||||
notifyManifestPublishTimeExpired();
|
||||
manifestRefreshNeeded = true;
|
||||
}
|
||||
}
|
||||
if (manifestRefreshNeeded) {
|
||||
maybeNotifyDashManifestRefreshNeeded();
|
||||
}
|
||||
return manifestRefreshNeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* For live streaming with emsg event stream, forward seeking can seek pass the emsg messages that
|
||||
* signals end-of-stream or Manifest expiry, which results in load error. In this case, we should
|
||||
* notify the Dash media source to refresh its manifest.
|
||||
*
|
||||
* @param chunk The chunk whose load encountered the error.
|
||||
* @return True if manifest refresh has been requested, false otherwise.
|
||||
*/
|
||||
/* package */ boolean maybeRefreshManifestOnLoadingError(Chunk chunk) {
|
||||
if (!manifest.dynamic) {
|
||||
return false;
|
||||
}
|
||||
if (isWaitingForManifestRefresh) {
|
||||
return true;
|
||||
}
|
||||
boolean isAfterForwardSeek =
|
||||
lastLoadedChunkEndTimeUs != C.TIME_UNSET && lastLoadedChunkEndTimeUs < chunk.startTimeUs;
|
||||
if (isAfterForwardSeek) {
|
||||
// if we are after a forward seek, and the playback is dynamic with embedded emsg stream,
|
||||
// there's a chance that we have seek over the emsg messages, in which case we should ask
|
||||
// media source for a refresh.
|
||||
maybeNotifyDashManifestRefreshNeeded();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the a new chunk in the current media stream has been loaded.
|
||||
*
|
||||
* @param chunk The chunk whose load has been completed.
|
||||
*/
|
||||
/* package */ void onChunkLoadCompleted(Chunk chunk) {
|
||||
if (lastLoadedChunkEndTimeUs == C.TIME_UNSET || chunk.endTimeUs > lastLoadedChunkEndTimeUs) {
|
||||
lastLoadedChunkEndTimeUs = chunk.endTimeUs;
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable Map.Entry<Long, Long> ceilingExpiryEntryForPublishTime(long publishTimeMs) {
|
||||
return manifestPublishTimeToExpiryTimeUs.ceilingEntry(publishTimeMs);
|
||||
}
|
||||
|
||||
private void removePreviouslyExpiredManifestPublishTimeValues() {
|
||||
for (Iterator<Entry<Long, Long>> it =
|
||||
manifestPublishTimeToExpiryTimeUs.entrySet().iterator();
|
||||
it.hasNext(); ) {
|
||||
Map.Entry<Long, Long> entry = it.next();
|
||||
long expiredManifestPublishTime = entry.getKey();
|
||||
if (expiredManifestPublishTime < manifest.publishTimeMs) {
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyManifestPublishTimeExpired() {
|
||||
playerEmsgCallback.onDashManifestPublishTimeExpired(expiredManifestPublishTimeUs);
|
||||
}
|
||||
|
||||
/** Requests DASH media manifest to be refreshed if necessary. */
|
||||
private void maybeNotifyDashManifestRefreshNeeded() {
|
||||
if (lastLoadedChunkEndTimeBeforeRefreshUs != C.TIME_UNSET
|
||||
&& lastLoadedChunkEndTimeBeforeRefreshUs == lastLoadedChunkEndTimeUs) {
|
||||
// Already requested manifest refresh.
|
||||
return;
|
||||
}
|
||||
isWaitingForManifestRefresh = true;
|
||||
lastLoadedChunkEndTimeBeforeRefreshUs = lastLoadedChunkEndTimeUs;
|
||||
playerEmsgCallback.onDashManifestRefreshRequested();
|
||||
}
|
||||
|
||||
/** Returns a {@link TrackOutput} that emsg messages could be written to. */
|
||||
public PlayerTrackEmsgHandler newPlayerTrackEmsgHandler() {
|
||||
return new PlayerTrackEmsgHandler(SampleQueue.createWithoutDrm(allocator));
|
||||
}
|
||||
|
||||
/** Release this emsg handler. It should not be reused after this call. */
|
||||
public void release() {
|
||||
released = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handleMessage(Message message) {
|
||||
if (released) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an event with given schemeIdUri and value is a DASH emsg event targeting the
|
||||
* player.
|
||||
*/
|
||||
public static boolean isPlayerEmsgEvent(String schemeIdUri, String value) {
|
||||
return "urn:mpeg:sabr:event:2025".equals(schemeIdUri)
|
||||
&& ("1".equals(value) || "2".equals(value) || "3".equals(value));
|
||||
}
|
||||
|
||||
/** Handles emsg messages for a specific track for the player. */
|
||||
public final class PlayerTrackEmsgHandler implements TrackOutput {
|
||||
private final SampleQueue sampleQueue;
|
||||
private final FormatHolder formatHolder;
|
||||
private final MetadataInputBuffer buffer;
|
||||
|
||||
private long maxLoadedChunkEndTimeUs;
|
||||
|
||||
public PlayerTrackEmsgHandler(SampleQueue sampleQueue) {
|
||||
this.sampleQueue = sampleQueue;
|
||||
this.formatHolder = new FormatHolder();
|
||||
this.buffer = new MetadataInputBuffer();
|
||||
this.maxLoadedChunkEndTimeUs = C.TIME_UNSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void format(Format format) {
|
||||
sampleQueue.format(format);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int sampleData(
|
||||
DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart)
|
||||
throws IOException {
|
||||
return sampleQueue.sampleData(input, length, allowEndOfInput);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) {
|
||||
sampleQueue.sampleData(data, length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sampleMetadata(
|
||||
long timeUs, int flags, int size, int offset, @Nullable CryptoData encryptionData) {
|
||||
sampleQueue.sampleMetadata(timeUs, flags, size, offset, encryptionData);
|
||||
parseAndDiscardSamples();
|
||||
}
|
||||
|
||||
/** For live streaming: check expiry before loading the next chunk. */
|
||||
public boolean maybeRefreshManifestBeforeLoadingNextChunk(long presentationPositionUs) {
|
||||
return PlayerEmsgHandler.this.maybeRefreshManifestBeforeLoadingNextChunk(presentationPositionUs);
|
||||
}
|
||||
|
||||
/** Called when a new chunk finished loading. */
|
||||
public void onChunkLoadCompleted(Chunk chunk) {
|
||||
if (maxLoadedChunkEndTimeUs == C.TIME_UNSET || chunk.endTimeUs > maxLoadedChunkEndTimeUs) {
|
||||
maxLoadedChunkEndTimeUs = chunk.endTimeUs;
|
||||
}
|
||||
PlayerEmsgHandler.this.onChunkLoadCompleted(chunk);
|
||||
}
|
||||
|
||||
/** Called when a chunk load errored; may trigger a manifest refresh. */
|
||||
public boolean maybeRefreshManifestOnLoadingError(Chunk chunk) {
|
||||
return PlayerEmsgHandler.this.maybeRefreshManifestOnLoadingError(chunk);
|
||||
}
|
||||
|
||||
/** Release this track emsg handler. It should not be reused after this call. */
|
||||
public void release() {
|
||||
sampleQueue.release();
|
||||
}
|
||||
|
||||
private void parseAndDiscardSamples() {
|
||||
while (sampleQueue.isReady(/* loadingFinished= */ false)) {
|
||||
MetadataInputBuffer inputBuffer = dequeueSample();
|
||||
if (inputBuffer == null) {
|
||||
continue;
|
||||
}
|
||||
long eventTimeUs = inputBuffer.timeUs;
|
||||
Metadata metadata = decoder.decode(inputBuffer);
|
||||
if (metadata == null) {
|
||||
continue;
|
||||
}
|
||||
EventMessage eventMessage = (EventMessage) metadata.get(0);
|
||||
if (isPlayerEmsgEvent(eventMessage.schemeIdUri, eventMessage.value)) {
|
||||
parsePlayerEmsgEvent(eventTimeUs, eventMessage);
|
||||
}
|
||||
}
|
||||
sampleQueue.discardToRead();
|
||||
}
|
||||
|
||||
private void parsePlayerEmsgEvent(long eventTimeUs, EventMessage eventMessage) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private MetadataInputBuffer dequeueSample() {
|
||||
buffer.clear();
|
||||
int result = sampleQueue.read(
|
||||
formatHolder, buffer, /* readFlags= */ 0, /* loadingFinished= */ false);
|
||||
if (result == C.RESULT_BUFFER_READ) {
|
||||
buffer.flip();
|
||||
return buffer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds information related to a manifest expiry event. */
|
||||
private static final class ManifestExpiryEventInfo {
|
||||
|
||||
public final long eventTimeUs;
|
||||
public final long manifestPublishTimeMsInEmsg;
|
||||
|
||||
public ManifestExpiryEventInfo(long eventTimeUs, long manifestPublishTimeMsInEmsg) {
|
||||
this.eventTimeUs = eventTimeUs;
|
||||
this.manifestPublishTimeMsInEmsg = manifestPublishTimeMsInEmsg;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkSource;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerTrackEmsgHandler;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
|
||||
import androidx.media3.exoplayer.upstream.LoaderErrorThrower;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An {@link ChunkSource} for DASH streams.
|
||||
*/
|
||||
@UnstableApi
|
||||
public interface SabrChunkSource extends ChunkSource {
|
||||
|
||||
/** Factory for {@link SabrChunkSource}s. */
|
||||
interface Factory {
|
||||
|
||||
/**
|
||||
* @param manifestLoaderErrorThrower Throws errors affecting loading of manifests.
|
||||
* @param manifest The initial manifest.
|
||||
* @param periodIndex The index of the corresponding period in the manifest.
|
||||
* @param adaptationSetIndices The indices of the corresponding adaptation sets in the period.
|
||||
* @param trackSelection The track selection.
|
||||
* @param elapsedRealtimeOffsetMs If known, an estimate of the instantaneous difference between
|
||||
* server-side unix time and {@link SystemClock#elapsedRealtime()} in milliseconds,
|
||||
* specified as the server's unix time minus the local elapsed time. If unknown, set to 0.
|
||||
* @param enableEventMessageTrack Whether to output an event message track.
|
||||
* @param closedCaptionFormats The {@link Format Formats} of closed caption tracks to be output.
|
||||
* @param transferListener The transfer listener which should be informed of any data transfers.
|
||||
* May be null if no listener is available.
|
||||
* @return The created {@link SabrChunkSource}.
|
||||
*/
|
||||
SabrChunkSource createSabrChunkSource(
|
||||
LoaderErrorThrower manifestLoaderErrorThrower,
|
||||
SabrManifest manifest,
|
||||
int periodIndex,
|
||||
int[] adaptationSetIndices,
|
||||
ExoTrackSelection trackSelection,
|
||||
int type,
|
||||
long elapsedRealtimeOffsetMs,
|
||||
boolean enableEventMessageTrack,
|
||||
List<Format> closedCaptionFormats,
|
||||
@Nullable PlayerTrackEmsgHandler playerEmsgHandler,
|
||||
@Nullable TransferListener transferListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the manifest.
|
||||
*
|
||||
* @param newManifest The new manifest.
|
||||
*/
|
||||
void updateManifest(SabrManifest newManifest, int periodIndex);
|
||||
|
||||
/**
|
||||
* Updates the track selection.
|
||||
*
|
||||
* @param trackSelection The new track selection instance. Must be equivalent to the previous one.
|
||||
*/
|
||||
void updateTrackSelection(ExoTrackSelection trackSelection);
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import android.util.Pair;
|
||||
import android.util.SparseIntArray;
|
||||
|
||||
import androidx.annotation.IntDef;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.LoadingInfo;
|
||||
import androidx.media3.exoplayer.SeekParameters;
|
||||
import androidx.media3.exoplayer.drm.DrmSessionEventListener;
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManager;
|
||||
import androidx.media3.exoplayer.source.CompositeSequenceableLoaderFactory;
|
||||
import androidx.media3.exoplayer.source.EmptySampleStream;
|
||||
import androidx.media3.exoplayer.source.MediaPeriod;
|
||||
import androidx.media3.exoplayer.source.MediaSourceEventListener.EventDispatcher;
|
||||
import androidx.media3.exoplayer.source.SampleStream;
|
||||
import androidx.media3.exoplayer.source.SequenceableLoader;
|
||||
import androidx.media3.common.TrackGroup;
|
||||
import androidx.media3.exoplayer.source.TrackGroupArray;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkSampleStream;
|
||||
import androidx.media3.exoplayer.source.chunk.ChunkSampleStream.EmbeddedSampleStream;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerEmsgCallback;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerTrackEmsgHandler;
|
||||
import com.futo.platformplayer.sabr.SabrChunkSource.Factory;
|
||||
import com.futo.platformplayer.sabr.manifest.AdaptationSet;
|
||||
import com.futo.platformplayer.sabr.manifest.EventStream;
|
||||
import com.futo.platformplayer.sabr.manifest.Period;
|
||||
import com.futo.platformplayer.sabr.manifest.Representation;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
|
||||
import androidx.media3.exoplayer.upstream.Allocator;
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
|
||||
import androidx.media3.exoplayer.upstream.LoaderErrorThrower;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
@UnstableApi
|
||||
final class SabrMediaPeriod implements MediaPeriod, SequenceableLoader.Callback<ChunkSampleStream<SabrChunkSource>>, ChunkSampleStream.ReleaseCallback<SabrChunkSource> {
|
||||
/* package */ final int id;
|
||||
private final Factory chunkSourceFactory;
|
||||
@Nullable
|
||||
private final TransferListener transferListener;
|
||||
private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
|
||||
private final EventDispatcher eventDispatcher;
|
||||
private final long elapsedRealtimeOffsetMs;
|
||||
private final LoaderErrorThrower manifestLoaderErrorThrower;
|
||||
private final TrackGroupArray trackGroups;
|
||||
private final TrackGroupInfo[] trackGroupInfos;
|
||||
private final Allocator allocator;
|
||||
private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
|
||||
private final PlayerEmsgHandler playerEmsgHandler;
|
||||
private final IdentityHashMap<ChunkSampleStream<SabrChunkSource>, PlayerTrackEmsgHandler>
|
||||
trackEmsgHandlerBySampleStream;
|
||||
|
||||
private @Nullable Callback callback;
|
||||
private ChunkSampleStream<SabrChunkSource>[] sampleStreams;
|
||||
private SequenceableLoader compositeSequenceableLoader;
|
||||
private EventSampleStream[] eventSampleStreams;
|
||||
private SabrManifest manifest;
|
||||
private int periodIndex;
|
||||
private List<EventStream> eventStreams;
|
||||
private boolean notifiedReadingStarted;
|
||||
private final DrmSessionManager drmSessionManager;
|
||||
private final DrmSessionEventListener.EventDispatcher drmEventDispatcher;
|
||||
|
||||
public SabrMediaPeriod(
|
||||
int id,
|
||||
SabrManifest manifest,
|
||||
int periodIndex,
|
||||
SabrChunkSource.Factory chunkSourceFactory,
|
||||
@Nullable TransferListener transferListener,
|
||||
LoadErrorHandlingPolicy loadErrorHandlingPolicy,
|
||||
EventDispatcher eventDispatcher,
|
||||
long elapsedRealtimeOffsetMs,
|
||||
LoaderErrorThrower manifestLoaderErrorThrower,
|
||||
Allocator allocator,
|
||||
CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory,
|
||||
PlayerEmsgCallback playerEmsgCallback) {
|
||||
this.id = id;
|
||||
this.manifest = manifest;
|
||||
this.periodIndex = periodIndex;
|
||||
this.chunkSourceFactory = chunkSourceFactory;
|
||||
this.transferListener = transferListener;
|
||||
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
|
||||
this.eventDispatcher = eventDispatcher;
|
||||
this.elapsedRealtimeOffsetMs = elapsedRealtimeOffsetMs;
|
||||
this.manifestLoaderErrorThrower = manifestLoaderErrorThrower;
|
||||
this.allocator = allocator;
|
||||
this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory;
|
||||
playerEmsgHandler = new PlayerEmsgHandler(manifest, playerEmsgCallback, allocator);
|
||||
sampleStreams = newSampleStreamArray(0);
|
||||
eventSampleStreams = new EventSampleStream[0];
|
||||
trackEmsgHandlerBySampleStream = new IdentityHashMap<>();
|
||||
compositeSequenceableLoader =
|
||||
compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(sampleStreams);
|
||||
Period period = manifest.getPeriod(periodIndex);
|
||||
Pair<TrackGroupArray, TrackGroupInfo[]> result = buildTrackGroups(period.adaptationSets);
|
||||
trackGroups = result.first;
|
||||
trackGroupInfos = result.second;
|
||||
this.drmSessionManager = DrmSessionManager.DRM_UNSUPPORTED;
|
||||
this.drmEventDispatcher = new DrmSessionEventListener.EventDispatcher();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLoading() {
|
||||
return compositeSequenceableLoader.isLoading();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepare(Callback callback, long positionUs) {
|
||||
this.callback = callback;
|
||||
callback.onPrepared(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowPrepareError() throws IOException {
|
||||
manifestLoaderErrorThrower.maybeThrowError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrackGroupArray getTrackGroups() {
|
||||
return trackGroups;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long selectTracks(
|
||||
@Nullable ExoTrackSelection[] selections,
|
||||
boolean[] mayRetainStreamFlags,
|
||||
@Nullable SampleStream[] streams,
|
||||
boolean[] streamResetFlags,
|
||||
long positionUs) {
|
||||
int[] streamIndexToTrackGroupIndex = getStreamIndexToTrackGroupIndex(selections);
|
||||
releaseDisabledStreams(selections, mayRetainStreamFlags, streams);
|
||||
releaseOrphanEmbeddedStreams(selections, streams, streamIndexToTrackGroupIndex);
|
||||
selectNewStreams(
|
||||
selections, streams, streamResetFlags, positionUs, streamIndexToTrackGroupIndex);
|
||||
|
||||
ArrayList<ChunkSampleStream<SabrChunkSource>> sampleStreamList = new ArrayList<>();
|
||||
ArrayList<EventSampleStream> eventSampleStreamList = new ArrayList<>();
|
||||
for (SampleStream sampleStream : streams) {
|
||||
if (sampleStream instanceof ChunkSampleStream) {
|
||||
@SuppressWarnings("unchecked")
|
||||
ChunkSampleStream<SabrChunkSource> stream =
|
||||
(ChunkSampleStream<SabrChunkSource>) sampleStream;
|
||||
sampleStreamList.add(stream);
|
||||
} else if (sampleStream instanceof EventSampleStream) {
|
||||
eventSampleStreamList.add((EventSampleStream) sampleStream);
|
||||
}
|
||||
}
|
||||
sampleStreams = newSampleStreamArray(sampleStreamList.size());
|
||||
sampleStreamList.toArray(sampleStreams);
|
||||
eventSampleStreams = new EventSampleStream[eventSampleStreamList.size()];
|
||||
eventSampleStreamList.toArray(eventSampleStreams);
|
||||
|
||||
compositeSequenceableLoader =
|
||||
compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(sampleStreams);
|
||||
return positionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void discardBuffer(long positionUs, boolean toKeyframe) {
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
sampleStream.discardBuffer(positionUs, toKeyframe);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long readDiscontinuity() {
|
||||
if (!notifiedReadingStarted) {
|
||||
notifiedReadingStarted = true;
|
||||
}
|
||||
return C.TIME_UNSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long seekToUs(long positionUs) {
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
sampleStream.seekToUs(positionUs);
|
||||
}
|
||||
for (EventSampleStream sampleStream : eventSampleStreams) {
|
||||
sampleStream.seekToUs(positionUs);
|
||||
}
|
||||
return positionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
if (sampleStream.primaryTrackType == C.TRACK_TYPE_VIDEO) {
|
||||
return sampleStream.getAdjustedSeekPositionUs(positionUs, seekParameters);
|
||||
}
|
||||
}
|
||||
return positionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBufferedPositionUs() {
|
||||
return compositeSequenceableLoader.getBufferedPositionUs();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getNextLoadPositionUs() {
|
||||
return compositeSequenceableLoader.getNextLoadPositionUs();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean continueLoading(LoadingInfo loadingInfo) {
|
||||
return compositeSequenceableLoader.continueLoading(loadingInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reevaluateBuffer(long positionUs) {
|
||||
compositeSequenceableLoader.reevaluateBuffer(positionUs);
|
||||
}
|
||||
|
||||
// SequenceableLoader.Callback implementation.
|
||||
@Override
|
||||
public void onContinueLoadingRequested(ChunkSampleStream<SabrChunkSource> source) {
|
||||
callback.onContinueLoadingRequested(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onSampleStreamReleased(ChunkSampleStream<SabrChunkSource> stream) {
|
||||
PlayerTrackEmsgHandler trackEmsgHandler = trackEmsgHandlerBySampleStream.remove(stream);
|
||||
if (trackEmsgHandler != null) {
|
||||
trackEmsgHandler.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the {@link SabrManifest} and the index of this period in the manifest.
|
||||
*
|
||||
* @param manifest The updated manifest.
|
||||
* @param periodIndex the new index of this period in the updated manifest.
|
||||
*/
|
||||
public void updateManifest(SabrManifest manifest, int periodIndex) {
|
||||
this.manifest = manifest;
|
||||
this.periodIndex = periodIndex;
|
||||
playerEmsgHandler.updateManifest(manifest);
|
||||
if (sampleStreams != null) {
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
sampleStream.getChunkSource().updateManifest(manifest, periodIndex);
|
||||
}
|
||||
callback.onContinueLoadingRequested(this);
|
||||
}
|
||||
}
|
||||
|
||||
public void release() {
|
||||
playerEmsgHandler.release();
|
||||
for (ChunkSampleStream<SabrChunkSource> sampleStream : sampleStreams) {
|
||||
sampleStream.release(this);
|
||||
}
|
||||
callback = null;
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ChunkSampleStream<SabrChunkSource>[] newSampleStreamArray(int length) {
|
||||
return new ChunkSampleStream[length];
|
||||
}
|
||||
|
||||
private static Pair<TrackGroupArray, TrackGroupInfo[]> buildTrackGroups(
|
||||
List<AdaptationSet> adaptationSets) {
|
||||
int[][] groupedAdaptationSetIndices = getGroupedAdaptationSetIndices(adaptationSets);
|
||||
|
||||
int primaryGroupCount = groupedAdaptationSetIndices.length;
|
||||
boolean[] primaryGroupHasEventMessageTrackFlags = new boolean[primaryGroupCount];
|
||||
Format[][] primaryGroupCea608TrackFormats = new Format[primaryGroupCount][];
|
||||
int totalEmbeddedTrackGroupCount =
|
||||
identifyEmbeddedTracks(
|
||||
primaryGroupCount,
|
||||
adaptationSets,
|
||||
groupedAdaptationSetIndices,
|
||||
primaryGroupHasEventMessageTrackFlags,
|
||||
primaryGroupCea608TrackFormats);
|
||||
|
||||
int totalGroupCount = primaryGroupCount + totalEmbeddedTrackGroupCount;
|
||||
TrackGroup[] trackGroups = new TrackGroup[totalGroupCount];
|
||||
TrackGroupInfo[] trackGroupInfos = new TrackGroupInfo[totalGroupCount];
|
||||
|
||||
int trackGroupCount =
|
||||
buildPrimaryAndEmbeddedTrackGroupInfos(
|
||||
adaptationSets,
|
||||
groupedAdaptationSetIndices,
|
||||
primaryGroupCount,
|
||||
primaryGroupHasEventMessageTrackFlags,
|
||||
primaryGroupCea608TrackFormats,
|
||||
trackGroups,
|
||||
trackGroupInfos);
|
||||
|
||||
return Pair.create(new TrackGroupArray(trackGroups), trackGroupInfos);
|
||||
}
|
||||
|
||||
private static int[][] getGroupedAdaptationSetIndices(List<AdaptationSet> adaptationSets) {
|
||||
int adaptationSetCount = adaptationSets.size();
|
||||
SparseIntArray idToIndexMap = new SparseIntArray(adaptationSetCount);
|
||||
for (int i = 0; i < adaptationSetCount; i++) {
|
||||
idToIndexMap.put(adaptationSets.get(i).id, i);
|
||||
}
|
||||
|
||||
int[][] groupedAdaptationSetIndices = new int[adaptationSetCount][];
|
||||
boolean[] adaptationSetUsedFlags = new boolean[adaptationSetCount];
|
||||
|
||||
int groupCount = 0;
|
||||
for (int i = 0; i < adaptationSetCount; i++) {
|
||||
if (adaptationSetUsedFlags[i]) {
|
||||
// This adaptation set has already been included in a group.
|
||||
continue;
|
||||
}
|
||||
adaptationSetUsedFlags[i] = true;
|
||||
groupedAdaptationSetIndices[groupCount++] = new int[] {i};
|
||||
}
|
||||
|
||||
return groupCount < adaptationSetCount
|
||||
? Arrays.copyOf(groupedAdaptationSetIndices, groupCount) : groupedAdaptationSetIndices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through list of primary track groups and identifies embedded tracks.
|
||||
*
|
||||
* @param primaryGroupCount The number of primary track groups.
|
||||
* @param adaptationSets The list of {@link AdaptationSet} of the current DASH period.
|
||||
* @param groupedAdaptationSetIndices The indices of {@link AdaptationSet} that belongs to the
|
||||
* same primary group, grouped in primary track groups order.
|
||||
* @param primaryGroupHasEventMessageTrackFlags An output array to be filled with flags indicating
|
||||
* whether each of the primary track groups contains an embedded event message track.
|
||||
* @param primaryGroupCea608TrackFormats An output array to be filled with track formats for
|
||||
* CEA-608 tracks embedded in each of the primary track groups.
|
||||
* @return Total number of embedded track groups.
|
||||
*/
|
||||
private static int identifyEmbeddedTracks(
|
||||
int primaryGroupCount,
|
||||
List<AdaptationSet> adaptationSets,
|
||||
int[][] groupedAdaptationSetIndices,
|
||||
boolean[] primaryGroupHasEventMessageTrackFlags,
|
||||
Format[][] primaryGroupCea608TrackFormats) {
|
||||
int numEmbeddedTrackGroups = 0;
|
||||
for (int i = 0; i < primaryGroupCount; i++) {
|
||||
primaryGroupCea608TrackFormats[i] =
|
||||
getCea608TrackFormats(adaptationSets, groupedAdaptationSetIndices[i]);
|
||||
if (primaryGroupCea608TrackFormats[i].length != 0) {
|
||||
numEmbeddedTrackGroups++;
|
||||
}
|
||||
}
|
||||
return numEmbeddedTrackGroups;
|
||||
}
|
||||
|
||||
private static int buildPrimaryAndEmbeddedTrackGroupInfos(
|
||||
List<AdaptationSet> adaptationSets,
|
||||
int[][] groupedAdaptationSetIndices,
|
||||
int primaryGroupCount,
|
||||
boolean[] primaryGroupHasEventMessageTrackFlags,
|
||||
Format[][] primaryGroupCea608TrackFormats,
|
||||
TrackGroup[] trackGroups,
|
||||
TrackGroupInfo[] trackGroupInfos) {
|
||||
int trackGroupCount = 0;
|
||||
for (int i = 0; i < primaryGroupCount; i++) {
|
||||
int[] adaptationSetIndices = groupedAdaptationSetIndices[i];
|
||||
List<Representation> representations = new ArrayList<>();
|
||||
for (int adaptationSetIndex : adaptationSetIndices) {
|
||||
representations.addAll(adaptationSets.get(adaptationSetIndex).representations);
|
||||
}
|
||||
Format[] formats = new Format[representations.size()];
|
||||
for (int j = 0; j < formats.length; j++) {
|
||||
formats[j] = representations.get(j).format;
|
||||
}
|
||||
|
||||
AdaptationSet firstAdaptationSet = adaptationSets.get(adaptationSetIndices[0]);
|
||||
int primaryTrackGroupIndex = trackGroupCount++;
|
||||
int eventMessageTrackGroupIndex =
|
||||
primaryGroupHasEventMessageTrackFlags[i] ? trackGroupCount++ : C.INDEX_UNSET;
|
||||
int cea608TrackGroupIndex =
|
||||
primaryGroupCea608TrackFormats[i].length != 0 ? trackGroupCount++ : C.INDEX_UNSET;
|
||||
|
||||
trackGroups[primaryTrackGroupIndex] = new TrackGroup(formats);
|
||||
trackGroupInfos[primaryTrackGroupIndex] =
|
||||
TrackGroupInfo.primaryTrack(
|
||||
firstAdaptationSet.type,
|
||||
adaptationSetIndices,
|
||||
primaryTrackGroupIndex,
|
||||
eventMessageTrackGroupIndex,
|
||||
cea608TrackGroupIndex);
|
||||
if (eventMessageTrackGroupIndex != C.INDEX_UNSET) {
|
||||
Format format =
|
||||
new Format.Builder()
|
||||
.setId(firstAdaptationSet.id + ":emsg")
|
||||
.setSampleMimeType(MimeTypes.APPLICATION_EMSG)
|
||||
.build();
|
||||
trackGroups[eventMessageTrackGroupIndex] = new TrackGroup(format);
|
||||
trackGroupInfos[eventMessageTrackGroupIndex] =
|
||||
TrackGroupInfo.embeddedEmsgTrack(adaptationSetIndices, primaryTrackGroupIndex);
|
||||
}
|
||||
if (cea608TrackGroupIndex != C.INDEX_UNSET) {
|
||||
trackGroups[cea608TrackGroupIndex] = new TrackGroup(primaryGroupCea608TrackFormats[i]);
|
||||
trackGroupInfos[cea608TrackGroupIndex] =
|
||||
TrackGroupInfo.embeddedCea608Track(adaptationSetIndices, primaryTrackGroupIndex);
|
||||
}
|
||||
}
|
||||
return trackGroupCount;
|
||||
}
|
||||
|
||||
private static Format[] getCea608TrackFormats(
|
||||
List<AdaptationSet> adaptationSets, int[] adaptationSetIndices) {
|
||||
return new Format[0];
|
||||
}
|
||||
|
||||
private int[] getStreamIndexToTrackGroupIndex(ExoTrackSelection[] selections) {
|
||||
int[] streamIndexToTrackGroupIndex = new int[selections.length];
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
if (selections[i] != null) {
|
||||
streamIndexToTrackGroupIndex[i] = trackGroups.indexOf(selections[i].getTrackGroup());
|
||||
} else {
|
||||
streamIndexToTrackGroupIndex[i] = C.INDEX_UNSET;
|
||||
}
|
||||
}
|
||||
return streamIndexToTrackGroupIndex;
|
||||
}
|
||||
|
||||
private void releaseDisabledStreams(ExoTrackSelection[] selections, boolean[] mayRetainStreamFlags, SampleStream[] streams) {
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
if (selections[i] == null || !mayRetainStreamFlags[i]) {
|
||||
if (streams[i] instanceof ChunkSampleStream) {
|
||||
@SuppressWarnings("unchecked")
|
||||
ChunkSampleStream<SabrChunkSource> stream =
|
||||
(ChunkSampleStream<SabrChunkSource>) streams[i];
|
||||
stream.release(this);
|
||||
} else if (streams[i] instanceof ChunkSampleStream.EmbeddedSampleStream) {
|
||||
((EmbeddedSampleStream) streams[i]).release();
|
||||
}
|
||||
streams[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseOrphanEmbeddedStreams(ExoTrackSelection[] selections, SampleStream[] streams, int[] streamIndexToTrackGroupIndex) {
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
if (streams[i] instanceof EmptySampleStream || streams[i] instanceof EmbeddedSampleStream) {
|
||||
// We need to release an embedded stream if the corresponding primary stream is released.
|
||||
int primaryStreamIndex = getPrimaryStreamIndex(i, streamIndexToTrackGroupIndex);
|
||||
boolean mayRetainStream;
|
||||
if (primaryStreamIndex == C.INDEX_UNSET) {
|
||||
// If the corresponding primary stream is not selected, we may retain an existing
|
||||
// EmptySampleStream.
|
||||
mayRetainStream = streams[i] instanceof EmptySampleStream;
|
||||
} else {
|
||||
// If the corresponding primary stream is selected, we may retain the embedded stream if
|
||||
// the stream's parent still matches.
|
||||
mayRetainStream =
|
||||
(streams[i] instanceof EmbeddedSampleStream)
|
||||
&& ((EmbeddedSampleStream) streams[i]).parent == streams[primaryStreamIndex];
|
||||
}
|
||||
if (!mayRetainStream) {
|
||||
if (streams[i] instanceof EmbeddedSampleStream) {
|
||||
((EmbeddedSampleStream) streams[i]).release();
|
||||
}
|
||||
streams[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int getPrimaryStreamIndex(int embeddedStreamIndex, int[] streamIndexToTrackGroupIndex) {
|
||||
int embeddedTrackGroupIndex = streamIndexToTrackGroupIndex[embeddedStreamIndex];
|
||||
if (embeddedTrackGroupIndex == C.INDEX_UNSET) {
|
||||
return C.INDEX_UNSET;
|
||||
}
|
||||
int primaryTrackGroupIndex = trackGroupInfos[embeddedTrackGroupIndex].primaryTrackGroupIndex;
|
||||
for (int i = 0; i < streamIndexToTrackGroupIndex.length; i++) {
|
||||
int trackGroupIndex = streamIndexToTrackGroupIndex[i];
|
||||
if (trackGroupIndex == primaryTrackGroupIndex
|
||||
&& trackGroupInfos[trackGroupIndex].trackGroupCategory
|
||||
== TrackGroupInfo.CATEGORY_PRIMARY) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return C.INDEX_UNSET;
|
||||
}
|
||||
|
||||
private void selectNewStreams(ExoTrackSelection[] selections, SampleStream[] streams, boolean[] streamResetFlags, long positionUs, int[] streamIndexToTrackGroupIndex) {
|
||||
// Create newly selected primary and event streams.
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
ExoTrackSelection selection = selections[i];
|
||||
if (selection == null) {
|
||||
continue;
|
||||
}
|
||||
if (streams[i] == null) {
|
||||
// Create new stream for selection.
|
||||
streamResetFlags[i] = true;
|
||||
int trackGroupIndex = streamIndexToTrackGroupIndex[i];
|
||||
TrackGroupInfo trackGroupInfo = trackGroupInfos[trackGroupIndex];
|
||||
if (trackGroupInfo.trackGroupCategory == TrackGroupInfo.CATEGORY_PRIMARY) {
|
||||
streams[i] = buildSampleStream(trackGroupInfo, selection, positionUs);
|
||||
} else if (trackGroupInfo.trackGroupCategory == TrackGroupInfo.CATEGORY_MANIFEST_EVENTS) {
|
||||
EventStream eventStream = eventStreams.get(trackGroupInfo.eventStreamGroupIndex);
|
||||
Format format = selection.getTrackGroup().getFormat(0);
|
||||
streams[i] = new EventSampleStream(eventStream, format, manifest.dynamic);
|
||||
}
|
||||
} else if (streams[i] instanceof ChunkSampleStream) {
|
||||
// Update selection in existing stream.
|
||||
@SuppressWarnings("unchecked")
|
||||
ChunkSampleStream<SabrChunkSource> stream = (ChunkSampleStream<SabrChunkSource>) streams[i];
|
||||
stream.getChunkSource().updateTrackSelection(selection);
|
||||
}
|
||||
}
|
||||
// Create newly selected embedded streams from the corresponding primary stream. Note that this
|
||||
// second pass is needed because the primary stream may not have been created yet in a first
|
||||
// pass if the index of the primary stream is greater than the index of the embedded stream.
|
||||
for (int i = 0; i < selections.length; i++) {
|
||||
if (streams[i] == null && selections[i] != null) {
|
||||
int trackGroupIndex = streamIndexToTrackGroupIndex[i];
|
||||
TrackGroupInfo trackGroupInfo = trackGroupInfos[trackGroupIndex];
|
||||
if (trackGroupInfo.trackGroupCategory == TrackGroupInfo.CATEGORY_EMBEDDED) {
|
||||
int primaryStreamIndex = getPrimaryStreamIndex(i, streamIndexToTrackGroupIndex);
|
||||
if (primaryStreamIndex == C.INDEX_UNSET) {
|
||||
// If an embedded track is selected without the corresponding primary track, create an
|
||||
// empty sample stream instead.
|
||||
streams[i] = new EmptySampleStream();
|
||||
} else {
|
||||
streams[i] =
|
||||
((ChunkSampleStream) streams[primaryStreamIndex])
|
||||
.selectEmbeddedTrack(positionUs, trackGroupInfo.trackType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ChunkSampleStream<SabrChunkSource> buildSampleStream(
|
||||
TrackGroupInfo trackGroupInfo,
|
||||
ExoTrackSelection selection,
|
||||
long positionUs) {
|
||||
|
||||
int embeddedTrackCount = 0;
|
||||
boolean enableEventMessageTrack =
|
||||
trackGroupInfo.embeddedEventMessageTrackGroupIndex != C.INDEX_UNSET;
|
||||
TrackGroup embeddedEventMessageTrackGroup = null;
|
||||
if (enableEventMessageTrack) {
|
||||
embeddedEventMessageTrackGroup =
|
||||
trackGroups.get(trackGroupInfo.embeddedEventMessageTrackGroupIndex);
|
||||
embeddedTrackCount++;
|
||||
}
|
||||
boolean enableCea608Tracks =
|
||||
trackGroupInfo.embeddedCea608TrackGroupIndex != C.INDEX_UNSET;
|
||||
TrackGroup embeddedCea608TrackGroup = null;
|
||||
if (enableCea608Tracks) {
|
||||
embeddedCea608TrackGroup =
|
||||
trackGroups.get(trackGroupInfo.embeddedCea608TrackGroupIndex);
|
||||
embeddedTrackCount += embeddedCea608TrackGroup.length;
|
||||
}
|
||||
|
||||
Format[] embeddedTrackFormats = new Format[embeddedTrackCount];
|
||||
int[] embeddedTrackTypes = new int[embeddedTrackCount];
|
||||
embeddedTrackCount = 0;
|
||||
|
||||
if (enableEventMessageTrack) {
|
||||
embeddedTrackFormats[embeddedTrackCount] =
|
||||
embeddedEventMessageTrackGroup.getFormat(0);
|
||||
embeddedTrackTypes[embeddedTrackCount] = C.TRACK_TYPE_METADATA;
|
||||
embeddedTrackCount++;
|
||||
}
|
||||
|
||||
List<Format> embeddedCea608TrackFormats = new ArrayList<>();
|
||||
if (enableCea608Tracks) {
|
||||
for (int i = 0; i < embeddedCea608TrackGroup.length; i++) {
|
||||
embeddedTrackFormats[embeddedTrackCount] =
|
||||
embeddedCea608TrackGroup.getFormat(i);
|
||||
embeddedTrackTypes[embeddedTrackCount] = C.TRACK_TYPE_TEXT;
|
||||
embeddedCea608TrackFormats.add(embeddedTrackFormats[embeddedTrackCount]);
|
||||
embeddedTrackCount++;
|
||||
}
|
||||
}
|
||||
|
||||
PlayerTrackEmsgHandler trackPlayerEmsgHandler =
|
||||
manifest.dynamic && enableEventMessageTrack
|
||||
? playerEmsgHandler.newPlayerTrackEmsgHandler()
|
||||
: null;
|
||||
|
||||
SabrChunkSource chunkSource =
|
||||
chunkSourceFactory.createSabrChunkSource(
|
||||
manifestLoaderErrorThrower,
|
||||
manifest,
|
||||
periodIndex,
|
||||
trackGroupInfo.adaptationSetIndices,
|
||||
selection,
|
||||
trackGroupInfo.trackType,
|
||||
elapsedRealtimeOffsetMs,
|
||||
enableEventMessageTrack,
|
||||
embeddedCea608TrackFormats,
|
||||
trackPlayerEmsgHandler,
|
||||
transferListener);
|
||||
|
||||
ChunkSampleStream<SabrChunkSource> stream =
|
||||
new ChunkSampleStream<>(
|
||||
trackGroupInfo.trackType,
|
||||
embeddedTrackTypes,
|
||||
embeddedTrackFormats,
|
||||
chunkSource,
|
||||
/* callback= */ this,
|
||||
allocator,
|
||||
positionUs,
|
||||
drmSessionManager,
|
||||
drmEventDispatcher,
|
||||
loadErrorHandlingPolicy,
|
||||
eventDispatcher,
|
||||
/* canReportInitialDiscontinuity= */ true,
|
||||
/* downloadExecutor= */ null);
|
||||
|
||||
synchronized (this) {
|
||||
// The map is also accessed on the loading thread so synchronize access.
|
||||
trackEmsgHandlerBySampleStream.put(stream, trackPlayerEmsgHandler);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static final class TrackGroupInfo {
|
||||
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
@IntDef({CATEGORY_PRIMARY, CATEGORY_EMBEDDED, CATEGORY_MANIFEST_EVENTS})
|
||||
public @interface TrackGroupCategory {}
|
||||
|
||||
/**
|
||||
* A normal track group that has its samples drawn from the stream.
|
||||
* For example: a video Track Group or an audio Track Group.
|
||||
*/
|
||||
private static final int CATEGORY_PRIMARY = 0;
|
||||
|
||||
/**
|
||||
* A track group whose samples are embedded within one of the primary streams. For example: an
|
||||
* EMSG track has its sample embedded in emsg atoms in one of the primary streams.
|
||||
*/
|
||||
private static final int CATEGORY_EMBEDDED = 1;
|
||||
|
||||
/**
|
||||
* A track group that has its samples listed explicitly in the DASH manifest file.
|
||||
* For example: an EventStream track has its sample (Events) included directly in the DASH
|
||||
* manifest file.
|
||||
*/
|
||||
private static final int CATEGORY_MANIFEST_EVENTS = 2;
|
||||
|
||||
public final int[] adaptationSetIndices;
|
||||
public final int trackType;
|
||||
@TrackGroupCategory public final int trackGroupCategory;
|
||||
|
||||
public final int eventStreamGroupIndex;
|
||||
public final int primaryTrackGroupIndex;
|
||||
public final int embeddedEventMessageTrackGroupIndex;
|
||||
public final int embeddedCea608TrackGroupIndex;
|
||||
|
||||
public static TrackGroupInfo primaryTrack(
|
||||
int trackType,
|
||||
int[] adaptationSetIndices,
|
||||
int primaryTrackGroupIndex,
|
||||
int embeddedEventMessageTrackGroupIndex,
|
||||
int embeddedCea608TrackGroupIndex) {
|
||||
return new TrackGroupInfo(
|
||||
trackType,
|
||||
CATEGORY_PRIMARY,
|
||||
adaptationSetIndices,
|
||||
primaryTrackGroupIndex,
|
||||
embeddedEventMessageTrackGroupIndex,
|
||||
embeddedCea608TrackGroupIndex,
|
||||
/* eventStreamGroupIndex= */ -1);
|
||||
}
|
||||
|
||||
public static TrackGroupInfo embeddedEmsgTrack(int[] adaptationSetIndices,
|
||||
int primaryTrackGroupIndex) {
|
||||
return new TrackGroupInfo(
|
||||
C.TRACK_TYPE_METADATA,
|
||||
CATEGORY_EMBEDDED,
|
||||
adaptationSetIndices,
|
||||
primaryTrackGroupIndex,
|
||||
C.INDEX_UNSET,
|
||||
C.INDEX_UNSET,
|
||||
/* eventStreamGroupIndex= */ -1);
|
||||
}
|
||||
|
||||
public static TrackGroupInfo embeddedCea608Track(int[] adaptationSetIndices,
|
||||
int primaryTrackGroupIndex) {
|
||||
return new TrackGroupInfo(
|
||||
C.TRACK_TYPE_TEXT,
|
||||
CATEGORY_EMBEDDED,
|
||||
adaptationSetIndices,
|
||||
primaryTrackGroupIndex,
|
||||
C.INDEX_UNSET,
|
||||
C.INDEX_UNSET,
|
||||
/* eventStreamGroupIndex= */ -1);
|
||||
}
|
||||
|
||||
public static TrackGroupInfo mpdEventTrack(int eventStreamIndex) {
|
||||
return new TrackGroupInfo(
|
||||
C.TRACK_TYPE_METADATA,
|
||||
CATEGORY_MANIFEST_EVENTS,
|
||||
new int[0],
|
||||
/* primaryTrackGroupIndex= */ -1,
|
||||
C.INDEX_UNSET,
|
||||
C.INDEX_UNSET,
|
||||
eventStreamIndex);
|
||||
}
|
||||
|
||||
private TrackGroupInfo(
|
||||
int trackType,
|
||||
@TrackGroupCategory int trackGroupCategory,
|
||||
int[] adaptationSetIndices,
|
||||
int primaryTrackGroupIndex,
|
||||
int embeddedEventMessageTrackGroupIndex,
|
||||
int embeddedCea608TrackGroupIndex,
|
||||
int eventStreamGroupIndex) {
|
||||
this.trackType = trackType;
|
||||
this.adaptationSetIndices = adaptationSetIndices;
|
||||
this.trackGroupCategory = trackGroupCategory;
|
||||
this.primaryTrackGroupIndex = primaryTrackGroupIndex;
|
||||
this.embeddedEventMessageTrackGroupIndex = embeddedEventMessageTrackGroupIndex;
|
||||
this.embeddedCea608TrackGroupIndex = embeddedCea608TrackGroupIndex;
|
||||
this.eventStreamGroupIndex = eventStreamGroupIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.SystemClock;
|
||||
import android.util.SparseArray;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.Timeline;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
|
||||
import androidx.media3.exoplayer.source.BaseMediaSource;
|
||||
import androidx.media3.exoplayer.source.CompositeSequenceableLoaderFactory;
|
||||
import androidx.media3.exoplayer.source.DefaultCompositeSequenceableLoaderFactory;
|
||||
import androidx.media3.exoplayer.source.MediaPeriod;
|
||||
import androidx.media3.exoplayer.source.MediaSource;
|
||||
import androidx.media3.exoplayer.source.MediaSourceEventListener;
|
||||
import androidx.media3.exoplayer.source.MediaSourceEventListener.EventDispatcher;
|
||||
import androidx.media3.exoplayer.source.ads.AdsMediaSource;
|
||||
import com.futo.platformplayer.sabr.PlayerEmsgHandler.PlayerEmsgCallback;
|
||||
import com.futo.platformplayer.sabr.manifest.AdaptationSet;
|
||||
import com.futo.platformplayer.sabr.manifest.SabrManifest;
|
||||
import androidx.media3.exoplayer.upstream.Allocator;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy;
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
|
||||
import androidx.media3.exoplayer.upstream.Loader;
|
||||
import androidx.media3.exoplayer.upstream.LoaderErrorThrower;
|
||||
import androidx.media3.datasource.TransferListener;
|
||||
import androidx.media3.common.util.Assertions;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@UnstableApi
|
||||
public final class SabrMediaSource extends BaseMediaSource {
|
||||
|
||||
private static final int NOTIFY_MANIFEST_INTERVAL_MS = 5000;
|
||||
/**
|
||||
* The minimum default start position for live streams, relative to the start of the live window.
|
||||
*/
|
||||
private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5000000;
|
||||
private final SabrManifest manifest;
|
||||
private final MediaItem mediaItem;
|
||||
private final SabrChunkSource.Factory chunkSourceFactory;
|
||||
private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
|
||||
private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
|
||||
private @Nullable TransferListener mediaTransferListener;
|
||||
private final LoaderErrorThrower manifestLoadErrorThrower;
|
||||
private final PlayerEmsgCallback playerEmsgCallback;
|
||||
private Loader loader;
|
||||
private IOException manifestFatalError;
|
||||
private final long livePresentationDelayMs;
|
||||
private final SparseArray<SabrMediaPeriod> periodsById;
|
||||
private final @Nullable Object tag;
|
||||
private long elapsedRealtimeOffsetMs;
|
||||
private int firstPeriodId;
|
||||
private final boolean livePresentationDelayOverridesManifest;
|
||||
|
||||
/**
|
||||
* The default presentation delay for live streams. The presentation delay is the duration by
|
||||
* which the default start position precedes the end of the live window.
|
||||
*/
|
||||
private static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30000;
|
||||
|
||||
private SabrMediaSource(
|
||||
SabrManifest manifest,
|
||||
MediaItem mediaItem,
|
||||
SabrChunkSource.Factory chunkSourceFactory,
|
||||
CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory,
|
||||
LoadErrorHandlingPolicy loadErrorHandlingPolicy,
|
||||
long livePresentationDelayMs,
|
||||
boolean livePresentationDelayOverridesManifest,
|
||||
@Nullable Object tag
|
||||
) {
|
||||
this.manifest = manifest;
|
||||
this.mediaItem = mediaItem;
|
||||
this.chunkSourceFactory = chunkSourceFactory;
|
||||
this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory;
|
||||
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
|
||||
this.livePresentationDelayMs = livePresentationDelayMs;
|
||||
this.livePresentationDelayOverridesManifest = livePresentationDelayOverridesManifest;
|
||||
this.tag = tag;
|
||||
periodsById = new SparseArray<>();
|
||||
playerEmsgCallback = new DefaultPlayerEmsgCallback();
|
||||
manifestLoadErrorThrower = new ManifestLoadErrorThrower();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaItem getMediaItem() {
|
||||
return mediaItem;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
|
||||
this.mediaTransferListener = mediaTransferListener;
|
||||
loader = new Loader("Loader:SabrMediaSource");
|
||||
processManifest();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void releaseSourceInternal() {
|
||||
if (loader != null) {
|
||||
loader.release();
|
||||
loader = null;
|
||||
}
|
||||
elapsedRealtimeOffsetMs = 0;
|
||||
manifestFatalError = null;
|
||||
firstPeriodId = 0;
|
||||
periodsById.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowSourceInfoRefreshError() throws IOException {
|
||||
manifestLoadErrorThrower.maybeThrowError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaPeriod createPeriod(MediaPeriodId periodId, Allocator allocator, long startPositionUs) {
|
||||
int periodIndex = (Integer) periodId.periodUid - firstPeriodId;
|
||||
EventDispatcher periodEventDispatcher =
|
||||
createEventDispatcher(periodId, manifest.getPeriod(periodIndex).startMs);
|
||||
SabrMediaPeriod mediaPeriod = new SabrMediaPeriod(
|
||||
firstPeriodId + periodIndex,
|
||||
manifest,
|
||||
periodIndex,
|
||||
chunkSourceFactory,
|
||||
mediaTransferListener,
|
||||
loadErrorHandlingPolicy,
|
||||
periodEventDispatcher,
|
||||
elapsedRealtimeOffsetMs,
|
||||
manifestLoadErrorThrower,
|
||||
allocator,
|
||||
compositeSequenceableLoaderFactory,
|
||||
playerEmsgCallback);
|
||||
periodsById.put(mediaPeriod.id, mediaPeriod);
|
||||
return mediaPeriod;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releasePeriod(MediaPeriod mediaPeriod) {
|
||||
SabrMediaPeriod sabrMediaPeriod = (SabrMediaPeriod) mediaPeriod;
|
||||
sabrMediaPeriod.release();
|
||||
periodsById.remove(sabrMediaPeriod.id);
|
||||
}
|
||||
|
||||
private void processManifest() {
|
||||
// Update any periods.
|
||||
for (int i = 0; i < periodsById.size(); i++) {
|
||||
int id = periodsById.keyAt(i);
|
||||
if (id >= firstPeriodId) {
|
||||
periodsById.valueAt(i).updateManifest(manifest, id - firstPeriodId);
|
||||
} else {
|
||||
// This period has been removed from the manifest so it doesn't need to be updated.
|
||||
}
|
||||
}
|
||||
// Update the window.
|
||||
boolean windowChangingImplicitly = false;
|
||||
int lastPeriodIndex = manifest.getPeriodCount() - 1;
|
||||
PeriodSeekInfo firstPeriodSeekInfo = PeriodSeekInfo.createPeriodSeekInfo(manifest.getPeriod(0),
|
||||
manifest.getPeriodDurationUs(0));
|
||||
PeriodSeekInfo lastPeriodSeekInfo = PeriodSeekInfo.createPeriodSeekInfo(
|
||||
manifest.getPeriod(lastPeriodIndex), manifest.getPeriodDurationUs(lastPeriodIndex));
|
||||
// Get the period-relative start/end times.
|
||||
long currentStartTimeUs = firstPeriodSeekInfo.availableStartTimeUs;
|
||||
long currentEndTimeUs = lastPeriodSeekInfo.availableEndTimeUs;
|
||||
if (manifest.dynamic && !lastPeriodSeekInfo.isIndexExplicit) {
|
||||
// The manifest describes an incomplete live stream. Update the start/end times to reflect the
|
||||
// live stream duration and the manifest's time shift buffer depth.
|
||||
long liveStreamDurationUs = getNowUnixTimeUs() - C.msToUs(manifest.availabilityStartTimeMs);
|
||||
long liveStreamEndPositionInLastPeriodUs = liveStreamDurationUs
|
||||
- C.msToUs(manifest.getPeriod(lastPeriodIndex).startMs);
|
||||
currentEndTimeUs = Math.min(liveStreamEndPositionInLastPeriodUs, currentEndTimeUs);
|
||||
if (manifest.timeShiftBufferDepthMs != C.TIME_UNSET) {
|
||||
long timeShiftBufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs);
|
||||
long offsetInPeriodUs = currentEndTimeUs - timeShiftBufferDepthUs;
|
||||
int periodIndex = lastPeriodIndex;
|
||||
while (offsetInPeriodUs < 0 && periodIndex > 0) {
|
||||
offsetInPeriodUs += manifest.getPeriodDurationUs(--periodIndex);
|
||||
}
|
||||
if (periodIndex == 0) {
|
||||
currentStartTimeUs = Math.max(currentStartTimeUs, offsetInPeriodUs);
|
||||
} else {
|
||||
// The time shift buffer starts after the earliest period.
|
||||
// TODO: Does this ever happen?
|
||||
currentStartTimeUs = manifest.getPeriodDurationUs(0);
|
||||
}
|
||||
}
|
||||
windowChangingImplicitly = true;
|
||||
}
|
||||
long windowDurationUs = currentEndTimeUs - currentStartTimeUs;
|
||||
for (int i = 0; i < manifest.getPeriodCount() - 1; i++) {
|
||||
windowDurationUs += manifest.getPeriodDurationUs(i);
|
||||
}
|
||||
long windowDefaultStartPositionUs = 0;
|
||||
if (manifest.dynamic) {
|
||||
long presentationDelayForManifestMs = livePresentationDelayMs;
|
||||
if (!livePresentationDelayOverridesManifest
|
||||
&& manifest.suggestedPresentationDelayMs != C.TIME_UNSET) {
|
||||
presentationDelayForManifestMs = manifest.suggestedPresentationDelayMs;
|
||||
}
|
||||
// Snap the default position to the start of the segment containing it.
|
||||
windowDefaultStartPositionUs = windowDurationUs - C.msToUs(presentationDelayForManifestMs);
|
||||
if (windowDefaultStartPositionUs < MIN_LIVE_DEFAULT_START_POSITION_US) {
|
||||
// The default start position is too close to the start of the live window. Set it to the
|
||||
// minimum default start position provided the window is at least twice as big. Else set
|
||||
// it to the middle of the window.
|
||||
windowDefaultStartPositionUs = Math.min(MIN_LIVE_DEFAULT_START_POSITION_US,
|
||||
windowDurationUs / 2);
|
||||
}
|
||||
}
|
||||
long windowStartTimeMs = manifest.availabilityStartTimeMs
|
||||
+ manifest.getPeriod(0).startMs + C.usToMs(currentStartTimeUs);
|
||||
SabrTimeline timeline =
|
||||
new SabrTimeline(
|
||||
manifest.availabilityStartTimeMs,
|
||||
windowStartTimeMs,
|
||||
firstPeriodId,
|
||||
currentStartTimeUs,
|
||||
windowDurationUs,
|
||||
windowDefaultStartPositionUs,
|
||||
manifest,
|
||||
tag);
|
||||
refreshSourceInfo(timeline);
|
||||
}
|
||||
|
||||
private long getNowUnixTimeUs() {
|
||||
if (elapsedRealtimeOffsetMs != 0) {
|
||||
return C.msToUs(SystemClock.elapsedRealtime() + elapsedRealtimeOffsetMs);
|
||||
} else {
|
||||
return C.msToUs(System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Factory implements MediaSource.Factory {
|
||||
private final SabrChunkSource.Factory chunkSourceFactory;
|
||||
@Nullable private final DataSource.Factory manifestDataSourceFactory;
|
||||
private LoadErrorHandlingPolicy loadErrorHandlingPolicy;
|
||||
private final DefaultCompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory;
|
||||
@Nullable private DrmSessionManagerProvider drmSessionManagerProvider;
|
||||
|
||||
private long livePresentationDelayMs;
|
||||
private boolean livePresentationDelayOverridesManifest;
|
||||
private boolean isCreateCalled;
|
||||
@Nullable private Object tag;
|
||||
|
||||
/**
|
||||
* Creates a new factory for {@link SabrMediaSource}s.
|
||||
*
|
||||
* @param chunkSourceFactory A factory for {@link SabrChunkSource} instances.
|
||||
* @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used
|
||||
* to load (and refresh) the manifest. May be {@code null} if the factory will only ever be
|
||||
* used to create create media sources with sideloaded manifests via {@link
|
||||
* #createMediaSource(SabrManifest, Handler, MediaSourceEventListener)}.
|
||||
*/
|
||||
public Factory(
|
||||
SabrChunkSource.Factory chunkSourceFactory,
|
||||
@Nullable DataSource.Factory manifestDataSourceFactory) {
|
||||
this.chunkSourceFactory = chunkSourceFactory;
|
||||
this.manifestDataSourceFactory = manifestDataSourceFactory;
|
||||
loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy();
|
||||
livePresentationDelayMs = DEFAULT_LIVE_PRESENTATION_DELAY_MS;
|
||||
compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Factory setDrmSessionManagerProvider(DrmSessionManagerProvider drmSessionManagerProvider) {
|
||||
Assertions.checkState(!isCreateCalled);
|
||||
this.drmSessionManagerProvider = drmSessionManagerProvider;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SabrMediaSource createMediaSource(MediaItem mediaItem) {
|
||||
Assertions.checkNotNull(mediaItem);
|
||||
MediaItem.LocalConfiguration localConfiguration = mediaItem.localConfiguration;
|
||||
Assertions.checkNotNull(localConfiguration, "MediaItem must have a local configuration");
|
||||
Object localTag = localConfiguration.tag;
|
||||
Assertions.checkArgument(
|
||||
localTag instanceof SabrManifest,
|
||||
"MediaItem.localConfiguration.tag must be a SabrManifest"
|
||||
);
|
||||
SabrManifest manifest = (SabrManifest) localTag;
|
||||
|
||||
isCreateCalled = true;
|
||||
return new SabrMediaSource(
|
||||
manifest,
|
||||
mediaItem,
|
||||
chunkSourceFactory,
|
||||
compositeSequenceableLoaderFactory,
|
||||
loadErrorHandlingPolicy,
|
||||
livePresentationDelayMs,
|
||||
livePresentationDelayOverridesManifest,
|
||||
tag
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new {@link SabrMediaSource} using the current parameters and the specified
|
||||
* sideloaded manifest.
|
||||
*
|
||||
* @param manifest The manifest.
|
||||
* @return The new {@link SabrMediaSource}.
|
||||
*/
|
||||
public SabrMediaSource createMediaSource(SabrManifest manifest) {
|
||||
isCreateCalled = true;
|
||||
|
||||
MediaItem mediaItem = new MediaItem.Builder()
|
||||
.setMediaId("sabr:" + manifest.hashCode())
|
||||
.setTag(manifest)
|
||||
.build();
|
||||
|
||||
return new SabrMediaSource(
|
||||
manifest,
|
||||
mediaItem,
|
||||
chunkSourceFactory,
|
||||
compositeSequenceableLoaderFactory,
|
||||
loadErrorHandlingPolicy,
|
||||
livePresentationDelayMs,
|
||||
livePresentationDelayOverridesManifest,
|
||||
tag
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link #createMediaSource(SabrManifest)} and {@link
|
||||
* #addEventListener(Handler, MediaSourceEventListener)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public SabrMediaSource createMediaSource(
|
||||
SabrManifest manifest,
|
||||
@Nullable Handler eventHandler,
|
||||
@Nullable MediaSourceEventListener eventListener) {
|
||||
isCreateCalled = true;
|
||||
SabrMediaSource mediaSource = createMediaSource(manifest);
|
||||
if (eventHandler != null && eventListener != null) {
|
||||
mediaSource.addEventListener(eventHandler, eventListener);
|
||||
}
|
||||
return mediaSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] getSupportedTypes() {
|
||||
return new int[] { C.CONTENT_TYPE_OTHER };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link
|
||||
* DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}.
|
||||
*
|
||||
* <p>Calling this method overrides any calls to {@link #setMinLoadableRetryCount(int)}.
|
||||
*
|
||||
* @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}.
|
||||
* @return This factory, for convenience.
|
||||
* @throws IllegalStateException If one of the {@code create} methods has already been called.
|
||||
*/
|
||||
@Override
|
||||
public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) {
|
||||
Assertions.checkState(!isCreateCalled);
|
||||
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimum number of times to retry if a loading error occurs. See {@link
|
||||
* #setLoadErrorHandlingPolicy} for the default value.
|
||||
*
|
||||
* <p>Calling this method is equivalent to calling {@link #setLoadErrorHandlingPolicy} with
|
||||
* {@link DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy(int)
|
||||
* DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)}
|
||||
*
|
||||
* @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs.
|
||||
* @return This factory, for convenience.
|
||||
* @throws IllegalStateException If one of the {@code create} methods has already been called.
|
||||
* @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public Factory setMinLoadableRetryCount(int minLoadableRetryCount) {
|
||||
return setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount));
|
||||
}
|
||||
|
||||
public Factory setTag(Object tag) {
|
||||
Assertions.checkState(!isCreateCalled);
|
||||
this.tag = tag;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the duration in milliseconds by which the default start position should precede the end
|
||||
* of the live window for live playbacks. The {@code overridesManifest} parameter specifies
|
||||
* whether the value is used in preference to one in the manifest, if present. The default value
|
||||
* is {@link #DEFAULT_LIVE_PRESENTATION_DELAY_MS}, and by default {@code overridesManifest} is
|
||||
* false.
|
||||
*
|
||||
* @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the
|
||||
* default start position should precede the end of the live window.
|
||||
* @param overridesManifest Whether the value is used in preference to one in the manifest, if
|
||||
* present.
|
||||
* @return This factory, for convenience.
|
||||
* @throws IllegalStateException If one of the {@code create} methods has already been called.
|
||||
*/
|
||||
public Factory setLivePresentationDelayMs(
|
||||
long livePresentationDelayMs, boolean overridesManifest) {
|
||||
Assertions.checkState(!isCreateCalled);
|
||||
this.livePresentationDelayMs = livePresentationDelayMs;
|
||||
this.livePresentationDelayOverridesManifest = overridesManifest;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link LoaderErrorThrower} that throws fatal {@link IOException} that has occurred during
|
||||
* manifest loading from the manifest {@code loader}, or exception with the loaded manifest.
|
||||
*/
|
||||
/* package */ final class ManifestLoadErrorThrower implements LoaderErrorThrower {
|
||||
|
||||
@Override
|
||||
public void maybeThrowError() throws IOException {
|
||||
loader.maybeThrowError();
|
||||
maybeThrowManifestError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowError(int minRetryCount) throws IOException {
|
||||
loader.maybeThrowError(minRetryCount);
|
||||
maybeThrowManifestError();
|
||||
}
|
||||
|
||||
private void maybeThrowManifestError() throws IOException {
|
||||
if (manifestFatalError != null) {
|
||||
throw manifestFatalError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DefaultPlayerEmsgCallback implements PlayerEmsgCallback {
|
||||
@Override
|
||||
public void onDashManifestRefreshRequested() {
|
||||
//SabrMediaSource.this.onDashManifestRefreshRequested();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDashManifestPublishTimeExpired(long expiredManifestPublishTimeUs) {
|
||||
//SabrMediaSource.this.onDashManifestPublishTimeExpired(expiredManifestPublishTimeUs);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SabrTimeline extends Timeline {
|
||||
|
||||
private final long presentationStartTimeMs;
|
||||
private final long windowStartTimeMs;
|
||||
|
||||
private final int firstPeriodId;
|
||||
private final long offsetInFirstPeriodUs;
|
||||
private final long windowDurationUs;
|
||||
private final long windowDefaultStartPositionUs;
|
||||
private final SabrManifest manifest;
|
||||
private final @Nullable Object windowTag;
|
||||
|
||||
public SabrTimeline(
|
||||
long presentationStartTimeMs,
|
||||
long windowStartTimeMs,
|
||||
int firstPeriodId,
|
||||
long offsetInFirstPeriodUs,
|
||||
long windowDurationUs,
|
||||
long windowDefaultStartPositionUs,
|
||||
SabrManifest manifest,
|
||||
@Nullable Object windowTag) {
|
||||
this.presentationStartTimeMs = presentationStartTimeMs;
|
||||
this.windowStartTimeMs = windowStartTimeMs;
|
||||
this.firstPeriodId = firstPeriodId;
|
||||
this.offsetInFirstPeriodUs = offsetInFirstPeriodUs;
|
||||
this.windowDurationUs = windowDurationUs;
|
||||
this.windowDefaultStartPositionUs = windowDefaultStartPositionUs;
|
||||
this.manifest = manifest;
|
||||
this.windowTag = windowTag;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPeriodCount() {
|
||||
return manifest.getPeriodCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Period getPeriod(int periodIndex, Period period, boolean setIdentifiers) {
|
||||
Assertions.checkIndex(periodIndex, 0, getPeriodCount());
|
||||
Object id = setIdentifiers ? manifest.getPeriod(periodIndex).id : null;
|
||||
Object uid = setIdentifiers ? (firstPeriodId + periodIndex) : null;
|
||||
return period.set(id, uid, 0, manifest.getPeriodDurationUs(periodIndex),
|
||||
C.msToUs(manifest.getPeriod(periodIndex).startMs - manifest.getPeriod(0).startMs)
|
||||
- offsetInFirstPeriodUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getWindowCount() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Window getWindow(
|
||||
int windowIndex, Window window, long defaultPositionProjectionUs) {
|
||||
Assertions.checkIndex(windowIndex, 0, 1);
|
||||
|
||||
long windowDefaultStartPositionUs =
|
||||
getAdjustedWindowDefaultStartPositionUs(defaultPositionProjectionUs);
|
||||
|
||||
boolean isDynamic =
|
||||
manifest.dynamic
|
||||
&& manifest.minUpdatePeriodMs != C.TIME_UNSET
|
||||
&& manifest.durationMs == C.TIME_UNSET;
|
||||
|
||||
return window.set(
|
||||
/* uid= */ Window.SINGLE_WINDOW_UID,
|
||||
/* mediaItem= */ null,
|
||||
/* manifest= */ manifest,
|
||||
/* presentationStartTimeMs= */ presentationStartTimeMs,
|
||||
/* windowStartTimeMs= */ windowStartTimeMs,
|
||||
/* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET,
|
||||
/* isSeekable= */ true,
|
||||
/* isDynamic= */ isDynamic,
|
||||
/* liveConfiguration= */ null,
|
||||
/* defaultPositionUs= */ windowDefaultStartPositionUs,
|
||||
/* durationUs= */ windowDurationUs,
|
||||
/* firstPeriodIndex= */ 0,
|
||||
/* lastPeriodIndex= */ getPeriodCount() - 1,
|
||||
/* positionInFirstPeriodUs= */ offsetInFirstPeriodUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIndexOfPeriod(Object uid) {
|
||||
if (!(uid instanceof Integer)) {
|
||||
return C.INDEX_UNSET;
|
||||
}
|
||||
int periodId = (int) uid;
|
||||
int periodIndex = periodId - firstPeriodId;
|
||||
return periodIndex < 0 || periodIndex >= getPeriodCount() ? C.INDEX_UNSET : periodIndex;
|
||||
}
|
||||
|
||||
private long getAdjustedWindowDefaultStartPositionUs(long defaultPositionProjectionUs) {
|
||||
long windowDefaultStartPositionUs = this.windowDefaultStartPositionUs;
|
||||
if (!manifest.dynamic) {
|
||||
return windowDefaultStartPositionUs;
|
||||
}
|
||||
if (defaultPositionProjectionUs > 0) {
|
||||
windowDefaultStartPositionUs += defaultPositionProjectionUs;
|
||||
if (windowDefaultStartPositionUs > windowDurationUs) {
|
||||
// The projection takes us beyond the end of the live window.
|
||||
return C.TIME_UNSET;
|
||||
}
|
||||
}
|
||||
// Attempt to snap to the start of the corresponding video segment.
|
||||
int periodIndex = 0;
|
||||
long defaultStartPositionInPeriodUs = offsetInFirstPeriodUs + windowDefaultStartPositionUs;
|
||||
long periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
|
||||
while (periodIndex < manifest.getPeriodCount() - 1
|
||||
&& defaultStartPositionInPeriodUs >= periodDurationUs) {
|
||||
defaultStartPositionInPeriodUs -= periodDurationUs;
|
||||
periodIndex++;
|
||||
periodDurationUs = manifest.getPeriodDurationUs(periodIndex);
|
||||
}
|
||||
com.futo.platformplayer.sabr.manifest.Period period =
|
||||
manifest.getPeriod(periodIndex);
|
||||
int videoAdaptationSetIndex = period.getAdaptationSetIndex(C.TRACK_TYPE_VIDEO);
|
||||
if (videoAdaptationSetIndex == C.INDEX_UNSET) {
|
||||
// No video adaptation set for snapping.
|
||||
return windowDefaultStartPositionUs;
|
||||
}
|
||||
// If there are multiple video adaptation sets with unaligned segments, the initial time may
|
||||
// not correspond to the start of a segment in both, but this is an edge case.
|
||||
SabrSegmentIndex snapIndex = period.adaptationSets.get(videoAdaptationSetIndex)
|
||||
.representations.get(0).getIndex();
|
||||
if (snapIndex == null || snapIndex.getSegmentCount(periodDurationUs) == 0) {
|
||||
// Video adaptation set does not include a non-empty index for snapping.
|
||||
return windowDefaultStartPositionUs;
|
||||
}
|
||||
long segmentNum = snapIndex.getSegmentNum(defaultStartPositionInPeriodUs, periodDurationUs);
|
||||
return windowDefaultStartPositionUs + snapIndex.getTimeUs(segmentNum)
|
||||
- defaultStartPositionInPeriodUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getUidOfPeriod(int periodIndex) {
|
||||
Assertions.checkIndex(periodIndex, 0, getPeriodCount());
|
||||
return firstPeriodId + periodIndex;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PeriodSeekInfo {
|
||||
|
||||
public static PeriodSeekInfo createPeriodSeekInfo(
|
||||
com.futo.platformplayer.sabr.manifest.Period period, long durationUs) {
|
||||
int adaptationSetCount = period.adaptationSets.size();
|
||||
long availableStartTimeUs = 0;
|
||||
long availableEndTimeUs = Long.MAX_VALUE;
|
||||
boolean isIndexExplicit = false;
|
||||
boolean seenEmptyIndex = false;
|
||||
|
||||
boolean haveAudioVideoAdaptationSets = false;
|
||||
for (int i = 0; i < adaptationSetCount; i++) {
|
||||
int type = period.adaptationSets.get(i).type;
|
||||
if (type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO) {
|
||||
haveAudioVideoAdaptationSets = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < adaptationSetCount; i++) {
|
||||
AdaptationSet adaptationSet = period.adaptationSets.get(i);
|
||||
// Exclude text adaptation sets from duration calculations, if we have at least one audio
|
||||
// or video adaptation set. See: https://github.com/google/ExoPlayer/issues/4029
|
||||
if (haveAudioVideoAdaptationSets && adaptationSet.type == C.TRACK_TYPE_TEXT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SabrSegmentIndex index = adaptationSet.representations.get(0).getIndex();
|
||||
if (index == null) {
|
||||
return new PeriodSeekInfo(true, 0, durationUs);
|
||||
}
|
||||
isIndexExplicit |= index.isExplicit();
|
||||
int segmentCount = index.getSegmentCount(durationUs);
|
||||
if (segmentCount == 0) {
|
||||
seenEmptyIndex = true;
|
||||
availableStartTimeUs = 0;
|
||||
availableEndTimeUs = 0;
|
||||
} else if (!seenEmptyIndex) {
|
||||
long firstSegmentNum = index.getFirstSegmentNum();
|
||||
long adaptationSetAvailableStartTimeUs = index.getTimeUs(firstSegmentNum);
|
||||
availableStartTimeUs = Math.max(availableStartTimeUs, adaptationSetAvailableStartTimeUs);
|
||||
if (segmentCount != SabrSegmentIndex.INDEX_UNBOUNDED) {
|
||||
long lastSegmentNum = firstSegmentNum + segmentCount - 1;
|
||||
long adaptationSetAvailableEndTimeUs = index.getTimeUs(lastSegmentNum)
|
||||
+ index.getDurationUs(lastSegmentNum, durationUs);
|
||||
availableEndTimeUs = Math.min(availableEndTimeUs, adaptationSetAvailableEndTimeUs);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new PeriodSeekInfo(isIndexExplicit, availableStartTimeUs, availableEndTimeUs);
|
||||
}
|
||||
|
||||
public final boolean isIndexExplicit;
|
||||
public final long availableStartTimeUs;
|
||||
public final long availableEndTimeUs;
|
||||
|
||||
private PeriodSeekInfo(boolean isIndexExplicit, long availableStartTimeUs,
|
||||
long availableEndTimeUs) {
|
||||
this.isIndexExplicit = isIndexExplicit;
|
||||
this.availableStartTimeUs = availableStartTimeUs;
|
||||
this.availableEndTimeUs = availableEndTimeUs;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import com.futo.platformplayer.sabr.manifest.RangedUri;
|
||||
|
||||
/**
|
||||
* Indexes the segments within a media stream.
|
||||
*/
|
||||
public interface SabrSegmentIndex {
|
||||
|
||||
int INDEX_UNBOUNDED = -1;
|
||||
|
||||
/**
|
||||
* Returns {@code getFirstSegmentNum()} if the index has no segments or if the given media time is
|
||||
* earlier than the start of the first segment. Returns {@code getFirstSegmentNum() +
|
||||
* getSegmentCount() - 1} if the given media time is later than the end of the last segment.
|
||||
* Otherwise, returns the segment number of the segment containing the given media time.
|
||||
*
|
||||
* @param timeUs The time in microseconds.
|
||||
* @param periodDurationUs The duration of the enclosing period in microseconds, or {@link
|
||||
* C#TIME_UNSET} if the period's duration is not yet known.
|
||||
* @return The segment number of the corresponding segment.
|
||||
*/
|
||||
long getSegmentNum(long timeUs, long periodDurationUs);
|
||||
|
||||
/**
|
||||
* Returns the start time of a segment.
|
||||
*
|
||||
* @param segmentNum The segment number.
|
||||
* @return The corresponding start time in microseconds.
|
||||
*/
|
||||
long getTimeUs(long segmentNum);
|
||||
|
||||
/**
|
||||
* Returns the duration of a segment.
|
||||
*
|
||||
* @param segmentNum The segment number.
|
||||
* @param periodDurationUs The duration of the enclosing period in microseconds, or {@link
|
||||
* C#TIME_UNSET} if the period's duration is not yet known.
|
||||
* @return The duration of the segment, in microseconds.
|
||||
*/
|
||||
long getDurationUs(long segmentNum, long periodDurationUs);
|
||||
|
||||
/**
|
||||
* Returns a {@link RangedUri} defining the location of a segment.
|
||||
*
|
||||
* @param segmentNum The segment number.
|
||||
* @return The {@link RangedUri} defining the location of the data.
|
||||
*/
|
||||
RangedUri getSegmentUrl(long segmentNum);
|
||||
|
||||
/**
|
||||
* Returns the segment number of the first segment.
|
||||
*
|
||||
* @return The segment number of the first segment.
|
||||
*/
|
||||
long getFirstSegmentNum();
|
||||
|
||||
/**
|
||||
* Returns the number of segments in the index, or {@link #INDEX_UNBOUNDED}.
|
||||
* <p>
|
||||
* An unbounded index occurs if a dynamic manifest uses SegmentTemplate elements without a
|
||||
* SegmentTimeline element, and if the period duration is not yet known. In this case the caller
|
||||
* must manually determine the window of currently available segments.
|
||||
*
|
||||
* @param periodDurationUs The duration of the enclosing period in microseconds, or
|
||||
* {@link C#TIME_UNSET} if the period's duration is not yet known.
|
||||
* @return The number of segments in the index, or {@link #INDEX_UNBOUNDED}.
|
||||
*/
|
||||
int getSegmentCount(long periodDurationUs);
|
||||
|
||||
/**
|
||||
* Returns true if segments are defined explicitly by the index.
|
||||
* <p>
|
||||
* If true is returned, each segment is defined explicitly by the index data, and all of the
|
||||
* listed segments are guaranteed to be available at the time when the index was obtained.
|
||||
* <p>
|
||||
* If false is returned then segment information was derived from properties such as a fixed
|
||||
* segment duration. If the presentation is dynamic, it's possible that only a subset of the
|
||||
* segments are available.
|
||||
*
|
||||
* @return Whether segments are defined explicitly by the index.
|
||||
*/
|
||||
boolean isExplicit();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ChunkIndex;
|
||||
import com.futo.platformplayer.sabr.manifest.RangedUri;
|
||||
|
||||
/**
|
||||
* An implementation of {@link SabrSegmentIndex} that wraps a {@link ChunkIndex} parsed from a
|
||||
* media stream.
|
||||
*/
|
||||
@UnstableApi
|
||||
public final class SabrWrappingSegmentIndex implements SabrSegmentIndex {
|
||||
|
||||
private final ChunkIndex chunkIndex;
|
||||
private final long timeOffsetUs;
|
||||
|
||||
/**
|
||||
* @param chunkIndex The {@link ChunkIndex} to wrap.
|
||||
* @param timeOffsetUs An offset to subtract from the times in the wrapped index, in microseconds.
|
||||
*/
|
||||
public SabrWrappingSegmentIndex(ChunkIndex chunkIndex, long timeOffsetUs) {
|
||||
this.chunkIndex = chunkIndex;
|
||||
this.timeOffsetUs = timeOffsetUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFirstSegmentNum() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
return chunkIndex.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimeUs(long segmentNum) {
|
||||
return chunkIndex.timesUs[(int) segmentNum] - timeOffsetUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationUs(long segmentNum, long periodDurationUs) {
|
||||
return chunkIndex.durationsUs[(int) segmentNum];
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(long segmentNum) {
|
||||
return new RangedUri(
|
||||
null, chunkIndex.offsets[(int) segmentNum], chunkIndex.sizes[(int) segmentNum]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSegmentNum(long timeUs, long periodDurationUs) {
|
||||
return chunkIndex.getChunkIndex(timeUs + timeOffsetUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExplicit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import static java.lang.Math.min;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.google.common.base.Ascii;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Utility methods for manipulating URIs. */
|
||||
@UnstableApi
|
||||
public final class UriUtil {
|
||||
|
||||
/** The length of arrays returned by {@link #getUriIndices(String)}. */
|
||||
private static final int INDEX_COUNT = 4;
|
||||
|
||||
/**
|
||||
* An index into an array returned by {@link #getUriIndices(String)}.
|
||||
*
|
||||
* <p>The value at this position in the array is the index of the ':' after the scheme. Equals -1
|
||||
* if the URI is a relative reference (no scheme). The hier-part starts at (schemeColon + 1),
|
||||
* including when the URI has no scheme.
|
||||
*/
|
||||
private static final int SCHEME_COLON = 0;
|
||||
|
||||
/**
|
||||
* An index into an array returned by {@link #getUriIndices(String)}.
|
||||
*
|
||||
* <p>The value at this position in the array is the index of the path part. Equals (schemeColon +
|
||||
* 1) if no authority part, (schemeColon + 3) if the authority part consists of just "//", and
|
||||
* (query) if no path part. The characters starting at this index can be "//" only if the
|
||||
* authority part is non-empty (in this case the double-slash means the first segment is empty).
|
||||
*/
|
||||
private static final int PATH = 1;
|
||||
|
||||
/**
|
||||
* An index into an array returned by {@link #getUriIndices(String)}.
|
||||
*
|
||||
* <p>The value at this position in the array is the index of the query part, including the '?'
|
||||
* before the query. Equals fragment if no query part, and (fragment - 1) if the query part is a
|
||||
* single '?' with no data.
|
||||
*/
|
||||
private static final int QUERY = 2;
|
||||
|
||||
/**
|
||||
* An index into an array returned by {@link #getUriIndices(String)}.
|
||||
*
|
||||
* <p>The value at this position in the array is the index of the fragment part, including the '#'
|
||||
* before the fragment. Equal to the length of the URI if no fragment part, and (length - 1) if
|
||||
* the fragment part is a single '#' with no data.
|
||||
*/
|
||||
private static final int FRAGMENT = 3;
|
||||
|
||||
private UriUtil() {}
|
||||
|
||||
/**
|
||||
* Like {@link #resolve(String, String)}, but returns a {@link Uri} instead of a {@link String}.
|
||||
*
|
||||
* @param baseUri The base URI.
|
||||
* @param referenceUri The reference URI to resolve.
|
||||
*/
|
||||
public static Uri resolveToUri(@Nullable String baseUri, @Nullable String referenceUri) {
|
||||
return Uri.parse(resolve(baseUri, referenceUri));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs relative resolution of a {@code referenceUri} with respect to a {@code baseUri}.
|
||||
*
|
||||
* <p>The resolution is performed as specified by RFC-3986.
|
||||
*
|
||||
* @param baseUri The base URI.
|
||||
* @param referenceUri The reference URI to resolve.
|
||||
*/
|
||||
public static String resolve(@Nullable String baseUri, @Nullable String referenceUri) {
|
||||
StringBuilder uri = new StringBuilder();
|
||||
|
||||
// Map null onto empty string, to make the following logic simpler.
|
||||
baseUri = baseUri == null ? "" : baseUri;
|
||||
referenceUri = referenceUri == null ? "" : referenceUri;
|
||||
|
||||
int[] refIndices = getUriIndices(referenceUri);
|
||||
if (refIndices[SCHEME_COLON] != -1) {
|
||||
// The reference is absolute. The target Uri is the reference.
|
||||
uri.append(referenceUri);
|
||||
removeDotSegments(uri, refIndices[PATH], refIndices[QUERY]);
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
int[] baseIndices = getUriIndices(baseUri);
|
||||
if (refIndices[FRAGMENT] == 0) {
|
||||
// The reference is empty or contains just the fragment part, then the target Uri is the
|
||||
// concatenation of the base Uri without its fragment, and the reference.
|
||||
return uri.append(baseUri, 0, baseIndices[FRAGMENT]).append(referenceUri).toString();
|
||||
}
|
||||
|
||||
if (refIndices[QUERY] == 0) {
|
||||
// The reference starts with the query part. The target is the base up to (but excluding) the
|
||||
// query, plus the reference.
|
||||
return uri.append(baseUri, 0, baseIndices[QUERY]).append(referenceUri).toString();
|
||||
}
|
||||
|
||||
if (refIndices[PATH] != 0) {
|
||||
// The reference has authority. The target is the base scheme plus the reference.
|
||||
int baseLimit = baseIndices[SCHEME_COLON] + 1;
|
||||
uri.append(baseUri, 0, baseLimit).append(referenceUri);
|
||||
return removeDotSegments(uri, baseLimit + refIndices[PATH], baseLimit + refIndices[QUERY]);
|
||||
}
|
||||
|
||||
if (referenceUri.charAt(refIndices[PATH]) == '/') {
|
||||
// The reference path is rooted. The target is the base scheme and authority (if any), plus
|
||||
// the reference.
|
||||
uri.append(baseUri, 0, baseIndices[PATH]).append(referenceUri);
|
||||
return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY]);
|
||||
}
|
||||
|
||||
// The target Uri is the concatenation of the base Uri up to (but excluding) the last segment,
|
||||
// and the reference. This can be split into 2 cases:
|
||||
if (baseIndices[SCHEME_COLON] + 2 < baseIndices[PATH]
|
||||
&& baseIndices[PATH] == baseIndices[QUERY]) {
|
||||
// Case 1: The base hier-part is just the authority, with an empty path. An additional '/' is
|
||||
// needed after the authority, before appending the reference.
|
||||
uri.append(baseUri, 0, baseIndices[PATH]).append('/').append(referenceUri);
|
||||
return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY] + 1);
|
||||
} else {
|
||||
// Case 2: Otherwise, find the last '/' in the base hier-part and append the reference after
|
||||
// it. If base hier-part has no '/', it could only mean that it is completely empty or
|
||||
// contains only one segment, in which case the whole hier-part is excluded and the reference
|
||||
// is appended right after the base scheme colon without an added '/'.
|
||||
int lastSlashIndex = baseUri.lastIndexOf('/', baseIndices[QUERY] - 1);
|
||||
int baseLimit = lastSlashIndex == -1 ? baseIndices[PATH] : lastSlashIndex + 1;
|
||||
uri.append(baseUri, 0, baseLimit).append(referenceUri);
|
||||
return removeDotSegments(uri, baseIndices[PATH], baseLimit + refIndices[QUERY]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the URI is starting with a scheme component, false otherwise. */
|
||||
public static boolean isAbsolute(@Nullable String uri) {
|
||||
return uri != null && getUriIndices(uri)[SCHEME_COLON] != -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes query parameter from a URI, if present.
|
||||
*
|
||||
* @param uri The URI.
|
||||
* @param queryParameterName The name of the query parameter.
|
||||
* @return The URI without the query parameter.
|
||||
*/
|
||||
public static Uri removeQueryParameter(Uri uri, String queryParameterName) {
|
||||
Uri.Builder builder = uri.buildUpon();
|
||||
builder.clearQuery();
|
||||
for (String key : uri.getQueryParameterNames()) {
|
||||
if (!key.equals(queryParameterName)) {
|
||||
for (String value : uri.getQueryParameters(key)) {
|
||||
builder.appendQueryParameter(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes dot segments from the path of a URI.
|
||||
*
|
||||
* @param uri A {@link StringBuilder} containing the URI.
|
||||
* @param offset The index of the start of the path in {@code uri}.
|
||||
* @param limit The limit (exclusive) of the path in {@code uri}.
|
||||
*/
|
||||
private static String removeDotSegments(StringBuilder uri, int offset, int limit) {
|
||||
if (offset >= limit) {
|
||||
// Nothing to do.
|
||||
return uri.toString();
|
||||
}
|
||||
if (uri.charAt(offset) == '/') {
|
||||
// If the path starts with a /, always retain it.
|
||||
offset++;
|
||||
}
|
||||
// The first character of the current path segment.
|
||||
int segmentStart = offset;
|
||||
int i = offset;
|
||||
while (i <= limit) {
|
||||
int nextSegmentStart;
|
||||
if (i == limit) {
|
||||
nextSegmentStart = i;
|
||||
} else if (uri.charAt(i) == '/') {
|
||||
nextSegmentStart = i + 1;
|
||||
} else {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// We've encountered the end of a segment or the end of the path. If the final segment was
|
||||
// "." or "..", remove the appropriate segments of the path.
|
||||
if (i == segmentStart + 1 && uri.charAt(segmentStart) == '.') {
|
||||
// Given "abc/def/./ghi", remove "./" to get "abc/def/ghi".
|
||||
uri.delete(segmentStart, nextSegmentStart);
|
||||
limit -= nextSegmentStart - segmentStart;
|
||||
i = segmentStart;
|
||||
} else if (i == segmentStart + 2
|
||||
&& uri.charAt(segmentStart) == '.'
|
||||
&& uri.charAt(segmentStart + 1) == '.') {
|
||||
// Given "abc/def/../ghi", remove "def/../" to get "abc/ghi".
|
||||
int prevSegmentStart = uri.lastIndexOf("/", segmentStart - 2) + 1;
|
||||
int removeFrom = prevSegmentStart > offset ? prevSegmentStart : offset;
|
||||
uri.delete(removeFrom, nextSegmentStart);
|
||||
limit -= nextSegmentStart - removeFrom;
|
||||
segmentStart = prevSegmentStart;
|
||||
i = prevSegmentStart;
|
||||
} else {
|
||||
i++;
|
||||
segmentStart = i;
|
||||
}
|
||||
}
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates indices of the constituent components of a URI.
|
||||
*
|
||||
* @param uriString The URI as a string.
|
||||
* @return The corresponding indices.
|
||||
*/
|
||||
private static int[] getUriIndices(String uriString) {
|
||||
int[] indices = new int[INDEX_COUNT];
|
||||
if (TextUtils.isEmpty(uriString)) {
|
||||
indices[SCHEME_COLON] = -1;
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Determine outer structure from right to left.
|
||||
// Uri = scheme ":" hier-part [ "?" query ] [ "#" fragment ]
|
||||
int length = uriString.length();
|
||||
int fragmentIndex = uriString.indexOf('#');
|
||||
if (fragmentIndex == -1) {
|
||||
fragmentIndex = length;
|
||||
}
|
||||
int queryIndex = uriString.indexOf('?');
|
||||
if (queryIndex == -1 || queryIndex > fragmentIndex) {
|
||||
// '#' before '?': '?' is within the fragment.
|
||||
queryIndex = fragmentIndex;
|
||||
}
|
||||
// Slashes are allowed only in hier-part so any colon after the first slash is part of the
|
||||
// hier-part, not the scheme colon separator.
|
||||
int schemeIndexLimit = uriString.indexOf('/');
|
||||
if (schemeIndexLimit == -1 || schemeIndexLimit > queryIndex) {
|
||||
schemeIndexLimit = queryIndex;
|
||||
}
|
||||
int schemeIndex = uriString.indexOf(':');
|
||||
if (schemeIndex > schemeIndexLimit) {
|
||||
// '/' before ':'
|
||||
schemeIndex = -1;
|
||||
}
|
||||
|
||||
// Determine hier-part structure: hier-part = "//" authority path / path
|
||||
// This block can also cope with schemeIndex == -1.
|
||||
boolean hasAuthority =
|
||||
schemeIndex + 2 < queryIndex
|
||||
&& uriString.charAt(schemeIndex + 1) == '/'
|
||||
&& uriString.charAt(schemeIndex + 2) == '/';
|
||||
int pathIndex;
|
||||
if (hasAuthority) {
|
||||
pathIndex = uriString.indexOf('/', schemeIndex + 3); // find first '/' after "://"
|
||||
if (pathIndex == -1 || pathIndex > queryIndex) {
|
||||
pathIndex = queryIndex;
|
||||
}
|
||||
} else {
|
||||
pathIndex = schemeIndex + 1;
|
||||
}
|
||||
|
||||
indices[SCHEME_COLON] = schemeIndex;
|
||||
indices[PATH] = pathIndex;
|
||||
indices[QUERY] = queryIndex;
|
||||
indices[FRAGMENT] = fragmentIndex;
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the relative path from a base URI to a target URI.
|
||||
*
|
||||
* @return The relative path from the base URI to the target URI, or {@code targetUri} if the URIs
|
||||
* have different schemes or authorities.
|
||||
*/
|
||||
@UnstableApi
|
||||
public static String getRelativePath(Uri baseUri, Uri targetUri) {
|
||||
if (baseUri.isOpaque() || targetUri.isOpaque()) {
|
||||
return targetUri.toString();
|
||||
}
|
||||
|
||||
String baseUriScheme = baseUri.getScheme();
|
||||
String targetUriScheme = targetUri.getScheme();
|
||||
boolean isSameScheme =
|
||||
baseUriScheme == null
|
||||
? targetUriScheme == null
|
||||
: targetUriScheme != null && Ascii.equalsIgnoreCase(baseUriScheme, targetUriScheme);
|
||||
if (!isSameScheme || !Objects.equals(baseUri.getAuthority(), targetUri.getAuthority())) {
|
||||
// Different schemes or authorities, cannot find relative path, return targetUri.
|
||||
return targetUri.toString();
|
||||
}
|
||||
|
||||
List<String> basePathSegments = baseUri.getPathSegments();
|
||||
List<String> targetPathSegments = targetUri.getPathSegments();
|
||||
|
||||
int commonPrefixCount = 0;
|
||||
int minSize = min(basePathSegments.size(), targetPathSegments.size());
|
||||
|
||||
for (int i = 0; i < minSize; i++) {
|
||||
if (!basePathSegments.get(i).equals(targetPathSegments.get(i))) {
|
||||
break;
|
||||
}
|
||||
commonPrefixCount++;
|
||||
}
|
||||
|
||||
StringBuilder relativePath = new StringBuilder();
|
||||
for (int i = commonPrefixCount; i < basePathSegments.size(); i++) {
|
||||
relativePath.append("../");
|
||||
}
|
||||
|
||||
for (int i = commonPrefixCount; i < targetPathSegments.size(); i++) {
|
||||
relativePath.append(targetPathSegments.get(i));
|
||||
if (i < targetPathSegments.size() - 1) {
|
||||
relativePath.append("/");
|
||||
}
|
||||
}
|
||||
|
||||
return relativePath.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
class UrlEncodedQueryString implements UrlQueryString {
|
||||
private static final Pattern VALIDATION_PATTERN = Pattern.compile("[^\\/?&]+=[^\\/&]+");
|
||||
private static final Pattern URL_PREFIX = Pattern.compile("^[a-z.]+://.+$");
|
||||
@Nullable
|
||||
private String mQueryPrefix;
|
||||
@Nullable
|
||||
private UrlEncodedQueryStringBase mQueryString;
|
||||
private String mUrl;
|
||||
|
||||
|
||||
public static boolean isValidUrl(String url) {
|
||||
if (url == null || url.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Matcher m = URL_PREFIX.matcher(url);
|
||||
return m.matches();
|
||||
}
|
||||
|
||||
private UrlEncodedQueryString(String url) {
|
||||
if (url == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mUrl = url;
|
||||
|
||||
if (isValidUrl(url)) {
|
||||
URI parsedUrl = getURI(url);
|
||||
if (parsedUrl != null) {
|
||||
mQueryPrefix = String.format("%s://%s%s", parsedUrl.getScheme(), parsedUrl.getHost(), parsedUrl.getPath());
|
||||
mQueryString = UrlEncodedQueryStringBase.parse(parsedUrl);
|
||||
}
|
||||
} else { // Only query
|
||||
mQueryString = UrlEncodedQueryStringBase.parse(url);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private URI getURI(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fix illegal character exception. E.g.
|
||||
// https://www.youtube.com/results?search_query=Джентльмены удачи
|
||||
// https://www.youtube.com/results?search_query=|FR|+Mrs.+Doubtfire
|
||||
// https://youtu.be/wTw-jreMgCk\ (last char isn't valid)
|
||||
// https://m.youtube.com/watch?v=JsY3_Va6uqI&feature=emb_title###&Urj7svfj=&Rkj2f3jk=&Czj1i9k6= (# isn't valid)
|
||||
return new URI(url.length() > 100 ? // OOM fix: don't replace long string
|
||||
url : url
|
||||
.replace(" ", "+")
|
||||
.replace("|", "%7C")
|
||||
.replace("\\", "/")
|
||||
.replace("#", "")
|
||||
);
|
||||
} catch (URISyntaxException e) {
|
||||
//throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static UrlEncodedQueryString parse(String url) {
|
||||
return new UrlEncodedQueryString(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
if (mQueryString != null) {
|
||||
mQueryString.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String get(String key) {
|
||||
return mQueryString != null ? mQueryString.get(key) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key) {
|
||||
String val = get(key);
|
||||
return val != null ? Float.parseFloat(val) : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, String value) {
|
||||
if (mQueryString != null) {
|
||||
mQueryString.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, float value) {
|
||||
set(key, String.valueOf(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, int value) {
|
||||
set(key, String.valueOf(value));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
if (mQueryString == null) {
|
||||
return mUrl != null ? mUrl : "";
|
||||
}
|
||||
|
||||
return mQueryPrefix != null ? String.format("%s?%s", mQueryPrefix, mQueryString) : mQueryString.toString();
|
||||
}
|
||||
|
||||
public static boolean matchAll(String input, Pattern... patterns) {
|
||||
for (Pattern pattern : patterns) {
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean matchAll(String input, String... regex) {
|
||||
for (String reg : regex) {
|
||||
Pattern pattern = Pattern.compile(reg);
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check query string
|
||||
*/
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
if (mUrl == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return matchAll(mUrl, VALIDATION_PATTERN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return mUrl == null || mUrl.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return mQueryString != null && mQueryString.contains(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Represents a www-form-urlencoded query string containing an (ordered) list of parameters.
|
||||
* <p>
|
||||
* An instance of this class represents a query string encoded using the
|
||||
* <code>www-form-urlencoded</code> encoding scheme, as defined by <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01 Specification:
|
||||
* application/x-www-form-urlencoded</a>, and <a
|
||||
* href="http://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.2.2">HTML 4.01
|
||||
* Specification: Ampersands in URI attribute values</a>. This is a common encoding scheme of the
|
||||
* query component of a URI, though the <a href="http://www.ietf.org/rfc/rfc2396.txt">RFC 2396 URI
|
||||
* specification</a> itself does not define a specific format for the query component.
|
||||
* <p>
|
||||
* This class provides static methods for <a href="#create()">creating</a> UrlEncodedQueryString
|
||||
* instances by <a href="#parse(java.lang.CharSequence)">parsing</a> URI and string forms. It can
|
||||
* then be used to create, retrieve, update and delete parameters, and to re-compareAndApply the query string
|
||||
* back to an existing URI.
|
||||
* <p>
|
||||
* <h4>Encoding and decoding</h4> UrlEncodedQueryString automatically encodes and decodes parameter
|
||||
* names and values to and from <code>www-form-urlencoded</code> encoding by using
|
||||
* <code>java.net.URLEncoder</code> and <code>java.net.URLDecoder</code>, which follow the <a
|
||||
* href="http://www.w3.org/TR/html40/appendix/notes.html#non-ascii-chars"> HTML 4.01 Specification:
|
||||
* Non-ASCII characters in URI attribute values</a> recommendation.
|
||||
* <h4>Multivalued parameters</h4> Often, parameter names are unique across the name/value pairs of
|
||||
* a <code>www-form-urlencoded</code> query string. However, it is permitted for the same parameter
|
||||
* name to appear in multiple name/value pairs, denoting that a single parameter has multiple
|
||||
* values. This less common use case can lead to ambiguity when adding parameters - is the 'add' a
|
||||
* 'replace' (of an existing parameter, if one with the same name already exists) or an 'append'
|
||||
* (potentially creating a multivalued parameter, if one with the same name already exists)?
|
||||
* <p>
|
||||
* This requirement significantly shapes the <code>UrlEncodedQueryString</code> API. In particular
|
||||
* there are:
|
||||
* <ul>
|
||||
* <li><code>set</code> methods for setting a parameter, potentially replacing an existing value
|
||||
* <li><code>append</code> methods for adding a parameter, potentially creating a multivalued
|
||||
* parameter
|
||||
* <li><code>get</code> methods for returning a single value, even if the parameter has multiple
|
||||
* values
|
||||
* <li><code>getValues</code> methods for returning multiple values
|
||||
* </ul>
|
||||
* <h4>Retrieving parameters</h4> UrlEncodedQueryString can be used to parse and retrieve parameters
|
||||
* from a query string by passing either a URI or a query string:
|
||||
* <p>
|
||||
* <code>
|
||||
* URI uri = new URI("http://java.sun.com?forum=2");<br/>
|
||||
* UrlEncodedQueryString queryString = UrlEncodedQueryString.parse(uri);<br/>
|
||||
* System.out.println(queryString.get("forum"));<br/>
|
||||
* </code>
|
||||
* <h4>Modifying parameters</h4> UrlEncodedQueryString can be used to set, append or remove
|
||||
* parameters from a query string:
|
||||
* <p>
|
||||
* <code>
|
||||
* URI uri = new URI("/forum/article.jsp?id=2&para=4");<br/>
|
||||
* UrlEncodedQueryString queryString = UrlEncodedQueryString.parse(uri);<br/>
|
||||
* queryString.set("id", 3);<br/>
|
||||
* queryString.remove("para");<br/>
|
||||
* System.out.println(queryString);<br/>
|
||||
* </code>
|
||||
* <p>
|
||||
* When modifying parameters, the ordering of existing parameters is maintained. Parameters are
|
||||
* <code>set</code> and <code>removed</code> in-place, while <code>appended</code> parameters are
|
||||
* added to the end of the query string.
|
||||
* <h4>Applying the Query</h4> UrlEncodedQueryString can be used to compareAndApply a modified query string
|
||||
* back to a URI, creating a new URI:
|
||||
* <p>
|
||||
* <code>
|
||||
* URI uri = new URI("/forum/article.jsp?id=2");<br/>
|
||||
* UrlEncodedQueryString queryString = UrlEncodedQueryString.parse(uri);<br/>
|
||||
* queryString.set("id", 3);<br/>
|
||||
* uri = queryString.compareAndApply(uri);<br/>
|
||||
* </code>
|
||||
* <p>
|
||||
* When reconstructing query strings, there are two valid separator parameters defined by the W3C
|
||||
* (ampersand "&" and semicolon ";"), with ampersand being the most common. The
|
||||
* <code>compareAndApply</code> and <code>toString</code> methods both default to using an ampersand, with
|
||||
* overloaded forms for using a semicolon.
|
||||
* <h4>Thread Safety</h4> This implementation is not synchronized. If multiple threads access a
|
||||
* query string concurrently, and at least one of the threads modifies the query string, it must be
|
||||
* synchronized externally. This is typically accomplished by synchronizing on some object that
|
||||
* naturally encapsulates the query string.
|
||||
*
|
||||
* @author Richard Kennard
|
||||
* @version 1.2
|
||||
*/
|
||||
|
||||
class UrlEncodedQueryStringBase {
|
||||
|
||||
//
|
||||
// Public statics
|
||||
//
|
||||
|
||||
/**
|
||||
* Enumeration of recommended www-form-urlencoded separators.
|
||||
* <p>
|
||||
* Recommended separators are defined by <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01
|
||||
* Specification: application/x-www-form-urlencoded</a> and <a
|
||||
* href="http://www.w3.org/TR/html401/appendix/notes.html#h-B.2.2">HTML 4.01 Specification:
|
||||
* Ampersands in URI attribute values</a>.
|
||||
* <p>
|
||||
* <em>All</em> separators are recognised when parsing query strings. <em>One</em> separator may
|
||||
* be passed to <code>toString</code> and <code>compareAndApply</code> when outputting query strings.
|
||||
*/
|
||||
|
||||
public static enum Separator {
|
||||
/**
|
||||
* An ampersand <code>&</code> - the separator recommended by <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01
|
||||
* Specification: application/x-www-form-urlencoded</a>.
|
||||
*/
|
||||
|
||||
AMPERSAND {
|
||||
|
||||
/**
|
||||
* Returns a String representation of this Separator.
|
||||
* <p>
|
||||
* The String representation matches that defined by the <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01
|
||||
* Specification: application/x-www-form-urlencoded</a>.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
return "&";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* A semicolon <code>;</code> - the separator recommended by <a
|
||||
* href="http://www.w3.org/TR/html401/appendix/notes.html#h-B.2.2">HTML 4.01 Specification:
|
||||
* Ampersands in URI attribute values</a>.
|
||||
*/
|
||||
|
||||
SEMICOLON {
|
||||
|
||||
/**
|
||||
* Returns a String representation of this Separator.
|
||||
* <p>
|
||||
* The String representation matches that defined by the <a
|
||||
* href="http://www.w3.org/TR/html401/appendix/notes.html#h-B.2.2">HTML 4.01
|
||||
* Specification: Ampersands in URI attribute values</a>.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
return ";";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty UrlEncodedQueryString.
|
||||
* <p>
|
||||
* Calling <code>toString()</code> on the created instance will return an empty String.
|
||||
*/
|
||||
|
||||
public static UrlEncodedQueryStringBase create() {
|
||||
|
||||
return new UrlEncodedQueryStringBase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UrlEncodedQueryString from the given Map.
|
||||
* <p>
|
||||
* The order the parameters are created in corresponds to the iteration order of the Map.
|
||||
*
|
||||
* @param parameterMap
|
||||
* <code>Map</code> containing parameter names and values.
|
||||
*/
|
||||
|
||||
public static UrlEncodedQueryStringBase create(Map<String, List<String>> parameterMap ) {
|
||||
|
||||
UrlEncodedQueryStringBase queryString = new UrlEncodedQueryStringBase();
|
||||
|
||||
// Defensively copy the List<String>'s
|
||||
|
||||
for ( Map.Entry<String, List<String>> entry : parameterMap.entrySet() ) {
|
||||
queryString.queryMap.put( entry.getKey(), new ArrayList<String>( entry.getValue() ) );
|
||||
}
|
||||
|
||||
return queryString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UrlEncodedQueryString by parsing the given query string.
|
||||
* <p>
|
||||
* This method assumes the given string is the <code>www-form-urlencoded</code> query component
|
||||
* of a URI. When parsing, all <a href="UrlEncodedQueryString.Separator.html">Separators</a> are
|
||||
* recognised.
|
||||
* <p>
|
||||
* The result of calling this method with a string that is not <code>www-form-urlencoded</code>
|
||||
* (eg. passing an entire URI, not just its query string) will likely be mismatched parameter
|
||||
* names.
|
||||
*
|
||||
* @param query
|
||||
* query string to be parsed
|
||||
*/
|
||||
|
||||
public static UrlEncodedQueryStringBase parse(final CharSequence query ) {
|
||||
|
||||
UrlEncodedQueryStringBase queryString = new UrlEncodedQueryStringBase();
|
||||
|
||||
// Note: import to call appendOrSet with 'true', in
|
||||
// case the given query contains multi-valued parameters
|
||||
|
||||
queryString.appendOrSet( query, true );
|
||||
|
||||
return queryString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UrlEncodedQueryString by extracting and parsing the query component from the given
|
||||
* URI.
|
||||
* <p>
|
||||
* This method assumes the query component is <code>www-form-urlencoded</code>. When parsing,
|
||||
* all separators from the Separators enum are recognised.
|
||||
* <p>
|
||||
* The result of calling this method with a query component that is not
|
||||
* <code>www-form-urlencoded</code> will likely be mismatched parameter names.
|
||||
*
|
||||
* @param uri
|
||||
* URI to be parsed
|
||||
*/
|
||||
|
||||
public static UrlEncodedQueryStringBase parse(final URI uri ) {
|
||||
|
||||
// Note: use uri.getRawQuery, not uri.getQuery, in case the
|
||||
// query parameters contain encoded ampersands (%26)
|
||||
|
||||
return parse( uri.getRawQuery() );
|
||||
}
|
||||
|
||||
//
|
||||
// Private statics
|
||||
//
|
||||
|
||||
/**
|
||||
* Separators to honour when parsing query strings.
|
||||
* <p>
|
||||
* <em>All</em> Separators are recognized when parsing parameters, regardless of what the user
|
||||
* later nominates as their <code>toString</code> output parameter.
|
||||
*/
|
||||
|
||||
private static final String PARSE_PARAMETER_SEPARATORS = String.valueOf( Separator.AMPERSAND ) + Separator.SEMICOLON;
|
||||
|
||||
//
|
||||
// Private members
|
||||
//
|
||||
|
||||
/**
|
||||
* Map of query parameters.
|
||||
*/
|
||||
|
||||
// Note: we initialize this Map upon object creation because, realistically, it
|
||||
// is always going to be needed (eg. there is little point lazy-initializing it)
|
||||
private final Map<String, List<String>> queryMap = new LinkedHashMap<String, List<String>>();
|
||||
|
||||
//
|
||||
// Public methods
|
||||
//
|
||||
|
||||
/**
|
||||
* Returns the value of the named parameter as a String. Returns <code>null</code> if the
|
||||
* parameter does not exist, or exists but has a <code>null</code> value (see {@link #contains
|
||||
* contains}).
|
||||
* <p>
|
||||
* You should only use this method when you are sure the parameter has only one value. If the
|
||||
* parameter might have more than one value, use <a
|
||||
* href="#getValues(java.lang.String)">getValues</a>.
|
||||
* <p>
|
||||
* If you use this method with a multivalued parameter, the value returned is equal to the first
|
||||
* value in the List returned by <a href="#getValues(java.lang.String)">getValues</a>.
|
||||
*
|
||||
* @param name
|
||||
* <code>String</code> specifying the name of the parameter
|
||||
* @return <code>String</code> representing the single value of the parameter, or
|
||||
* <code>null</code> if the parameter does not exist or exists but with a null value
|
||||
* (see {@link #contains contains}).
|
||||
*/
|
||||
|
||||
public String get( final String name ) {
|
||||
|
||||
List<String> parameters = getValues( name );
|
||||
|
||||
if ( parameters == null || parameters.isEmpty() ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parameters.get( 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the named parameter exists.
|
||||
* <p>
|
||||
* This can be useful to distinguish between a parameter not existing, and a parameter existing
|
||||
* but with a <code>null</code> value (eg. <code>foo=1&bar</code>). This is distinct from a
|
||||
* parameter existing with a value of the empty String (eg. <code>foo=1&bar=</code>).
|
||||
*/
|
||||
|
||||
public boolean contains( final String name ) {
|
||||
|
||||
return this.queryMap.containsKey( name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an <code>Iterator</code> of <code>String</code> objects containing the names of the
|
||||
* parameters. If there are no parameters, the method returns an empty Iterator. For names with
|
||||
* multiple values, only one copy of the name is returned.
|
||||
*
|
||||
* @return an <code>Iterator</code> of <code>String</code> objects, each String containing the
|
||||
* name of a parameter; or an empty Iterator if there are no parameters
|
||||
*/
|
||||
|
||||
public Iterator<String> getNames() {
|
||||
|
||||
return this.queryMap.keySet().iterator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a List of <code>String</code> objects containing all of the values the named
|
||||
* parameter has, or <code>null</code> if the parameter does not exist.
|
||||
* <p>
|
||||
* If the parameter has a single value, the List has a size of 1.
|
||||
*
|
||||
* @param name
|
||||
* name of the parameter to retrieve
|
||||
* @return a List of String objects containing the parameter's values, or <code>null</code> if
|
||||
* the paramater does not exist
|
||||
*/
|
||||
|
||||
public List<String> getValues( final String name ) {
|
||||
|
||||
return this.queryMap.get( name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a mutable <code>Map</code> of the query parameters.
|
||||
*
|
||||
* @return <code>Map</code> containing parameter names as keys and parameter values as map
|
||||
* values. The keys in the parameter map are of type <code>String</code>. The values in
|
||||
* the parameter map are Lists of type <code>String</code>, and their ordering is
|
||||
* consistent with their ordering in the query string. Will never return
|
||||
* <code>null</code>.
|
||||
*/
|
||||
|
||||
public Map<String, List<String>> getMap() {
|
||||
|
||||
LinkedHashMap<String, List<String>> map = new LinkedHashMap<String, List<String>>();
|
||||
|
||||
// Defensively copy the List<String>'s
|
||||
|
||||
for ( Map.Entry<String, List<String>> entry : this.queryMap.entrySet() ) {
|
||||
List<String> listValues = entry.getValue();
|
||||
map.put( entry.getKey(), new ArrayList<String>( listValues ) );
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a query parameter.
|
||||
* <p>
|
||||
* If one or more parameters with this name already exist, they will be replaced with a single
|
||||
* parameter with the given value. If no such parameters exist, one will be added.
|
||||
*
|
||||
* @param name
|
||||
* name of the query parameter
|
||||
* @param value
|
||||
* value of the query parameter. If <code>null</code>, the parameter is removed
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase set(final String name, final String value ) {
|
||||
|
||||
appendOrSet( name, value, false );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a query parameter.
|
||||
* <p>
|
||||
* If one or more parameters with this name already exist, they will be replaced with a single
|
||||
* parameter with the given value. If no such parameters exist, one will be added.
|
||||
* <p>
|
||||
* This version of <code>set</code> accepts a <code>Number</code> suitable for auto-boxing. For
|
||||
* example:
|
||||
* <p>
|
||||
* <code>
|
||||
* queryString.set( "id", 3 );<br/>
|
||||
* </code>
|
||||
*
|
||||
* @param name
|
||||
* name of the query parameter
|
||||
* @param value
|
||||
* value of the query parameter. If <code>null</code>, the parameter is removed
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase set(final String name, final Number value ) {
|
||||
|
||||
if ( value == null ) {
|
||||
remove( name );
|
||||
return this;
|
||||
}
|
||||
|
||||
appendOrSet( name, value.toString(), false );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets query parameters from a <code>www-form-urlencoded</code> string.
|
||||
* <p>
|
||||
* The given string is assumed to be in <code>www-form-urlencoded</code> format. The result of
|
||||
* passing a string not in <code>www-form-urlencoded</code> format (eg. passing an entire URI,
|
||||
* not just its query string) will likely be mismatched parameter names.
|
||||
* <p>
|
||||
* The given string is parsed into named parameters, and each is added to the existing
|
||||
* parameters. If a parameter with the same name already exists, it is replaced with a single
|
||||
* parameter with the given value. If the same parameter name appears more than once in the
|
||||
* given string, it is stored as a multivalued parameter. When parsing, all <a
|
||||
* href="UrlEncodedQueryString.Separator.html">Separators</a> are recognised.
|
||||
*
|
||||
* @param query
|
||||
* <code>www-form-urlencoded</code> string. If <code>null</code>, does nothing
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase set(final String query ) {
|
||||
|
||||
appendOrSet( query, false );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a query parameter.
|
||||
* <p>
|
||||
* If one or more parameters with this name already exist, their value will be preserved and the
|
||||
* given value will be stored as a multivalued parameter. If no such parameters exist, one will
|
||||
* be added.
|
||||
*
|
||||
* @param name
|
||||
* name of the query parameter
|
||||
* @param value
|
||||
* value of the query parameter. If <code>null</code>, does nothing
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase append(final String name, final String value ) {
|
||||
|
||||
appendOrSet( name, value, true );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a query parameter.
|
||||
* <p>
|
||||
* If one or more parameters with this name already exist, their value will be preserved and the
|
||||
* given value will be stored as a multivalued parameter. If no such parameters exist, one will
|
||||
* be added.
|
||||
* <p>
|
||||
* This version of <code>append</code> accepts a <code>Number</code> suitable for auto-boxing.
|
||||
* For example:
|
||||
* <p>
|
||||
* <code>
|
||||
* queryString.append( "id", 3 );<br/>
|
||||
* </code>
|
||||
*
|
||||
* @param name
|
||||
* name of the query parameter
|
||||
* @param value
|
||||
* value of the query parameter. If <code>null</code>, does nothing
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase append(final String name, final Number value ) {
|
||||
|
||||
appendOrSet( name, value.toString(), true );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends query parameters from a <code>www-form-urlencoded</code> string.
|
||||
* <p>
|
||||
* The given string is assumed to be in <code>www-form-urlencoded</code> format. The result of
|
||||
* passing a string not in <code>www-form-urlencoded</code> format (eg. passing an entire URI,
|
||||
* not just its query string) will likely be mismatched parameter names.
|
||||
* <p>
|
||||
* The given string is parsed into named parameters, and appended to the existing parameters. If
|
||||
* a parameter with the same name already exists, or if the same parameter name appears more
|
||||
* than once in the given string, it is stored as a multivalued parameter. When parsing, all <a
|
||||
* href="UrlEncodedQueryString.Separator.html">Separators</a> are recognised.
|
||||
*
|
||||
* @param query
|
||||
* <code>www-form-urlencoded</code> string. If <code>null</code>, does nothing
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase append(final String query ) {
|
||||
|
||||
appendOrSet( query, true );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the query string is empty.
|
||||
*
|
||||
* @return true if the query string has no parameters
|
||||
*/
|
||||
|
||||
public boolean isEmpty() {
|
||||
|
||||
return queryMap.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the named query parameter.
|
||||
* <p>
|
||||
* If the parameter has multiple values, all its values are removed.
|
||||
*
|
||||
* @param name
|
||||
* name of the parameter to remove
|
||||
* @return a reference to this object
|
||||
*/
|
||||
|
||||
public UrlEncodedQueryStringBase remove(final String name ) {
|
||||
|
||||
appendOrSet( name, null, false );
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the query string to the given URI.
|
||||
* <p>
|
||||
* A copy of the given URI is taken and its existing query string, if there is one, is replaced.
|
||||
* The query string parameters are separated by <code>Separator.Ampersand</code>.
|
||||
*
|
||||
* @param uri
|
||||
* URI to copy and update
|
||||
* @return a copy of the given URI, with an updated query string
|
||||
*/
|
||||
|
||||
public URI apply( URI uri ) {
|
||||
|
||||
return apply( uri, Separator.AMPERSAND );
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the query string to the given URI, using the given separator between parameters.
|
||||
* <p>
|
||||
* A copy of the given URI is taken and its existing query string, if there is one, is replaced.
|
||||
* The query string parameters are separated using the given <code>Separator</code>.
|
||||
*
|
||||
* @param uri
|
||||
* URI to copy and update
|
||||
* @param separator
|
||||
* separator to use between parameters
|
||||
* @return a copy of the given URI, with an updated query string
|
||||
*/
|
||||
|
||||
public URI apply( URI uri, Separator separator ) {
|
||||
|
||||
// Note this code is essentially a copy of 'java.net.URI.defineString',
|
||||
// which is private. We cannot use the 'new URI( scheme, userInfo, ... )' or
|
||||
// 'new URI( scheme, authority, ... )' constructors because they double
|
||||
// encode the query string using 'java.net.URI.quote'
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if ( uri.getScheme() != null ) {
|
||||
builder.append( uri.getScheme() );
|
||||
builder.append( ':' );
|
||||
}
|
||||
if ( uri.getHost() != null ) {
|
||||
builder.append( "//" );
|
||||
if ( uri.getUserInfo() != null ) {
|
||||
builder.append( uri.getUserInfo() );
|
||||
builder.append( '@' );
|
||||
}
|
||||
builder.append( uri.getHost() );
|
||||
if ( uri.getPort() != -1 ) {
|
||||
builder.append( ':' );
|
||||
builder.append( uri.getPort() );
|
||||
}
|
||||
} else if ( uri.getAuthority() != null ) {
|
||||
builder.append( "//" );
|
||||
builder.append( uri.getAuthority() );
|
||||
}
|
||||
if ( uri.getPath() != null ) {
|
||||
builder.append( uri.getPath() );
|
||||
}
|
||||
|
||||
String query = toString( separator );
|
||||
if ( query.length() != 0 ) {
|
||||
builder.append( '?' );
|
||||
builder.append( query );
|
||||
}
|
||||
if ( uri.getFragment() != null ) {
|
||||
builder.append( '#' );
|
||||
builder.append( uri.getFragment() );
|
||||
}
|
||||
|
||||
try {
|
||||
return new URI( builder.toString() );
|
||||
} catch ( URISyntaxException e ) {
|
||||
// Can never happen, as the given URI will always be valid,
|
||||
// and getQuery() will always return a valid query string
|
||||
|
||||
throw new RuntimeException( e );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the specified object with this UrlEncodedQueryString for equality.
|
||||
* <p>
|
||||
* Returns <code>true</code> if the given object is also a UrlEncodedQueryString and the two
|
||||
* UrlEncodedQueryStrings have the same parameters. More formally, two UrlEncodedQueryStrings
|
||||
* <code>t1</code> and <code>t2</code> represent the same UrlEncodedQueryString if
|
||||
* <code>t1.toString().equals(t2.toString())</code>. This ensures that the <code>equals</code>
|
||||
* method checks the ordering, as well as the existence, of every parameter.
|
||||
* <p>
|
||||
* Clients interested only in the existence, not the ordering, of parameters are recommended to
|
||||
* use <code>getMap().equals</code>.
|
||||
* <p>
|
||||
* This implementation first checks if the specified object is this UrlEncodedQueryString; if so
|
||||
* it returns <code>true</code>. Then, it checks if the specified object is a
|
||||
* UrlEncodedQueryString whose toString() is identical to the toString() of this
|
||||
* UrlEncodedQueryString; if not, it returns <code>false</code>. Otherwise, it returns
|
||||
* <code>true</code>
|
||||
*
|
||||
* @param obj
|
||||
* object to be compared for equality with this UrlEncodedQueryString.
|
||||
* @return <code>true</code> if the specified object is equal to this UrlEncodedQueryString.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public boolean equals( Object obj ) {
|
||||
|
||||
if ( obj == this ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( !( obj instanceof UrlEncodedQueryStringBase) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String query = toString();
|
||||
String thatQuery = ( (UrlEncodedQueryStringBase) obj ).toString();
|
||||
|
||||
return query.equals( thatQuery );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a hash code value for the UrlEncodedQueryString.
|
||||
* <p>
|
||||
* The hash code of the UrlEncodedQueryString is defined to be the hash code of the
|
||||
* <code>String</code> returned by toString(). This ensures the ordering, as well as the
|
||||
* existence, of parameters is taken into account.
|
||||
* <p>
|
||||
* Clients interested only in the existence, not the ordering, of parameters are recommended to
|
||||
* use <code>getMap().hashCode</code>.
|
||||
*
|
||||
* @return a hash code value for this UrlEncodedQueryString.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
|
||||
return toString().hashCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a <code>www-form-urlencoded</code> string of the query parameters.
|
||||
* <p>
|
||||
* The HTML specification recommends two parameter separators in <a
|
||||
* href="http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4.1">HTML 4.01
|
||||
* Specification: application/x-www-form-urlencoded</a> and <a
|
||||
* href="http://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.2.2">HTML 4.01
|
||||
* Specification: Ampersands in URI attribute values</a>. Of those, the ampersand is the more
|
||||
* commonly used and this method defaults to that.
|
||||
*
|
||||
* @return <code>www-form-urlencoded</code> string, or <code>null</code> if there are no
|
||||
* parameters.
|
||||
*/
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
|
||||
return toString( Separator.AMPERSAND );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a <code>www-form-urlencoded</code> string of the query parameters, using the given
|
||||
* separator between parameters.
|
||||
*
|
||||
* @param separator
|
||||
* separator to use between parameters
|
||||
* @return <code>www-form-urlencoded</code> string, or an empty String if there are no
|
||||
* parameters
|
||||
*/
|
||||
|
||||
// Note: this method takes a Separator, not just any String. Taking any String may
|
||||
// be useful in some circumstances (eg. you could pass '&' to generate query
|
||||
// strings for use in HTML pages) but would break the implied contract between
|
||||
// toString() and parse() (eg. you can always parse() what you toString() ).
|
||||
//
|
||||
// It was thought better to leave it to the user to explictly break this contract
|
||||
// (eg. toString().replaceAll( '&', '&' ))
|
||||
public String toString( Separator separator ) {
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
for ( String name : this.queryMap.keySet() ) {
|
||||
for ( String value : this.queryMap.get( name ) ) {
|
||||
if ( builder.length() != 0 ) {
|
||||
builder.append( separator );
|
||||
}
|
||||
|
||||
// Encode names and values. Do this in toString(), rather than
|
||||
// append/set, so that the Map always contains the
|
||||
// raw, unencoded values
|
||||
|
||||
try {
|
||||
builder.append( URLEncoder.encode( name, "UTF-8" ) );
|
||||
|
||||
if ( value != null ) {
|
||||
builder.append( '=' );
|
||||
builder.append( URLEncoder.encode( value, "UTF-8" ) );
|
||||
}
|
||||
} catch ( UnsupportedEncodingException e ) {
|
||||
// Should never happen. UTF-8 should always be available
|
||||
// according to Java spec
|
||||
|
||||
throw new RuntimeException( e );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
//
|
||||
// Private methods
|
||||
//
|
||||
|
||||
/**
|
||||
* Private constructor.
|
||||
* <p>
|
||||
* Clients should use one of the <code>create</code> or <code>parse</code> methods to create a
|
||||
* <code>UrlEncodedQueryString</code>.
|
||||
*/
|
||||
|
||||
private UrlEncodedQueryStringBase() {
|
||||
|
||||
// Can never be called
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for append and set
|
||||
*
|
||||
* @param name
|
||||
* the parameter's name
|
||||
* @param value
|
||||
* the parameter's value
|
||||
* @param append
|
||||
* whether to append (or set)
|
||||
*/
|
||||
|
||||
private void appendOrSet( final String name, final String value, final boolean append ) {
|
||||
|
||||
if ( name == null ) {
|
||||
throw new NullPointerException( "name" );
|
||||
}
|
||||
|
||||
// If we're appending, and there's an existing parameter...
|
||||
|
||||
if ( append ) {
|
||||
List<String> listValues = this.queryMap.get( name );
|
||||
|
||||
// ...add to it
|
||||
|
||||
if ( listValues != null ) {
|
||||
listValues.add( value );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ...otherwise, if we're setting and the value is null...
|
||||
|
||||
else if ( value == null ) {
|
||||
// ...remove it
|
||||
|
||||
this.queryMap.remove( name );
|
||||
return;
|
||||
}
|
||||
|
||||
// ...otherwise, create a new one
|
||||
|
||||
List<String> listValues = new ArrayList<String>();
|
||||
listValues.add( value );
|
||||
|
||||
this.queryMap.put( name, listValues );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for append and set
|
||||
*
|
||||
* @param parameters
|
||||
* <code>www-form-urlencoded</code> string
|
||||
* @param append
|
||||
* whether to append (or set)
|
||||
*/
|
||||
|
||||
private void appendOrSet( final CharSequence parameters, final boolean append ) {
|
||||
|
||||
// Nothing to do?
|
||||
|
||||
if ( parameters == null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Note we always parse using PARSE_PARAMETER_SEPARATORS, regardless
|
||||
// of what the user later nominates as their output parameter
|
||||
// separator using toString()
|
||||
|
||||
StringTokenizer tokenizer = new StringTokenizer( parameters.toString(), PARSE_PARAMETER_SEPARATORS );
|
||||
|
||||
Set<String> setAlreadyParsed = null;
|
||||
|
||||
while ( tokenizer.hasMoreTokens() ) {
|
||||
String parameter = tokenizer.nextToken();
|
||||
|
||||
int indexOf = parameter.indexOf( '=' );
|
||||
|
||||
String name;
|
||||
String value;
|
||||
|
||||
try {
|
||||
if ( indexOf == -1 ) {
|
||||
name = parameter;
|
||||
value = null;
|
||||
} else {
|
||||
name = parameter.substring( 0, indexOf );
|
||||
value = parameter.substring( indexOf + 1 );
|
||||
}
|
||||
|
||||
// Decode the name if necessary (i.e. %70age=1 becomes page=1)
|
||||
|
||||
name = URLDecoder.decode( name, "UTF-8" );
|
||||
|
||||
// When not appending, the first time we see a given
|
||||
// name it is important to remove it from the existing
|
||||
// parameters
|
||||
|
||||
if ( !append ) {
|
||||
if ( setAlreadyParsed == null ) {
|
||||
setAlreadyParsed = new HashSet<String>();
|
||||
}
|
||||
|
||||
if ( !setAlreadyParsed.contains( name ) ) {
|
||||
remove( name );
|
||||
}
|
||||
|
||||
setAlreadyParsed.add( name );
|
||||
}
|
||||
|
||||
if ( value != null ) {
|
||||
value = URLDecoder.decode( value, "UTF-8" );
|
||||
}
|
||||
|
||||
appendOrSet( name, value, true );
|
||||
} catch ( UnsupportedEncodingException e ) {
|
||||
// Should never happen. UTF-8 should always be available
|
||||
// according to Java spec
|
||||
|
||||
throw new RuntimeException( e );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
public interface UrlQueryString {
|
||||
void remove(String key);
|
||||
String get(String key);
|
||||
float getFloat(String key);
|
||||
void set(String key, String value);
|
||||
void set(String key, int value);
|
||||
void set(String key, float value);
|
||||
boolean isEmpty();
|
||||
boolean isValid();
|
||||
boolean contains(String key);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.futo.platformplayer.sabr;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
|
||||
public class UrlQueryStringFactory {
|
||||
public static UrlQueryString parse(Uri url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parse(url.toString());
|
||||
}
|
||||
|
||||
public static String toString(InputStream in) {
|
||||
try {
|
||||
int bufsize = 8196;
|
||||
char[] cbuf = new char[bufsize];
|
||||
StringBuilder buf = new StringBuilder(bufsize);
|
||||
InputStreamReader reader = new InputStreamReader(in, "UTF-8");
|
||||
|
||||
int readBytes;
|
||||
while ((readBytes = reader.read(cbuf, 0, bufsize)) != -1) {
|
||||
buf.append(cbuf, 0, readBytes);
|
||||
}
|
||||
|
||||
return buf.toString();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
Log.e("UrlQueryStringFactory", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static UrlQueryString parse(InputStream urlContent) {
|
||||
return parse(toString(urlContent));
|
||||
}
|
||||
|
||||
//public static UrlQueryString parse(String url) {
|
||||
// UrlQueryString pathQueryString = PathQueryString.parse(url);
|
||||
//
|
||||
// if (pathQueryString.isValid()) {
|
||||
// return pathQueryString;
|
||||
// }
|
||||
//
|
||||
// UrlQueryString urlQueryString = UrlEncodedQueryString.parse(url);
|
||||
//
|
||||
// if (urlQueryString.isValid()) {
|
||||
// return urlQueryString;
|
||||
// }
|
||||
//
|
||||
// return NullQueryString.parse(url);
|
||||
//}
|
||||
|
||||
public static UrlQueryString parse(String url) {
|
||||
return CombinedQueryString.parse(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents a set of interchangeable encoded versions of a media content component.
|
||||
*/
|
||||
public class AdaptationSet {
|
||||
|
||||
/**
|
||||
* Value of {@link #id} indicating no value is set.=
|
||||
*/
|
||||
public static final int ID_UNSET = -1;
|
||||
|
||||
/**
|
||||
* A non-negative identifier for the adaptation set that's unique in the scope of its containing
|
||||
* period, or {@link #ID_UNSET} if not specified.
|
||||
*/
|
||||
public final int id;
|
||||
|
||||
/**
|
||||
* The type of the adaptation set. One of the {@link androidx.media3.C}
|
||||
* {@code TRACK_TYPE_*} constants.
|
||||
*/
|
||||
public final int type;
|
||||
|
||||
/**
|
||||
* {@link Representation}s in the adaptation set.
|
||||
*/
|
||||
public final List<Representation> representations;
|
||||
|
||||
/**
|
||||
* @param id A non-negative identifier for the adaptation set that's unique in the scope of its
|
||||
* containing period, or {@link #ID_UNSET} if not specified.
|
||||
* @param type The type of the adaptation set. One of the {@link androidx.media3.C}
|
||||
* {@code TRACK_TYPE_*} constants.
|
||||
* @param representations {@link Representation}s in the adaptation set.
|
||||
*/
|
||||
public AdaptationSet(int id, int type, List<Representation> representations) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.representations = Collections.unmodifiableList(representations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.metadata.emsg.EventMessage;
|
||||
|
||||
/**
|
||||
* A DASH in-MPD EventStream element, as defined by ISO/IEC 23009-1, 2nd edition, section 5.10.
|
||||
*/
|
||||
@UnstableApi
|
||||
public final class EventStream {
|
||||
|
||||
/**
|
||||
* {@link EventMessage}s in the event stream.
|
||||
*/
|
||||
public final EventMessage[] events;
|
||||
|
||||
/**
|
||||
* Presentation time of the events in microsecond, sorted in ascending order.
|
||||
*/
|
||||
public final long[] presentationTimesUs;
|
||||
|
||||
/**
|
||||
* The scheme URI.
|
||||
*/
|
||||
public final String schemeIdUri;
|
||||
|
||||
/**
|
||||
* The value of the event stream. Use empty string if not defined in manifest.
|
||||
*/
|
||||
public final String value;
|
||||
|
||||
/**
|
||||
* The timescale in units per seconds, as defined in the manifest.
|
||||
*/
|
||||
public final long timescale;
|
||||
|
||||
public EventStream(String schemeIdUri, String value, long timescale, long[] presentationTimesUs,
|
||||
EventMessage[] events) {
|
||||
this.schemeIdUri = schemeIdUri;
|
||||
this.value = value;
|
||||
this.timescale = timescale;
|
||||
this.presentationTimesUs = presentationTimesUs;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* A constructed id of this {@link EventStream}. Equal to {@code schemeIdUri + "/" + value}.
|
||||
*/
|
||||
public String id() {
|
||||
return schemeIdUri + "/" + value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Encapsulates media content components over a contiguous period of time.
|
||||
*/
|
||||
public class Period {
|
||||
/**
|
||||
* The period identifier, if one exists.
|
||||
*/
|
||||
@Nullable
|
||||
public final String id;
|
||||
|
||||
/**
|
||||
* The start time of the period in milliseconds.
|
||||
*/
|
||||
public final long startMs;
|
||||
|
||||
/**
|
||||
* The adaptation sets belonging to the period.
|
||||
*/
|
||||
public final List<AdaptationSet> adaptationSets;
|
||||
|
||||
/**
|
||||
* @param id The period identifier. May be null.
|
||||
* @param startMs The start time of the period in milliseconds.
|
||||
* @param adaptationSets The adaptation sets belonging to the period.
|
||||
*/
|
||||
public Period(@Nullable String id, long startMs, List<AdaptationSet> adaptationSets) {
|
||||
this.id = id;
|
||||
this.startMs = startMs;
|
||||
this.adaptationSets = Collections.unmodifiableList(adaptationSets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the first adaptation set of a given type, or {@link C#INDEX_UNSET} if no
|
||||
* adaptation set of the specified type exists.
|
||||
*
|
||||
* @param type An adaptation set type.
|
||||
* @return The index of the first adaptation set of the specified type, or {@link C#INDEX_UNSET}.
|
||||
*/
|
||||
public int getAdaptationSetIndex(int type) {
|
||||
int adaptationCount = adaptationSets.size();
|
||||
for (int i = 0; i < adaptationCount; i++) {
|
||||
if (adaptationSets.get(i).type == type) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return C.INDEX_UNSET;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.futo.platformplayer.sabr.UriUtil;
|
||||
|
||||
/**
|
||||
* Defines a range of data located at a reference uri.
|
||||
*/
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public final class RangedUri {
|
||||
|
||||
/**
|
||||
* The (zero based) index of the first byte of the range.
|
||||
*/
|
||||
public final long start;
|
||||
|
||||
/**
|
||||
* The length of the range, or {@link C#LENGTH_UNSET} to indicate that the range is unbounded.
|
||||
*/
|
||||
public final long length;
|
||||
|
||||
private final String referenceUri;
|
||||
|
||||
private int hashCode;
|
||||
|
||||
/**
|
||||
* Constructs an ranged uri.
|
||||
*
|
||||
* @param referenceUri The reference uri.
|
||||
* @param start The (zero based) index of the first byte of the range.
|
||||
* @param length The length of the range, or {@link C#LENGTH_UNSET} to indicate that the range is
|
||||
* unbounded.
|
||||
*/
|
||||
public RangedUri(@Nullable String referenceUri, long start, long length) {
|
||||
this.referenceUri = referenceUri == null ? "" : referenceUri;
|
||||
this.start = start;
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved {@link Uri} represented by the instance.
|
||||
*
|
||||
* @param baseUri The base Uri.
|
||||
* @return The {@link Uri} represented by the instance.
|
||||
*/
|
||||
public Uri resolveUri(String baseUri) {
|
||||
return UriUtil.resolveToUri(baseUri, referenceUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved uri represented by the instance as a string.
|
||||
*
|
||||
* @param baseUri The base Uri.
|
||||
* @return The uri represented by the instance.
|
||||
*/
|
||||
public String resolveUriString(String baseUri) {
|
||||
return UriUtil.resolve(baseUri, referenceUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to merge this {@link RangedUri} with another and an optional common base uri.
|
||||
*
|
||||
* <p>A merge is successful if both instances define the same {@link Uri} after resolution with
|
||||
* the base uri, and if one starts the byte after the other ends, forming a contiguous region with
|
||||
* no overlap.
|
||||
*
|
||||
* <p>If {@code other} is null then the merge is considered unsuccessful, and null is returned.
|
||||
*
|
||||
* @param other The {@link RangedUri} to merge.
|
||||
* @param baseUri The optional base Uri.
|
||||
* @return The merged {@link RangedUri} if the merge was successful. Null otherwise.
|
||||
*/
|
||||
public @Nullable RangedUri attemptMerge(@Nullable RangedUri other, String baseUri) {
|
||||
final String resolvedUri = resolveUriString(baseUri);
|
||||
if (other == null || !resolvedUri.equals(other.resolveUriString(baseUri))) {
|
||||
return null;
|
||||
} else if (length != C.LENGTH_UNSET && start + length == other.start) {
|
||||
return new RangedUri(resolvedUri, start,
|
||||
other.length == C.LENGTH_UNSET ? C.LENGTH_UNSET : length + other.length);
|
||||
} else if (other.length != C.LENGTH_UNSET && other.start + other.length == start) {
|
||||
return new RangedUri(resolvedUri, other.start,
|
||||
length == C.LENGTH_UNSET ? C.LENGTH_UNSET : other.length + length);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
if (hashCode == 0) {
|
||||
int result = 17;
|
||||
result = 31 * result + (int) start;
|
||||
result = 31 * result + (int) length;
|
||||
result = 31 * result + referenceUri.hashCode();
|
||||
hashCode = result;
|
||||
}
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (obj == null || getClass() != obj.getClass()) {
|
||||
return false;
|
||||
}
|
||||
RangedUri other = (RangedUri) obj;
|
||||
return this.start == other.start
|
||||
&& this.length == other.length
|
||||
&& referenceUri.equals(other.referenceUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RangedUri("
|
||||
+ "referenceUri="
|
||||
+ referenceUri
|
||||
+ ", start="
|
||||
+ start
|
||||
+ ", length="
|
||||
+ length
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import com.futo.platformplayer.sabr.SabrSegmentIndex;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.MultiSegmentBase;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SingleSegmentBase;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A SABR representation.
|
||||
*/
|
||||
public abstract class Representation {
|
||||
/**
|
||||
* A default value for {@link #revisionId}.
|
||||
*/
|
||||
public static final long REVISION_ID_DEFAULT = -1;
|
||||
|
||||
/**
|
||||
* Identifies the revision of the media contained within the representation. If the media can
|
||||
* change over time (e.g. as a result of it being re-encoded), then this identifier can be set to
|
||||
* uniquely identify the revision of the media. The timestamp at which the media was encoded is
|
||||
* often a suitable.
|
||||
*/
|
||||
public final long revisionId;
|
||||
/**
|
||||
* The format of the representation.
|
||||
*/
|
||||
public final Format format;
|
||||
/**
|
||||
* The base URL of the representation.
|
||||
*/
|
||||
public final String baseUrl;
|
||||
/**
|
||||
* The offset of the presentation timestamps in the media stream relative to media time.
|
||||
*/
|
||||
public final long presentationTimeOffsetUs;
|
||||
|
||||
private final RangedUri initializationUri;
|
||||
|
||||
public static Representation newInstance(
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SegmentBase segmentBase) {
|
||||
return newInstance(REVISION_ID_DEFAULT, format, baseUrl, segmentBase, null);
|
||||
}
|
||||
|
||||
public static Representation newInstance(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SegmentBase segmentBase) {
|
||||
return newInstance(revisionId, format, baseUrl, segmentBase, null);
|
||||
}
|
||||
|
||||
public static Representation newInstance(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SegmentBase segmentBase,
|
||||
String cacheKey) {
|
||||
if (segmentBase instanceof SingleSegmentBase) {
|
||||
return new SingleSegmentRepresentation(
|
||||
revisionId,
|
||||
format,
|
||||
baseUrl,
|
||||
(SingleSegmentBase) segmentBase,
|
||||
cacheKey,
|
||||
C.LENGTH_UNSET);
|
||||
} else if (segmentBase instanceof MultiSegmentBase) {
|
||||
return new MultiSegmentRepresentation(
|
||||
revisionId, format, baseUrl, (MultiSegmentBase) segmentBase);
|
||||
} else {
|
||||
throw new IllegalArgumentException("segmentBase must be of type SingleSegmentBase or "
|
||||
+ "MultiSegmentBase");
|
||||
}
|
||||
}
|
||||
|
||||
private Representation(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SegmentBase segmentBase) {
|
||||
this.revisionId = revisionId;
|
||||
this.format = format;
|
||||
this.baseUrl = baseUrl;
|
||||
initializationUri = segmentBase.getInitialization(this);
|
||||
presentationTimeOffsetUs = segmentBase.getPresentationTimeOffsetUs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link RangedUri} defining the location of the representation's initialization data,
|
||||
* or null if no initialization data exists.
|
||||
*/
|
||||
public RangedUri getInitializationUri() {
|
||||
return initializationUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link RangedUri} defining the location of the representation's segment index, or
|
||||
* null if the representation provides an index directly.
|
||||
*/
|
||||
public abstract RangedUri getIndexUri();
|
||||
|
||||
/**
|
||||
* Returns an index if the representation provides one directly, or null otherwise.
|
||||
*/
|
||||
public abstract SabrSegmentIndex getIndex();
|
||||
|
||||
/** Returns a cache key for the representation if set, or null. */
|
||||
public abstract String getCacheKey();
|
||||
|
||||
/**
|
||||
* A DASH representation consisting of a single segment.
|
||||
*/
|
||||
public static class SingleSegmentRepresentation extends Representation {
|
||||
|
||||
/**
|
||||
* The uri of the single segment.
|
||||
*/
|
||||
public final Uri uri;
|
||||
|
||||
/**
|
||||
* The content length, or {@link C#LENGTH_UNSET} if unknown.
|
||||
*/
|
||||
public final long contentLength;
|
||||
|
||||
private final String cacheKey;
|
||||
private final RangedUri indexUri;
|
||||
private final SingleSegmentIndex segmentIndex;
|
||||
|
||||
public static SingleSegmentRepresentation newInstance(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String uri,
|
||||
long initializationStart,
|
||||
long initializationEnd,
|
||||
long indexStart,
|
||||
long indexEnd,
|
||||
String cacheKey,
|
||||
long contentLength) {
|
||||
RangedUri rangedUri = new RangedUri(null, initializationStart,
|
||||
initializationEnd - initializationStart + 1);
|
||||
SingleSegmentBase segmentBase = new SingleSegmentBase(rangedUri, 1, 0, indexStart,
|
||||
indexEnd - indexStart + 1);
|
||||
return new SingleSegmentRepresentation(
|
||||
revisionId, format, uri, segmentBase, cacheKey, contentLength);
|
||||
}
|
||||
|
||||
public SingleSegmentRepresentation(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
SingleSegmentBase segmentBase,
|
||||
String cacheKey,
|
||||
long contentLength) {
|
||||
super(revisionId, format, baseUrl, segmentBase);
|
||||
this.uri = Uri.parse(baseUrl);
|
||||
this.indexUri = segmentBase.getIndex();
|
||||
this.cacheKey = cacheKey;
|
||||
this.contentLength = contentLength;
|
||||
// If we have an index uri then the index is defined externally, and we shouldn't return one
|
||||
// directly. If we don't, then we can't do better than an index defining a single segment.
|
||||
segmentIndex = indexUri != null ? null
|
||||
: new SingleSegmentIndex(new RangedUri(null, 0, contentLength));
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getIndexUri() {
|
||||
return indexUri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SabrSegmentIndex getIndex() {
|
||||
return segmentIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCacheKey() {
|
||||
return cacheKey;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A DASH representation consisting of multiple segments.
|
||||
*/
|
||||
public static class MultiSegmentRepresentation extends Representation
|
||||
implements SabrSegmentIndex {
|
||||
|
||||
private final MultiSegmentBase segmentBase;
|
||||
|
||||
/**
|
||||
* @param revisionId Identifies the revision of the content.
|
||||
* @param format The format of the representation.
|
||||
* @param baseUrl The base URL of the representation.
|
||||
* @param segmentBase The segment base underlying the representation.
|
||||
*/
|
||||
public MultiSegmentRepresentation(
|
||||
long revisionId,
|
||||
Format format,
|
||||
String baseUrl,
|
||||
MultiSegmentBase segmentBase) {
|
||||
super(revisionId, format, baseUrl, segmentBase);
|
||||
this.segmentBase = segmentBase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getIndexUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SabrSegmentIndex getIndex() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCacheKey() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// DashSegmentIndex implementation.
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(long segmentIndex) {
|
||||
return segmentBase.getSegmentUrl(this, segmentIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSegmentNum(long timeUs, long periodDurationUs) {
|
||||
return segmentBase.getSegmentNum(timeUs, periodDurationUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimeUs(long segmentIndex) {
|
||||
return segmentBase.getSegmentTimeUs(segmentIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationUs(long segmentIndex, long periodDurationUs) {
|
||||
return segmentBase.getSegmentDurationUs(segmentIndex, periodDurationUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFirstSegmentNum() {
|
||||
return segmentBase.getFirstSegmentNum();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
return segmentBase.getSegmentCount(periodDurationUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExplicit() {
|
||||
return segmentBase.isExplicit();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.exoplayer.offline.FilterableManifest;
|
||||
import androidx.media3.common.StreamKey;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents a SABR media presentation
|
||||
*/
|
||||
@UnstableApi
|
||||
public class SabrManifest implements FilterableManifest<SabrManifest> {
|
||||
/**
|
||||
* The {@code availabilityStartTime} value in milliseconds since epoch, or {@link C#TIME_UNSET} if
|
||||
* not present.
|
||||
*/
|
||||
public final long availabilityStartTimeMs;
|
||||
|
||||
/**
|
||||
* The duration of the presentation in milliseconds, or {@link C#TIME_UNSET} if not applicable.
|
||||
*/
|
||||
public final long durationMs;
|
||||
|
||||
/**
|
||||
* The {@code minBufferTime} value in milliseconds, or {@link C#TIME_UNSET} if not present.
|
||||
*/
|
||||
public final long minBufferTimeMs;
|
||||
|
||||
/**
|
||||
* The {@code timeShiftBufferDepth} value in milliseconds, or {@link C#TIME_UNSET} if not
|
||||
* present.
|
||||
*/
|
||||
public final long timeShiftBufferDepthMs;
|
||||
|
||||
/**
|
||||
* The {@code suggestedPresentationDelay} value in milliseconds, or {@link C#TIME_UNSET} if not
|
||||
* present.
|
||||
*/
|
||||
public final long suggestedPresentationDelayMs;
|
||||
|
||||
/**
|
||||
* The {@code publishTime} value in milliseconds since epoch, or {@link C#TIME_UNSET} if
|
||||
* not present.
|
||||
*/
|
||||
public final long publishTimeMs;
|
||||
|
||||
public final List<Period> periods;
|
||||
|
||||
/**
|
||||
* Whether the manifest has value "dynamic" for the {@code type} attribute.
|
||||
*/
|
||||
public final boolean dynamic;
|
||||
|
||||
/**
|
||||
* The {@code minimumUpdatePeriod} value in milliseconds, or {@link C#TIME_UNSET} if not
|
||||
* applicable.
|
||||
*/
|
||||
public final long minUpdatePeriodMs;
|
||||
|
||||
public SabrManifest(
|
||||
long availabilityStartTimeMs,
|
||||
long durationMs,
|
||||
long minBufferTimeMs,
|
||||
boolean dynamic,
|
||||
long minUpdatePeriodMs,
|
||||
long timeShiftBufferDepthMs,
|
||||
long suggestedPresentationDelayMs,
|
||||
long publishTimeMs,
|
||||
List<Period> periods) {
|
||||
this.availabilityStartTimeMs = availabilityStartTimeMs;
|
||||
this.durationMs = durationMs;
|
||||
this.minBufferTimeMs = minBufferTimeMs;
|
||||
this.dynamic = dynamic;
|
||||
this.minUpdatePeriodMs = minUpdatePeriodMs;
|
||||
this.timeShiftBufferDepthMs = timeShiftBufferDepthMs;
|
||||
this.suggestedPresentationDelayMs = suggestedPresentationDelayMs;
|
||||
this.publishTimeMs = publishTimeMs;
|
||||
this.periods = periods;
|
||||
}
|
||||
|
||||
public final int getPeriodCount() {
|
||||
return periods.size();
|
||||
}
|
||||
|
||||
public final Period getPeriod(int index) {
|
||||
return periods.get(index);
|
||||
}
|
||||
|
||||
public final long getPeriodDurationMs(int index) {
|
||||
return index == periods.size() - 1
|
||||
? (durationMs == C.TIME_UNSET ? C.TIME_UNSET : (durationMs - periods.get(index).startMs))
|
||||
: (periods.get(index + 1).startMs - periods.get(index).startMs);
|
||||
}
|
||||
|
||||
public final long getPeriodDurationUs(int index) {
|
||||
return C.msToUs(getPeriodDurationMs(index));
|
||||
}
|
||||
|
||||
@Override
|
||||
public SabrManifest copy(List<StreamKey> streamKeys) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.DrmInitData;
|
||||
import androidx.media3.common.DrmInitData.SchemeData;
|
||||
|
||||
import com.futo.platformplayer.sabr.ITagUtils;
|
||||
import com.futo.platformplayer.sabr.MediaFormat;
|
||||
import com.futo.platformplayer.sabr.MediaFormatComparator;
|
||||
import com.futo.platformplayer.sabr.MediaFormatUtils;
|
||||
import com.futo.platformplayer.sabr.MediaItemFormatInfo;
|
||||
import com.futo.platformplayer.sabr.MediaSubtitle;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SegmentList;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SegmentTemplate;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SegmentTimelineElement;
|
||||
import com.futo.platformplayer.sabr.manifest.SegmentBase.SingleSegmentBase;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
@UnstableApi
|
||||
public class SabrManifestParser {
|
||||
private static final String TAG = SabrManifestParser.class.getSimpleName();
|
||||
private int mId;
|
||||
private static final String NULL_INDEX_RANGE = "0-0";
|
||||
private static final String NULL_CONTENT_LENGTH = "0";
|
||||
private static final int MAX_DURATION_SEC = 48 * 60 * 60;
|
||||
private MediaItemFormatInfo mFormatInfo;
|
||||
private Set<MediaFormat> mMP4Videos;
|
||||
private Set<MediaFormat> mWEBMVideos;
|
||||
private Map<String, Set<MediaFormat>> mMP4Audios;
|
||||
private Map<String, Set<MediaFormat>> mWEBMAudios;
|
||||
private List<MediaSubtitle> mSubs;
|
||||
|
||||
public SabrManifest parse(@NonNull MediaItemFormatInfo formatInfo) {
|
||||
mFormatInfo = formatInfo;
|
||||
MediaFormatComparator comp = new MediaFormatComparator();
|
||||
mMP4Videos = new TreeSet<>(comp);
|
||||
mWEBMVideos = new TreeSet<>(comp);
|
||||
mMP4Audios = new HashMap<>();
|
||||
mWEBMAudios = new HashMap<>();
|
||||
mSubs = new ArrayList<>();
|
||||
return parseSabrManifest(formatInfo);
|
||||
}
|
||||
|
||||
public static boolean isInteger(String s) {
|
||||
return s != null && s.matches("^[-+]?\\d+$");
|
||||
}
|
||||
|
||||
public static boolean isNumeric(String s) {
|
||||
return s != null && s.matches("^[-+]?\\d*\\.?\\d+$");
|
||||
}
|
||||
|
||||
public static int parseInt(String numString) {
|
||||
return parseInt(numString, -1);
|
||||
}
|
||||
|
||||
public static int parseInt(String numString, int defaultValue) {
|
||||
if (!isInteger(numString)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return Integer.parseInt(numString);
|
||||
}
|
||||
|
||||
public static long parseLong(String numString) {
|
||||
return parseLong(numString, -1);
|
||||
}
|
||||
|
||||
public static long parseLong(String numString, long defaultValue) {
|
||||
if (!isInteger(numString)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return Long.parseLong(numString);
|
||||
}
|
||||
|
||||
public static float parseFloat(String numString) {
|
||||
return parseFloat(numString, -1);
|
||||
}
|
||||
|
||||
public static float parseFloat(String numString, float defaultValue) {
|
||||
if (!isNumeric(numString)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return Float.parseFloat(numString);
|
||||
}
|
||||
|
||||
private SabrManifest parseSabrManifest(MediaItemFormatInfo formatInfo) {
|
||||
long availabilityStartTime = C.TIME_UNSET;
|
||||
long durationMs = getDurationMs(formatInfo);
|
||||
long minBufferTimeMs = 1500; // "PT1.500S"
|
||||
long timeShiftBufferDepthMs = C.TIME_UNSET;
|
||||
long suggestedPresentationDelayMs = C.TIME_UNSET;
|
||||
long publishTimeMs = C.TIME_UNSET;
|
||||
boolean dynamic = false;
|
||||
long minUpdateTimeMs = C.TIME_UNSET; // 3155690800000L, "P100Y" no refresh (there is no dash url)
|
||||
|
||||
List<Period> periods = new ArrayList<>();
|
||||
|
||||
Pair<Period, Long> periodWithDurationMs = parsePeriod(formatInfo);
|
||||
if (periodWithDurationMs != null) {
|
||||
Period period = periodWithDurationMs.first;
|
||||
periods.add(period);
|
||||
}
|
||||
|
||||
return new SabrManifest(
|
||||
availabilityStartTime,
|
||||
durationMs,
|
||||
minBufferTimeMs,
|
||||
dynamic,
|
||||
minUpdateTimeMs,
|
||||
timeShiftBufferDepthMs,
|
||||
suggestedPresentationDelayMs,
|
||||
publishTimeMs,
|
||||
periods);
|
||||
}
|
||||
|
||||
private static long getDurationMs(MediaItemFormatInfo formatInfo) {
|
||||
long lenSeconds = parseLong(formatInfo.getLengthSeconds());
|
||||
return lenSeconds > 0 ? lenSeconds * 1_000 : C.TIME_UNSET;
|
||||
}
|
||||
|
||||
private Pair<Period, Long> parsePeriod(MediaItemFormatInfo formatInfo) {
|
||||
String id = formatInfo.getVideoId();
|
||||
long startMs = 0; // Should add real start time or make it unset?
|
||||
long durationMs = getDurationMs(formatInfo);
|
||||
List<AdaptationSet> adaptationSets = new ArrayList<>();
|
||||
|
||||
for (MediaFormat format : formatInfo.getAdaptiveFormats()) {
|
||||
append(format);
|
||||
}
|
||||
|
||||
if (formatInfo.getSubtitles() != null) {
|
||||
append(formatInfo.getSubtitles());
|
||||
}
|
||||
|
||||
// MXPlayer fix: write high quality formats first
|
||||
if (!mMP4Videos.isEmpty()) {
|
||||
adaptationSets.add(parseAdaptationSet(mMP4Videos));
|
||||
}
|
||||
if (!mWEBMVideos.isEmpty()) {
|
||||
adaptationSets.add(parseAdaptationSet(mWEBMVideos));
|
||||
}
|
||||
|
||||
for (Set<MediaFormat> formats : mMP4Audios.values()) {
|
||||
adaptationSets.add(parseAdaptationSet(formats));
|
||||
}
|
||||
|
||||
for (Set<MediaFormat> formats : mWEBMAudios.values()) {
|
||||
adaptationSets.add(parseAdaptationSet(formats));
|
||||
}
|
||||
|
||||
for (MediaSubtitle subtitle : mSubs) {
|
||||
adaptationSets.add(parseAdaptationSet(Collections.singletonList(subtitle)));
|
||||
}
|
||||
|
||||
return Pair.create(new Period(id, startMs, adaptationSets), durationMs);
|
||||
}
|
||||
|
||||
private AdaptationSet parseAdaptationSet(Set<MediaFormat> formats) {
|
||||
int id = mId++;
|
||||
int contentType = C.TRACK_TYPE_UNKNOWN;
|
||||
String label = null;
|
||||
String drmSchemeType = null;
|
||||
ArrayList<SchemeData> drmSchemeDatas = new ArrayList<>();
|
||||
List<RepresentationInfo> representationInfos = new ArrayList<>();
|
||||
|
||||
for (MediaFormat format : formats) {
|
||||
RepresentationInfo representationInfo = parseRepresentation(format);
|
||||
if (contentType == C.TRACK_TYPE_UNKNOWN) {
|
||||
contentType = getContentType(representationInfo.format);
|
||||
}
|
||||
representationInfos.add(representationInfo);
|
||||
}
|
||||
|
||||
// Build the representations.
|
||||
List<Representation> representations = new ArrayList<>(representationInfos.size());
|
||||
for (int i = 0; i < representationInfos.size(); i++) {
|
||||
representations.add(
|
||||
buildRepresentation(
|
||||
representationInfos.get(i),
|
||||
label,
|
||||
drmSchemeType,
|
||||
drmSchemeDatas));
|
||||
}
|
||||
|
||||
return new AdaptationSet(id, contentType, representations);
|
||||
}
|
||||
|
||||
private AdaptationSet parseAdaptationSet(List<MediaSubtitle> formats) {
|
||||
int id = mId++;
|
||||
int contentType = C.TRACK_TYPE_UNKNOWN;
|
||||
String label = null;
|
||||
String drmSchemeType = null;
|
||||
ArrayList<SchemeData> drmSchemeDatas = new ArrayList<>();
|
||||
List<RepresentationInfo> representationInfos = new ArrayList<>();
|
||||
|
||||
for (MediaSubtitle format : formats) {
|
||||
RepresentationInfo representationInfo = parseRepresentation(format);
|
||||
if (contentType == C.TRACK_TYPE_UNKNOWN) {
|
||||
contentType = getContentType(representationInfo.format);
|
||||
}
|
||||
representationInfos.add(representationInfo);
|
||||
}
|
||||
|
||||
// Build the representations.
|
||||
List<Representation> representations = new ArrayList<>(representationInfos.size());
|
||||
for (int i = 0; i < representationInfos.size(); i++) {
|
||||
representations.add(
|
||||
buildRepresentation(
|
||||
representationInfos.get(i),
|
||||
label,
|
||||
drmSchemeType,
|
||||
drmSchemeDatas));
|
||||
}
|
||||
|
||||
return new AdaptationSet(id, contentType, representations);
|
||||
}
|
||||
|
||||
private SegmentTemplate parseSegmentTemplate(MediaFormat format) {
|
||||
int unitsPerSecond = 1_000_000;
|
||||
|
||||
// Present on live streams only.
|
||||
int segmentDurationUs = mFormatInfo.getSegmentDurationUs();
|
||||
|
||||
if (segmentDurationUs <= 0) {
|
||||
// Inaccurate. Present on past (!) live streams.
|
||||
segmentDurationUs = Integer.parseInt(format.getTargetDurationSec()) * 1_000_000;
|
||||
}
|
||||
|
||||
int lengthSeconds = Integer.parseInt(mFormatInfo.getLengthSeconds());
|
||||
|
||||
if (mFormatInfo.isLive() || lengthSeconds <= 0) {
|
||||
// For premiere streams (length > 0) or regular streams (length == 0) set window that exceeds normal limits - 48hrs
|
||||
lengthSeconds = MAX_DURATION_SEC;
|
||||
}
|
||||
|
||||
// To make long streams (12hrs) seekable we should decrease size of the segment a bit
|
||||
//long segmentDurationUnits = (long) targetDurationSec * unitsPerSecond * 9999 / 10000;
|
||||
int segmentDurationUnits = (int)(segmentDurationUs * (long) unitsPerSecond / 1_000_000);
|
||||
// Increase count a bit to compensate previous tweak
|
||||
//long segmentCount = (long) lengthSeconds / targetDurationSec * 10000 / 9999;
|
||||
//int segmentCount = (int)(lengthSeconds * (long) unitsPerSecond / segmentDurationUnits);
|
||||
int segmentCount = (int) Math.ceil(lengthSeconds * (double) unitsPerSecond / segmentDurationUnits);
|
||||
// Increase offset a bit to compensate previous tweaks
|
||||
// Streams to check:
|
||||
// https://www.youtube.com/watch?v=drdemkJpgao
|
||||
long offsetUnits = (long) segmentDurationUnits * mFormatInfo.getStartSegmentNum();
|
||||
|
||||
long timescale = unitsPerSecond;
|
||||
long presentationTimeOffset = offsetUnits;
|
||||
long duration = segmentDurationUnits;
|
||||
long startNumber = mFormatInfo.getStartSegmentNum();
|
||||
long endNumber = C.INDEX_UNSET;
|
||||
UrlTemplate mediaTemplate = UrlTemplate.compile(format.getUrl() + "&sq=$Number$");
|
||||
//UrlTemplate initializationTemplate = UrlTemplate.compile(format.getOtfInitUrl()); // ?
|
||||
UrlTemplate initializationTemplate = null; // ?
|
||||
|
||||
RangedUri initialization = parseRangedUrl(format.getSourceUrl(), format.getInit());
|
||||
|
||||
List<SegmentTimelineElement> timeline = parseSegmentTimeline(offsetUnits, segmentDurationUnits, segmentCount);
|
||||
|
||||
return new SegmentTemplate(
|
||||
initialization,
|
||||
timescale,
|
||||
presentationTimeOffset,
|
||||
startNumber,
|
||||
endNumber,
|
||||
duration,
|
||||
timeline,
|
||||
initializationTemplate,
|
||||
mediaTemplate);
|
||||
}
|
||||
|
||||
private SegmentList parseSegmentList(MediaFormat format) {
|
||||
long timescale = 1;
|
||||
long presentationTimeOffset = 0;
|
||||
long duration = C.TIME_UNSET;
|
||||
long startNumber = 1;
|
||||
|
||||
RangedUri initialization = parseRangedUrl(format.getSourceUrl(), format.getInit());
|
||||
|
||||
List<SegmentTimelineElement> timeline = parseSegmentTimeline(format);
|
||||
|
||||
List<RangedUri> segments = parseSegmentUrl(format);
|
||||
|
||||
return new SegmentList(initialization, timescale, presentationTimeOffset,
|
||||
startNumber, duration, timeline, segments);
|
||||
}
|
||||
|
||||
private RangedUri parseRangedUrl(String urlText, String rangeText) {
|
||||
long rangeStart = 0;
|
||||
long rangeLength = C.LENGTH_UNSET;
|
||||
if (rangeText != null) {
|
||||
String[] rangeTextArray = rangeText.split("-");
|
||||
rangeStart = Long.parseLong(rangeTextArray[0]);
|
||||
if (rangeTextArray.length == 2) {
|
||||
rangeLength = Long.parseLong(rangeTextArray[1]) - rangeStart + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return new RangedUri(urlText, rangeStart, rangeLength);
|
||||
}
|
||||
|
||||
private List<SegmentTimelineElement> parseSegmentTimeline(MediaFormat format) {
|
||||
List<SegmentTimelineElement> timeline = new ArrayList<>();
|
||||
|
||||
if (format.getGlobalSegmentList() == null) {
|
||||
return timeline;
|
||||
}
|
||||
|
||||
// From writeGlobalSegmentList
|
||||
long elapsedTime = 0;
|
||||
|
||||
// SegmentURL tag
|
||||
for (String segment : format.getGlobalSegmentList()) {
|
||||
long duration = parseLong(segment, C.TIME_UNSET);
|
||||
int count = 1;
|
||||
for (int i = 0; i < count; i++) {
|
||||
timeline.add(new SegmentTimelineElement(elapsedTime, duration));
|
||||
elapsedTime += duration;
|
||||
}
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private List<SegmentTimelineElement> parseSegmentTimeline(long elapsedTime, long duration, int segmentCount) {
|
||||
List<SegmentTimelineElement> timeline = new ArrayList<>();
|
||||
|
||||
// From writeLiveMediaSegmentList
|
||||
int count = 1 + segmentCount;
|
||||
for (int i = 0; i < count; i++) {
|
||||
timeline.add(new SegmentTimelineElement(elapsedTime, duration));
|
||||
elapsedTime += duration;
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private List<RangedUri> parseSegmentUrl(MediaFormat format) {
|
||||
List<RangedUri> segments = new ArrayList<>();
|
||||
|
||||
if (format.getSegmentUrlList() == null) {
|
||||
return segments;
|
||||
}
|
||||
|
||||
// SegmentURL tag
|
||||
for (String url : format.getSegmentUrlList()) {
|
||||
segments.add(parseRangedUrl(url, null));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private SingleSegmentBase parseSegmentBase(MediaFormat format) {
|
||||
long timescale = 1000;
|
||||
long presentationTimeOffset = 0;
|
||||
|
||||
long indexStart = 0;
|
||||
long indexLength = 0;
|
||||
String indexRangeText = format.getIndex();
|
||||
if (indexRangeText != null) {
|
||||
String[] indexRange = indexRangeText.split("-");
|
||||
indexStart = Long.parseLong(indexRange[0]);
|
||||
indexLength = Long.parseLong(indexRange[1]) - indexStart + 1;
|
||||
}
|
||||
|
||||
RangedUri initialization = parseRangedUrl(format.getSourceUrl(), format.getInit());
|
||||
|
||||
|
||||
return new SingleSegmentBase(initialization, timescale, presentationTimeOffset, indexStart,
|
||||
indexLength);
|
||||
}
|
||||
|
||||
private RepresentationInfo parseRepresentation(MediaFormat mediaFormat) {
|
||||
int roleFlags = C.ROLE_FLAG_MAIN;
|
||||
int selectionFlags = C.SELECTION_FLAG_DEFAULT;
|
||||
String id = mediaFormat.isDrc() ? mediaFormat.getITag() + "-drc" : mediaFormat.getITag();
|
||||
int bandwidth = parseInt(mediaFormat.getBitrate(), Format.NO_VALUE);
|
||||
String mimeType = MediaFormatUtils.extractMimeType(mediaFormat);
|
||||
String codecs = MediaFormatUtils.extractCodecs(mediaFormat);
|
||||
int width = mediaFormat.getWidth();
|
||||
int height = mediaFormat.getHeight();
|
||||
float frameRate = parseFloat(mediaFormat.getFps(), Format.NO_VALUE);
|
||||
int audioChannels = Format.NO_VALUE;
|
||||
int audioSamplingRate = parseInt(ITagUtils.getAudioRateByTag(mediaFormat.getITag()), Format.NO_VALUE);
|
||||
String language = mediaFormat.getLanguage();
|
||||
String baseUrl = mediaFormat.getUrl();
|
||||
String label = null;
|
||||
String drmSchemeType = null;
|
||||
ArrayList<SchemeData> drmSchemeDatas = new ArrayList<>();
|
||||
|
||||
Format format =
|
||||
buildFormat(
|
||||
id,
|
||||
mimeType,
|
||||
width,
|
||||
height,
|
||||
frameRate,
|
||||
audioChannels,
|
||||
audioSamplingRate,
|
||||
bandwidth,
|
||||
language,
|
||||
roleFlags,
|
||||
selectionFlags,
|
||||
codecs);
|
||||
|
||||
SegmentBase segmentBase = null;
|
||||
|
||||
if (MediaFormatUtils.isLiveMedia(mediaFormat)) {
|
||||
segmentBase = parseSegmentTemplate(mediaFormat);
|
||||
} else if (mediaFormat.getSegmentUrlList() != null) {
|
||||
segmentBase = parseSegmentList(mediaFormat);
|
||||
} else if (mediaFormat.getIndex() != null &&
|
||||
!mediaFormat.getIndex().equals(NULL_INDEX_RANGE)) { // json mediaFormat fix: index is null
|
||||
segmentBase = parseSegmentBase(mediaFormat);
|
||||
}
|
||||
|
||||
segmentBase = segmentBase != null ? segmentBase : new SingleSegmentBase();
|
||||
|
||||
return new RepresentationInfo(format, baseUrl, segmentBase, drmSchemeType, drmSchemeDatas, Representation.REVISION_ID_DEFAULT);
|
||||
}
|
||||
|
||||
private RepresentationInfo parseRepresentation(MediaSubtitle sub) {
|
||||
int roleFlags = C.ROLE_FLAG_SUBTITLE;
|
||||
int selectionFlags = 0;
|
||||
String id = String.valueOf(mId++);
|
||||
int bandwidth = 268;
|
||||
String mimeType = sub.getMimeType();
|
||||
String codecs = sub.getCodecs();
|
||||
int width = Format.NO_VALUE;
|
||||
int height = Format.NO_VALUE;
|
||||
float frameRate = Format.NO_VALUE;
|
||||
int audioChannels = Format.NO_VALUE;
|
||||
int audioSamplingRate = Format.NO_VALUE;
|
||||
String language = sub.getName() == null ? sub.getLanguageCode() : sub.getName();
|
||||
String baseUrl = sub.getBaseUrl();
|
||||
String label = null;
|
||||
String drmSchemeType = null;
|
||||
ArrayList<SchemeData> drmSchemeDatas = new ArrayList<>();
|
||||
|
||||
Format format =
|
||||
buildFormat(
|
||||
id,
|
||||
mimeType,
|
||||
width,
|
||||
height,
|
||||
frameRate,
|
||||
audioChannels,
|
||||
audioSamplingRate,
|
||||
bandwidth,
|
||||
language,
|
||||
roleFlags,
|
||||
selectionFlags,
|
||||
codecs);
|
||||
|
||||
SegmentBase segmentBase = new SingleSegmentBase();
|
||||
|
||||
return new RepresentationInfo(format, baseUrl, segmentBase, drmSchemeType, drmSchemeDatas, Representation.REVISION_ID_DEFAULT);
|
||||
}
|
||||
|
||||
protected Representation buildRepresentation(
|
||||
RepresentationInfo representationInfo,
|
||||
@Nullable String label,
|
||||
@Nullable String extraDrmSchemeType,
|
||||
ArrayList<SchemeData> extraDrmSchemeDatas) {
|
||||
|
||||
// Start from the existing format
|
||||
Format.Builder formatBuilder = representationInfo.format.buildUpon();
|
||||
|
||||
// copyWithLabel(label)
|
||||
if (label != null) {
|
||||
formatBuilder.setLabel(label);
|
||||
}
|
||||
|
||||
// Decide scheme type: representationInfo.drmSchemeType wins over extraDrmSchemeType
|
||||
String drmSchemeType =
|
||||
representationInfo.drmSchemeType != null
|
||||
? representationInfo.drmSchemeType
|
||||
: extraDrmSchemeType;
|
||||
|
||||
// Accumulate DRM scheme datas (same as your old code)
|
||||
ArrayList<SchemeData> drmSchemeDatas = representationInfo.drmSchemeDatas;
|
||||
if (extraDrmSchemeDatas != null && !extraDrmSchemeDatas.isEmpty()) {
|
||||
drmSchemeDatas.addAll(extraDrmSchemeDatas);
|
||||
}
|
||||
|
||||
if (!drmSchemeDatas.isEmpty()) {
|
||||
filterRedundantIncompleteSchemeDatas(drmSchemeDatas);
|
||||
|
||||
DrmInitData drmInitData = new DrmInitData(drmSchemeType, drmSchemeDatas);
|
||||
|
||||
// copyWithDrmInitData(drmInitData)
|
||||
formatBuilder.setDrmInitData(drmInitData);
|
||||
}
|
||||
|
||||
Format format = formatBuilder.build();
|
||||
|
||||
// Representation.newInstance(...) still exists with this signature in Media3.:contentReference[oaicite:1]{index=1}
|
||||
return Representation.newInstance(
|
||||
representationInfo.revisionId,
|
||||
format,
|
||||
representationInfo.baseUrl,
|
||||
representationInfo.segmentBase);
|
||||
}
|
||||
|
||||
|
||||
protected Format buildFormat(
|
||||
String id,
|
||||
String containerMimeType,
|
||||
int width,
|
||||
int height,
|
||||
float frameRate,
|
||||
int audioChannels,
|
||||
int audioSamplingRate,
|
||||
int bitrate,
|
||||
String language,
|
||||
@C.RoleFlags int roleFlags,
|
||||
@C.SelectionFlags int selectionFlags,
|
||||
String codecs) {
|
||||
|
||||
String sampleMimeType = getSampleMimeType(containerMimeType, codecs);
|
||||
|
||||
// Base builder: fields common to all track types
|
||||
Format.Builder builder = new Format.Builder()
|
||||
.setId(id)
|
||||
.setContainerMimeType(containerMimeType)
|
||||
.setSampleMimeType(sampleMimeType)
|
||||
.setCodecs(codecs)
|
||||
.setAverageBitrate(bitrate) // same semantics as old "bitrate" arg
|
||||
.setSelectionFlags(selectionFlags)
|
||||
.setRoleFlags(roleFlags)
|
||||
.setLanguage(language);
|
||||
|
||||
if (sampleMimeType != null) {
|
||||
if (MimeTypes.isVideo(sampleMimeType)) {
|
||||
// Replacement for createVideoContainerFormat(...)
|
||||
builder
|
||||
.setWidth(width)
|
||||
.setHeight(height)
|
||||
.setFrameRate(frameRate);
|
||||
|
||||
} else if (MimeTypes.isAudio(sampleMimeType)) {
|
||||
// Replacement for createAudioContainerFormat(...)
|
||||
builder
|
||||
.setChannelCount(audioChannels)
|
||||
.setSampleRate(audioSamplingRate);
|
||||
|
||||
} else if (mimeTypeIsRawText(sampleMimeType)) {
|
||||
// Replacement for createTextContainerFormat(...)
|
||||
// You passed Format.NO_VALUE for accessibilityChannel before,
|
||||
// which is already the default, but we can be explicit:
|
||||
builder.setAccessibilityChannel(Format.NO_VALUE);
|
||||
}
|
||||
}
|
||||
|
||||
// Replacement for createContainerFormat(...) when no specialized type matched
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a sample mimeType from a container mimeType and codecs attribute.
|
||||
*
|
||||
* @param containerMimeType The mimeType of the container.
|
||||
* @param codecs The codecs attribute.
|
||||
* @return The derived sample mimeType, or null if it could not be derived.
|
||||
*/
|
||||
private static String getSampleMimeType(String containerMimeType, String codecs) {
|
||||
if (MimeTypes.isAudio(containerMimeType)) {
|
||||
return MimeTypes.getAudioMediaMimeType(codecs);
|
||||
} else if (MimeTypes.isVideo(containerMimeType)) {
|
||||
return MimeTypes.getVideoMediaMimeType(codecs);
|
||||
} else if (mimeTypeIsRawText(containerMimeType)) {
|
||||
return containerMimeType;
|
||||
} else if (MimeTypes.APPLICATION_MP4.equals(containerMimeType)) {
|
||||
if (codecs != null) {
|
||||
if (codecs.startsWith("stpp")) {
|
||||
return MimeTypes.APPLICATION_TTML;
|
||||
} else if (codecs.startsWith("wvtt")) {
|
||||
return MimeTypes.APPLICATION_MP4VTT;
|
||||
}
|
||||
}
|
||||
} else if (MimeTypes.APPLICATION_RAWCC.equals(containerMimeType)) {
|
||||
if (codecs != null) {
|
||||
if (codecs.contains("cea708")) {
|
||||
return MimeTypes.APPLICATION_CEA708;
|
||||
} else if (codecs.contains("eia608") || codecs.contains("cea608")) {
|
||||
return MimeTypes.APPLICATION_CEA608;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a mimeType is a text sample mimeType.
|
||||
*
|
||||
* @param mimeType The mimeType.
|
||||
* @return Whether the mimeType is a text sample mimeType.
|
||||
*/
|
||||
private static boolean mimeTypeIsRawText(String mimeType) {
|
||||
return MimeTypes.isText(mimeType)
|
||||
|| MimeTypes.APPLICATION_TTML.equals(mimeType)
|
||||
|| MimeTypes.APPLICATION_MP4VTT.equals(mimeType)
|
||||
|| MimeTypes.APPLICATION_CEA708.equals(mimeType)
|
||||
|| MimeTypes.APPLICATION_CEA608.equals(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes unnecessary {@link SchemeData}s with null {@link SchemeData#data}.
|
||||
*/
|
||||
private static void filterRedundantIncompleteSchemeDatas(ArrayList<SchemeData> schemeDatas) {
|
||||
for (int i = schemeDatas.size() - 1; i >= 0; i--) {
|
||||
SchemeData schemeData = schemeDatas.get(i);
|
||||
if (!schemeData.hasData()) {
|
||||
for (int j = 0; j < schemeDatas.size(); j++) {
|
||||
if (schemeDatas.get(j).canReplace(schemeData)) {
|
||||
// schemeData is incomplete, but there is another matching SchemeData which does contain
|
||||
// data, so we remove the incomplete one.
|
||||
schemeDatas.remove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void append(List<MediaSubtitle> subs) {
|
||||
mSubs.addAll(subs);
|
||||
}
|
||||
|
||||
private void append(MediaSubtitle sub) {
|
||||
mSubs.add(sub);
|
||||
}
|
||||
|
||||
private void append(MediaFormat mediaItem) {
|
||||
if (!MediaFormatUtils.checkMediaUrl(mediaItem)) {
|
||||
Log.e(TAG, "Media item doesn't contain required url field!");
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE: FORMAT_STREAM_TYPE_OTF not supported
|
||||
if (!MediaFormatUtils.isDash(mediaItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
//fixOTF(mediaItem);
|
||||
|
||||
Set<MediaFormat> placeholder = null;
|
||||
String mimeType = MediaFormatUtils.extractMimeType(mediaItem);
|
||||
if (mimeType != null) {
|
||||
switch (mimeType) {
|
||||
case MediaFormatUtils.MIME_MP4_VIDEO:
|
||||
placeholder = mMP4Videos;
|
||||
break;
|
||||
case MediaFormatUtils.MIME_WEBM_VIDEO:
|
||||
placeholder = mWEBMVideos;
|
||||
break;
|
||||
case MediaFormatUtils.MIME_MP4_AUDIO:
|
||||
placeholder = getMP4Audios(mediaItem.getLanguage());
|
||||
break;
|
||||
case MediaFormatUtils.MIME_WEBM_AUDIO:
|
||||
placeholder = getWEBMAudios(mediaItem.getLanguage());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholder != null) {
|
||||
placeholder.add(mediaItem); // NOTE: reverse order
|
||||
}
|
||||
}
|
||||
|
||||
private Set<MediaFormat> getMP4Audios(String language) {
|
||||
return getFormats(mMP4Audios, language);
|
||||
}
|
||||
|
||||
private Set<MediaFormat> getWEBMAudios(String language) {
|
||||
return getFormats(mWEBMAudios, language);
|
||||
}
|
||||
|
||||
private static Set<MediaFormat> getFormats(Map<String, Set<MediaFormat>> formatMap, String language) {
|
||||
if (language == null) {
|
||||
language = "default";
|
||||
}
|
||||
|
||||
Set<MediaFormat> mediaFormats = formatMap.get(language);
|
||||
|
||||
if (mediaFormats == null) {
|
||||
mediaFormats = new TreeSet<>(new MediaFormatComparator());
|
||||
formatMap.put(language, mediaFormats);
|
||||
}
|
||||
|
||||
return mediaFormats;
|
||||
}
|
||||
|
||||
protected int getContentType(Format format) {
|
||||
String sampleMimeType = format.sampleMimeType;
|
||||
if (TextUtils.isEmpty(sampleMimeType)) {
|
||||
return C.TRACK_TYPE_UNKNOWN;
|
||||
} else if (MimeTypes.isVideo(sampleMimeType)) {
|
||||
return C.TRACK_TYPE_VIDEO;
|
||||
} else if (MimeTypes.isAudio(sampleMimeType)) {
|
||||
return C.TRACK_TYPE_AUDIO;
|
||||
} else if (mimeTypeIsRawText(sampleMimeType)) {
|
||||
return C.TRACK_TYPE_TEXT;
|
||||
}
|
||||
return C.TRACK_TYPE_UNKNOWN;
|
||||
}
|
||||
|
||||
/** A parsed Representation element. */
|
||||
protected static final class RepresentationInfo {
|
||||
|
||||
public final Format format;
|
||||
public final String baseUrl;
|
||||
public final SegmentBase segmentBase;
|
||||
public final String drmSchemeType;
|
||||
public final ArrayList<SchemeData> drmSchemeDatas;
|
||||
public final long revisionId;
|
||||
|
||||
public RepresentationInfo(Format format, String baseUrl, SegmentBase segmentBase,
|
||||
String drmSchemeType, ArrayList<SchemeData> drmSchemeDatas,
|
||||
long revisionId) {
|
||||
this.format = format;
|
||||
this.baseUrl = baseUrl;
|
||||
this.segmentBase = segmentBase;
|
||||
this.drmSchemeType = drmSchemeType;
|
||||
this.drmSchemeDatas = drmSchemeDatas;
|
||||
this.revisionId = revisionId;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.media3.common.C;
|
||||
import com.futo.platformplayer.sabr.SabrSegmentIndex;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An approximate representation of a SegmentBase manifest element.
|
||||
*/
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public abstract class SegmentBase {
|
||||
|
||||
/* package */ final RangedUri initialization;
|
||||
/* package */ final long timescale;
|
||||
/* package */ final long presentationTimeOffset;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
*/
|
||||
public SegmentBase(RangedUri initialization, long timescale, long presentationTimeOffset) {
|
||||
this.initialization = initialization;
|
||||
this.timescale = timescale;
|
||||
this.presentationTimeOffset = presentationTimeOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link RangedUri} defining the location of initialization data for a given
|
||||
* representation, or null if no initialization data exists.
|
||||
*
|
||||
* @param representation The {@link Representation} for which initialization data is required.
|
||||
* @return A {@link RangedUri} defining the location of the initialization data, or null.
|
||||
*/
|
||||
public RangedUri getInitialization(Representation representation) {
|
||||
return initialization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the presentation time offset, in microseconds.
|
||||
*/
|
||||
public long getPresentationTimeOffsetUs() {
|
||||
return Util.scaleLargeTimestamp(presentationTimeOffset, C.MICROS_PER_SECOND, timescale);
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link SegmentBase} that defines a single segment.
|
||||
*/
|
||||
public static class SingleSegmentBase extends SegmentBase {
|
||||
|
||||
/* package */ final long indexStart;
|
||||
/* package */ final long indexLength;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
* @param indexStart The byte offset of the index data in the segment.
|
||||
* @param indexLength The length of the index data in bytes.
|
||||
*/
|
||||
public SingleSegmentBase(RangedUri initialization, long timescale, long presentationTimeOffset,
|
||||
long indexStart, long indexLength) {
|
||||
super(initialization, timescale, presentationTimeOffset);
|
||||
this.indexStart = indexStart;
|
||||
this.indexLength = indexLength;
|
||||
}
|
||||
|
||||
public SingleSegmentBase() {
|
||||
this(null, 1, 0, 0, 0);
|
||||
}
|
||||
|
||||
public RangedUri getIndex() {
|
||||
return indexLength <= 0 ? null : new RangedUri(null, indexStart, indexLength);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link SegmentBase} that consists of multiple segments.
|
||||
*/
|
||||
public abstract static class MultiSegmentBase extends SegmentBase {
|
||||
|
||||
/* package */ final long startNumber;
|
||||
/* package */ final long duration;
|
||||
/* package */ final List<SegmentTimelineElement> segmentTimeline;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
* @param startNumber The sequence number of the first segment.
|
||||
* @param duration The duration of each segment in the case of fixed duration segments. The
|
||||
* value in seconds is the division of this value and {@code timescale}. If {@code
|
||||
* segmentTimeline} is non-null then this parameter is ignored.
|
||||
* @param segmentTimeline A segment timeline corresponding to the segments. If null, then
|
||||
* segments are assumed to be of fixed duration as specified by the {@code duration}
|
||||
* parameter.
|
||||
*/
|
||||
public MultiSegmentBase(
|
||||
RangedUri initialization,
|
||||
long timescale,
|
||||
long presentationTimeOffset,
|
||||
long startNumber,
|
||||
long duration,
|
||||
List<SegmentTimelineElement> segmentTimeline) {
|
||||
super(initialization, timescale, presentationTimeOffset);
|
||||
this.startNumber = startNumber;
|
||||
this.duration = duration;
|
||||
this.segmentTimeline = segmentTimeline;
|
||||
}
|
||||
|
||||
/** @see SabrSegmentIndex#getSegmentNum(long, long) */
|
||||
public long getSegmentNum(long timeUs, long periodDurationUs) {
|
||||
final long firstSegmentNum = getFirstSegmentNum();
|
||||
final long segmentCount = getSegmentCount(periodDurationUs);
|
||||
if (segmentCount == 0) {
|
||||
return firstSegmentNum;
|
||||
}
|
||||
if (segmentTimeline == null) {
|
||||
// All segments are of equal duration (with the possible exception of the last one).
|
||||
long durationUs = (duration * C.MICROS_PER_SECOND) / timescale;
|
||||
long segmentNum = startNumber + timeUs / durationUs;
|
||||
// Ensure we stay within bounds.
|
||||
return segmentNum < firstSegmentNum ? firstSegmentNum
|
||||
: segmentCount == SabrSegmentIndex.INDEX_UNBOUNDED ? segmentNum
|
||||
: Math.min(segmentNum, firstSegmentNum + segmentCount - 1);
|
||||
} else {
|
||||
// The index cannot be unbounded. Identify the segment using binary search.
|
||||
long lowIndex = firstSegmentNum;
|
||||
long highIndex = firstSegmentNum + segmentCount - 1;
|
||||
while (lowIndex <= highIndex) {
|
||||
long midIndex = lowIndex + (highIndex - lowIndex) / 2;
|
||||
long midTimeUs = getSegmentTimeUs(midIndex);
|
||||
if (midTimeUs < timeUs) {
|
||||
lowIndex = midIndex + 1;
|
||||
} else if (midTimeUs > timeUs) {
|
||||
highIndex = midIndex - 1;
|
||||
} else {
|
||||
return midIndex;
|
||||
}
|
||||
}
|
||||
return lowIndex == firstSegmentNum ? lowIndex : highIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/** @see SabrSegmentIndex#getDurationUs(long, long) */
|
||||
public final long getSegmentDurationUs(long sequenceNumber, long periodDurationUs) {
|
||||
if (segmentTimeline != null) {
|
||||
long duration = segmentTimeline.get((int) (sequenceNumber - startNumber)).duration;
|
||||
return (duration * C.MICROS_PER_SECOND) / timescale;
|
||||
} else {
|
||||
int segmentCount = getSegmentCount(periodDurationUs);
|
||||
return segmentCount != SabrSegmentIndex.INDEX_UNBOUNDED
|
||||
&& sequenceNumber == (getFirstSegmentNum() + segmentCount - 1)
|
||||
? (periodDurationUs - getSegmentTimeUs(sequenceNumber))
|
||||
: ((duration * C.MICROS_PER_SECOND) / timescale);
|
||||
}
|
||||
}
|
||||
|
||||
/** @see SabrSegmentIndex#getTimeUs(long) */
|
||||
public final long getSegmentTimeUs(long sequenceNumber) {
|
||||
long unscaledSegmentTime;
|
||||
if (segmentTimeline != null) {
|
||||
unscaledSegmentTime =
|
||||
segmentTimeline.get((int) (sequenceNumber - startNumber)).startTime
|
||||
- presentationTimeOffset;
|
||||
} else {
|
||||
unscaledSegmentTime = (sequenceNumber - startNumber) * duration;
|
||||
}
|
||||
return Util.scaleLargeTimestamp(unscaledSegmentTime, C.MICROS_PER_SECOND, timescale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link RangedUri} defining the location of a segment for the given index in the
|
||||
* given representation.
|
||||
*
|
||||
* @see SabrSegmentIndex#getSegmentUrl(long)
|
||||
*/
|
||||
public abstract RangedUri getSegmentUrl(Representation representation, long index);
|
||||
|
||||
/** @see SabrSegmentIndex#getFirstSegmentNum() */
|
||||
public long getFirstSegmentNum() {
|
||||
return startNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see SabrSegmentIndex#getSegmentCount(long)
|
||||
*/
|
||||
public abstract int getSegmentCount(long periodDurationUs);
|
||||
|
||||
/**
|
||||
* @see SabrSegmentIndex#isExplicit()
|
||||
*/
|
||||
public boolean isExplicit() {
|
||||
return segmentTimeline != null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link MultiSegmentBase} that uses a SegmentList to define its segments.
|
||||
*/
|
||||
public static class SegmentList extends MultiSegmentBase {
|
||||
|
||||
/* package */ final List<RangedUri> mediaSegments;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
* @param startNumber The sequence number of the first segment.
|
||||
* @param duration The duration of each segment in the case of fixed duration segments. The
|
||||
* value in seconds is the division of this value and {@code timescale}. If {@code
|
||||
* segmentTimeline} is non-null then this parameter is ignored.
|
||||
* @param segmentTimeline A segment timeline corresponding to the segments. If null, then
|
||||
* segments are assumed to be of fixed duration as specified by the {@code duration}
|
||||
* parameter.
|
||||
* @param mediaSegments A list of {@link RangedUri}s indicating the locations of the segments.
|
||||
*/
|
||||
public SegmentList(
|
||||
RangedUri initialization,
|
||||
long timescale,
|
||||
long presentationTimeOffset,
|
||||
long startNumber,
|
||||
long duration,
|
||||
List<SegmentTimelineElement> segmentTimeline,
|
||||
List<RangedUri> mediaSegments) {
|
||||
super(initialization, timescale, presentationTimeOffset, startNumber, duration,
|
||||
segmentTimeline);
|
||||
this.mediaSegments = mediaSegments;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(Representation representation, long sequenceNumber) {
|
||||
return mediaSegments.get((int) (sequenceNumber - startNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
return mediaSegments.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExplicit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link MultiSegmentBase} that uses a SegmentTemplate to define its segments.
|
||||
*/
|
||||
public static class SegmentTemplate extends MultiSegmentBase {
|
||||
|
||||
/* package */ final UrlTemplate initializationTemplate;
|
||||
/* package */ final UrlTemplate mediaTemplate;
|
||||
/* package */ final long endNumber;
|
||||
|
||||
/**
|
||||
* @param initialization A {@link RangedUri} corresponding to initialization data, if such data
|
||||
* exists. The value of this parameter is ignored if {@code initializationTemplate} is
|
||||
* non-null.
|
||||
* @param timescale The timescale in units per second.
|
||||
* @param presentationTimeOffset The presentation time offset. The value in seconds is the
|
||||
* division of this value and {@code timescale}.
|
||||
* @param startNumber The sequence number of the first segment.
|
||||
* @param endNumber The sequence number of the last segment as specified by the
|
||||
* SupplementalProperty with schemeIdUri="http://dashif.org/guidelines/last-segment-number",
|
||||
* or {@link C#INDEX_UNSET}.
|
||||
* @param duration The duration of each segment in the case of fixed duration segments. The
|
||||
* value in seconds is the division of this value and {@code timescale}. If {@code
|
||||
* segmentTimeline} is non-null then this parameter is ignored.
|
||||
* @param segmentTimeline A segment timeline corresponding to the segments. If null, then
|
||||
* segments are assumed to be of fixed duration as specified by the {@code duration}
|
||||
* parameter.
|
||||
* @param initializationTemplate A template defining the location of initialization data, if
|
||||
* such data exists. If non-null then the {@code initialization} parameter is ignored. If
|
||||
* null then {@code initialization} will be used.
|
||||
* @param mediaTemplate A template defining the location of each media segment.
|
||||
*/
|
||||
public SegmentTemplate(
|
||||
RangedUri initialization,
|
||||
long timescale,
|
||||
long presentationTimeOffset,
|
||||
long startNumber,
|
||||
long endNumber,
|
||||
long duration,
|
||||
List<SegmentTimelineElement> segmentTimeline,
|
||||
UrlTemplate initializationTemplate,
|
||||
UrlTemplate mediaTemplate) {
|
||||
super(
|
||||
initialization,
|
||||
timescale,
|
||||
presentationTimeOffset,
|
||||
startNumber,
|
||||
duration,
|
||||
segmentTimeline);
|
||||
this.initializationTemplate = initializationTemplate;
|
||||
this.mediaTemplate = mediaTemplate;
|
||||
this.endNumber = endNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getInitialization(Representation representation) {
|
||||
if (initializationTemplate != null) {
|
||||
String urlString = initializationTemplate.buildUri(representation.format.id, 0,
|
||||
representation.format.bitrate, 0);
|
||||
return new RangedUri(urlString, 0, C.LENGTH_UNSET);
|
||||
} else {
|
||||
return super.getInitialization(representation);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(Representation representation, long sequenceNumber) {
|
||||
long time;
|
||||
if (segmentTimeline != null) {
|
||||
time = segmentTimeline.get((int) (sequenceNumber - startNumber)).startTime;
|
||||
} else {
|
||||
time = (sequenceNumber - startNumber) * duration;
|
||||
}
|
||||
String uriString = mediaTemplate.buildUri(representation.format.id, sequenceNumber,
|
||||
representation.format.bitrate, time);
|
||||
return new RangedUri(uriString, 0, C.LENGTH_UNSET);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
if (segmentTimeline != null) {
|
||||
return segmentTimeline.size();
|
||||
} else if (endNumber != C.INDEX_UNSET) {
|
||||
return (int) (endNumber - startNumber + 1);
|
||||
} else if (periodDurationUs != C.TIME_UNSET) {
|
||||
long durationUs = (duration * C.MICROS_PER_SECOND) / timescale;
|
||||
return (int) Util.ceilDivide(periodDurationUs, durationUs);
|
||||
} else {
|
||||
return SabrSegmentIndex.INDEX_UNBOUNDED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a timeline segment from the MPD's SegmentTimeline list.
|
||||
*/
|
||||
public static class SegmentTimelineElement {
|
||||
|
||||
/* package */ final long startTime;
|
||||
/* package */ final long duration;
|
||||
|
||||
/**
|
||||
* @param startTime The start time of the element. The value in seconds is the division of this
|
||||
* value and the {@code timescale} of the enclosing element.
|
||||
* @param duration The duration of the element. The value in seconds is the division of this
|
||||
* value and the {@code timescale} of the enclosing element.
|
||||
*/
|
||||
public SegmentTimelineElement(long startTime, long duration) {
|
||||
this.startTime = startTime;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import com.futo.platformplayer.sabr.SabrSegmentIndex;
|
||||
|
||||
/**
|
||||
* A {@link SabrSegmentIndex} that defines a single segment.
|
||||
*/
|
||||
/* package */ final class SingleSegmentIndex implements SabrSegmentIndex {
|
||||
|
||||
private final RangedUri uri;
|
||||
|
||||
/**
|
||||
* @param uri A {@link RangedUri} defining the location of the segment data.
|
||||
*/
|
||||
public SingleSegmentIndex(RangedUri uri) {
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSegmentNum(long timeUs, long periodDurationUs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimeUs(long segmentNum) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationUs(long segmentNum, long periodDurationUs) {
|
||||
return periodDurationUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RangedUri getSegmentUrl(long segmentNum) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getFirstSegmentNum() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSegmentCount(long periodDurationUs) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExplicit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.futo.platformplayer.sabr.manifest;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* A template from which URLs can be built.
|
||||
* <p>
|
||||
* URLs are built according to the substitution rules defined in ISO/IEC 23009-1:2014 5.3.9.4.4.
|
||||
*/
|
||||
public final class UrlTemplate {
|
||||
|
||||
private static final String REPRESENTATION = "RepresentationID";
|
||||
private static final String NUMBER = "Number";
|
||||
private static final String BANDWIDTH = "Bandwidth";
|
||||
private static final String TIME = "Time";
|
||||
private static final String ESCAPED_DOLLAR = "$$";
|
||||
private static final String DEFAULT_FORMAT_TAG = "%01d";
|
||||
|
||||
private static final int REPRESENTATION_ID = 1;
|
||||
private static final int NUMBER_ID = 2;
|
||||
private static final int BANDWIDTH_ID = 3;
|
||||
private static final int TIME_ID = 4;
|
||||
|
||||
private final String[] urlPieces;
|
||||
private final int[] identifiers;
|
||||
private final String[] identifierFormatTags;
|
||||
private final int identifierCount;
|
||||
|
||||
/**
|
||||
* Compile an instance from the provided template string.
|
||||
*
|
||||
* @param template The template.
|
||||
* @return The compiled instance.
|
||||
* @throws IllegalArgumentException If the template string is malformed.
|
||||
*/
|
||||
public static UrlTemplate compile(String template) {
|
||||
// These arrays are sizes assuming each of the four possible identifiers will be present at
|
||||
// most once in the template, which seems like a reasonable assumption.
|
||||
String[] urlPieces = new String[5];
|
||||
int[] identifiers = new int[4];
|
||||
String[] identifierFormatTags = new String[4];
|
||||
int identifierCount = parseTemplate(template, urlPieces, identifiers, identifierFormatTags);
|
||||
return new UrlTemplate(urlPieces, identifiers, identifierFormatTags, identifierCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal constructor. Use {@link #compile(String)} to build instances of this class.
|
||||
*/
|
||||
private UrlTemplate(String[] urlPieces, int[] identifiers, String[] identifierFormatTags,
|
||||
int identifierCount) {
|
||||
this.urlPieces = urlPieces;
|
||||
this.identifiers = identifiers;
|
||||
this.identifierFormatTags = identifierFormatTags;
|
||||
this.identifierCount = identifierCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a Uri from the template, substituting in the provided arguments.
|
||||
*
|
||||
* <p>Arguments whose corresponding identifiers are not present in the template will be ignored.
|
||||
*
|
||||
* @param representationId The representation identifier.
|
||||
* @param segmentNumber The segment number.
|
||||
* @param bandwidth The bandwidth.
|
||||
* @param time The time as specified by the segment timeline.
|
||||
* @return The built Uri.
|
||||
*/
|
||||
public String buildUri(String representationId, long segmentNumber, int bandwidth, long time) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < identifierCount; i++) {
|
||||
builder.append(urlPieces[i]);
|
||||
if (identifiers[i] == REPRESENTATION_ID) {
|
||||
builder.append(representationId);
|
||||
} else if (identifiers[i] == NUMBER_ID) {
|
||||
builder.append(String.format(Locale.US, identifierFormatTags[i], segmentNumber));
|
||||
} else if (identifiers[i] == BANDWIDTH_ID) {
|
||||
builder.append(String.format(Locale.US, identifierFormatTags[i], bandwidth));
|
||||
} else if (identifiers[i] == TIME_ID) {
|
||||
builder.append(String.format(Locale.US, identifierFormatTags[i], time));
|
||||
}
|
||||
}
|
||||
builder.append(urlPieces[identifierCount]);
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses {@code template}, placing the decomposed components into the provided arrays.
|
||||
* <p>
|
||||
* If the return value is N, {@code urlPieces} will contain (N+1) strings that must be
|
||||
* interleaved with N arguments in order to construct a url. The N identifiers that correspond to
|
||||
* the required arguments, together with the tags that define their required formatting, are
|
||||
* returned in {@code identifiers} and {@code identifierFormatTags} respectively.
|
||||
*
|
||||
* @param template The template to parse.
|
||||
* @param urlPieces A holder for pieces of url parsed from the template.
|
||||
* @param identifiers A holder for identifiers parsed from the template.
|
||||
* @param identifierFormatTags A holder for format tags corresponding to the parsed identifiers.
|
||||
* @return The number of identifiers in the template url.
|
||||
* @throws IllegalArgumentException If the template string is malformed.
|
||||
*/
|
||||
private static int parseTemplate(String template, String[] urlPieces, int[] identifiers,
|
||||
String[] identifierFormatTags) {
|
||||
urlPieces[0] = "";
|
||||
int templateIndex = 0;
|
||||
int identifierCount = 0;
|
||||
while (templateIndex < template.length()) {
|
||||
int dollarIndex = template.indexOf("$", templateIndex);
|
||||
if (dollarIndex == -1) {
|
||||
urlPieces[identifierCount] += template.substring(templateIndex);
|
||||
templateIndex = template.length();
|
||||
} else if (dollarIndex != templateIndex) {
|
||||
urlPieces[identifierCount] += template.substring(templateIndex, dollarIndex);
|
||||
templateIndex = dollarIndex;
|
||||
} else if (template.startsWith(ESCAPED_DOLLAR, templateIndex)) {
|
||||
urlPieces[identifierCount] += "$";
|
||||
templateIndex += 2;
|
||||
} else {
|
||||
int secondIndex = template.indexOf("$", templateIndex + 1);
|
||||
String identifier = template.substring(templateIndex + 1, secondIndex);
|
||||
if (identifier.equals(REPRESENTATION)) {
|
||||
identifiers[identifierCount] = REPRESENTATION_ID;
|
||||
} else {
|
||||
int formatTagIndex = identifier.indexOf("%0");
|
||||
String formatTag = DEFAULT_FORMAT_TAG;
|
||||
if (formatTagIndex != -1) {
|
||||
formatTag = identifier.substring(formatTagIndex);
|
||||
// Allowed conversions are decimal integer (which is the only conversion allowed by the
|
||||
// DASH specification) and hexadecimal integer (due to existing content that uses it).
|
||||
// Else we assume that the conversion is missing, and that it should be decimal integer.
|
||||
if (!formatTag.endsWith("d") && !formatTag.endsWith("x")) {
|
||||
formatTag += "d";
|
||||
}
|
||||
identifier = identifier.substring(0, formatTagIndex);
|
||||
}
|
||||
switch (identifier) {
|
||||
case NUMBER:
|
||||
identifiers[identifierCount] = NUMBER_ID;
|
||||
break;
|
||||
case BANDWIDTH:
|
||||
identifiers[identifierCount] = BANDWIDTH_ID;
|
||||
break;
|
||||
case TIME:
|
||||
identifiers[identifierCount] = TIME_ID;
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Invalid template: " + template);
|
||||
}
|
||||
identifierFormatTags[identifierCount] = formatTag;
|
||||
}
|
||||
identifierCount++;
|
||||
urlPieces[identifierCount] = "";
|
||||
templateIndex = secondIndex + 1;
|
||||
}
|
||||
}
|
||||
return identifierCount;
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,499 @@
|
||||
package com.futo.platformplayer.sabr.parser;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
|
||||
import com.futo.platformplayer.sabr.UrlQueryString;
|
||||
import com.futo.platformplayer.sabr.UrlQueryStringFactory;
|
||||
import com.futo.platformplayer.sabr.parser.exceptions.MediaSegmentMismatchError;
|
||||
import com.futo.platformplayer.sabr.parser.exceptions.SabrStreamError;
|
||||
import com.futo.platformplayer.sabr.parser.models.AudioSelector;
|
||||
import com.futo.platformplayer.sabr.parser.models.CaptionSelector;
|
||||
import com.futo.platformplayer.sabr.parser.models.VideoSelector;
|
||||
import com.futo.platformplayer.sabr.parser.parts.FormatInitializedSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSeekSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentDataSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentEndSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentInitSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.PoTokenStatusSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.RefreshPlayerResponseSabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.parts.SabrPart;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessFormatInitializationMetadataResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessMediaEndResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessMediaHeaderResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessMediaResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.ProcessStreamProtectionStatusResult;
|
||||
import com.futo.platformplayer.sabr.parser.processor.SabrProcessor;
|
||||
import com.futo.platformplayer.sabr.parser.ump.UMPDecoder;
|
||||
import com.futo.platformplayer.sabr.parser.ump.UMPPart;
|
||||
import com.futo.platformplayer.sabr.parser.ump.UMPPartId;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.ClientAbrState;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.ClientInfo;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatInitializationMetadata;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.LiveMetadata;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.NextRequestPolicy;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.MediaHeader;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrRedirect;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.StreamProtectionStatus;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrSeek;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrError;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrContextUpdate;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.SabrContextSendingPolicy;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.ReloadPlayerResponse;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@UnstableApi
|
||||
public class SabrStream {
|
||||
private static final String TAG = SabrStream.class.getSimpleName();
|
||||
private final int[] KNOWN_PARTS = {
|
||||
UMPPartId.MEDIA_HEADER,
|
||||
UMPPartId.MEDIA,
|
||||
UMPPartId.MEDIA_END,
|
||||
UMPPartId.STREAM_PROTECTION_STATUS,
|
||||
UMPPartId.SABR_REDIRECT,
|
||||
UMPPartId.FORMAT_INITIALIZATION_METADATA,
|
||||
UMPPartId.NEXT_REQUEST_POLICY,
|
||||
UMPPartId.LIVE_METADATA,
|
||||
UMPPartId.SABR_SEEK,
|
||||
UMPPartId.SABR_ERROR,
|
||||
UMPPartId.SABR_CONTEXT_UPDATE,
|
||||
UMPPartId.SABR_CONTEXT_SENDING_POLICY,
|
||||
UMPPartId.RELOAD_PLAYER_RESPONSE
|
||||
};
|
||||
private final int[] IGNORED_PARTS = {
|
||||
UMPPartId.REQUEST_IDENTIFIER,
|
||||
UMPPartId.REQUEST_CANCELLATION_POLICY,
|
||||
UMPPartId.PLAYBACK_START_POLICY,
|
||||
UMPPartId.ALLOWED_CACHED_FORMATS,
|
||||
UMPPartId.PAUSE_BW_SAMPLING_HINT,
|
||||
UMPPartId.START_BW_SAMPLING_HINT,
|
||||
UMPPartId.REQUEST_PIPELINING,
|
||||
UMPPartId.SELECTABLE_FORMATS,
|
||||
UMPPartId.PREWARM_CONNECTION,
|
||||
};
|
||||
private final UMPDecoder decoder;
|
||||
private final SabrProcessor processor;
|
||||
private final NoSegmentsTracker noNewSegmentsTracker;
|
||||
private final Set<Integer> unknownPartTypes;
|
||||
private int sqMismatchForwardCount;
|
||||
private int sqMismatchBacktrackCount;
|
||||
private boolean receivedNewSegments;
|
||||
private String url;
|
||||
private List<? extends SabrPart> multiResult = null;
|
||||
|
||||
private static class NoSegmentsTracker { // TODO: move to the SABR request builder
|
||||
public int consecutiveRequests = 0;
|
||||
public float timestampStarted = -1;
|
||||
public int liveHeadSegmentStarted = -1;
|
||||
|
||||
public void reset() {
|
||||
consecutiveRequests = 0;
|
||||
timestampStarted = -1;
|
||||
liveHeadSegmentStarted = -1;
|
||||
}
|
||||
|
||||
public void increment(int liveHeadSegment) {
|
||||
if (consecutiveRequests == 0) {
|
||||
timestampStarted = System.currentTimeMillis() * 1_000;
|
||||
liveHeadSegmentStarted = liveHeadSegment;
|
||||
}
|
||||
consecutiveRequests += 1;
|
||||
}
|
||||
}
|
||||
|
||||
public SabrStream(
|
||||
@NonNull String serverAbrStreamingUrl,
|
||||
@NonNull String videoPlaybackUstreamerConfig,
|
||||
@NonNull ClientInfo clientInfo,
|
||||
AudioSelector audioSelection,
|
||||
VideoSelector videoSelection,
|
||||
CaptionSelector captionSelection,
|
||||
int liveSegmentTargetDurationSec,
|
||||
int liveSegmentTargetDurationToleranceMs,
|
||||
long startTimeMs,
|
||||
String poToken,
|
||||
boolean postLive,
|
||||
String videoId
|
||||
) {
|
||||
decoder = new UMPDecoder();
|
||||
processor = new SabrProcessor(
|
||||
videoPlaybackUstreamerConfig,
|
||||
clientInfo,
|
||||
audioSelection,
|
||||
videoSelection,
|
||||
captionSelection,
|
||||
liveSegmentTargetDurationSec,
|
||||
liveSegmentTargetDurationToleranceMs,
|
||||
startTimeMs,
|
||||
poToken,
|
||||
postLive,
|
||||
videoId
|
||||
);
|
||||
url = serverAbrStreamingUrl;
|
||||
|
||||
// Whether we got any new (not consumed) segments in the request
|
||||
noNewSegmentsTracker = new NoSegmentsTracker();
|
||||
unknownPartTypes = new HashSet<>();
|
||||
|
||||
sqMismatchBacktrackCount = 0;
|
||||
sqMismatchForwardCount = 0;
|
||||
}
|
||||
|
||||
public SabrPart parse(@NonNull ExtractorInput extractorInput) {
|
||||
SabrPart result = null;
|
||||
|
||||
while (result == null && (multiResult == null || multiResult.isEmpty())) {
|
||||
UMPPart part = nextKnownUMPPart(extractorInput);
|
||||
|
||||
if (part == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
result = parsePart(part);
|
||||
|
||||
if (result == null) {
|
||||
multiResult = parseMultiPart(part);
|
||||
}
|
||||
}
|
||||
|
||||
return result != null ? result : multiResult != null && !multiResult.isEmpty() ? multiResult.remove(0) : null;
|
||||
}
|
||||
|
||||
private SabrPart parsePart(UMPPart part) {
|
||||
switch (part.partId) {
|
||||
case UMPPartId.MEDIA_HEADER:
|
||||
return processMediaHeader(part);
|
||||
case UMPPartId.MEDIA:
|
||||
return processMedia(part);
|
||||
case UMPPartId.MEDIA_END:
|
||||
return processMediaEnd(part);
|
||||
case UMPPartId.STREAM_PROTECTION_STATUS:
|
||||
return processStreamProtectionStatus(part);
|
||||
case UMPPartId.SABR_REDIRECT:
|
||||
processSabrRedirect(part);
|
||||
return null;
|
||||
case UMPPartId.FORMAT_INITIALIZATION_METADATA:
|
||||
return processFormatInitializationMetadata(part);
|
||||
case UMPPartId.NEXT_REQUEST_POLICY:
|
||||
processNextRequestPolicy(part);
|
||||
return null;
|
||||
case UMPPartId.SABR_ERROR:
|
||||
processSabrError(part);
|
||||
return null;
|
||||
case UMPPartId.SABR_CONTEXT_UPDATE:
|
||||
processSabrContextUpdate(part);
|
||||
return null;
|
||||
case UMPPartId.SABR_CONTEXT_SENDING_POLICY:
|
||||
processSabrContextSendingPolicy(part);
|
||||
return null;
|
||||
case UMPPartId.RELOAD_PLAYER_RESPONSE:
|
||||
return processReloadPlayerResponse(part);
|
||||
}
|
||||
|
||||
if (!contains(IGNORED_PARTS, part.partId)) {
|
||||
unknownPartTypes.add(part.partId);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Unhandled part type %s", part.partId));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<? extends SabrPart> parseMultiPart(UMPPart part) {
|
||||
switch (part.partId) {
|
||||
case UMPPartId.LIVE_METADATA:
|
||||
return processLiveMetadata(part);
|
||||
case UMPPartId.SABR_SEEK:
|
||||
return processSabrSeek(part);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private MediaSegmentInitSabrPart processMediaHeader(UMPPart part) {
|
||||
MediaHeader mediaHeader;
|
||||
|
||||
try {
|
||||
mediaHeader = MediaHeader.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
try {
|
||||
ProcessMediaHeaderResult result = processor.processMediaHeader(mediaHeader);
|
||||
|
||||
return result.sabrPart;
|
||||
} catch (MediaSegmentMismatchError e) {
|
||||
// For livestreams, the server may not know the exact segment for a given player time.
|
||||
// For segments near stream head, it estimates using segment duration, which can cause off-by-one segment mismatches.
|
||||
// If a segment is much longer or shorter than expected, the server may return a segment ahead or behind.
|
||||
// In such cases, retry with an adjusted player time to resync.
|
||||
if (processor.isLive() && e.receivedSequenceNumber == e.expectedSequenceNumber - 1) {
|
||||
// The segment before the previous segment was possibly longer than expected.
|
||||
// Move the player time forward to try to adjust for this.
|
||||
ClientAbrState state = processor.getClientAbrState().toBuilder()
|
||||
.setPlayerTimeMs(processor.getClientAbrState().getPlayerTimeMs() + processor.getLiveSegmentTargetDurationToleranceMs())
|
||||
.build();
|
||||
processor.setClientAbrState(state);
|
||||
sqMismatchForwardCount += 1;
|
||||
return null;
|
||||
} else if (processor.isLive() && e.receivedSequenceNumber == e.expectedSequenceNumber + 2) {
|
||||
// The previous segment was possibly shorter than expected
|
||||
// Move the player time backwards to try to adjust for this.
|
||||
ClientAbrState state = processor.getClientAbrState().toBuilder()
|
||||
.setPlayerTimeMs(Math.max(0, processor.getClientAbrState().getPlayerTimeMs() - processor.getLiveSegmentTargetDurationToleranceMs()))
|
||||
.build();
|
||||
processor.setClientAbrState(state);
|
||||
sqMismatchBacktrackCount += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private MediaSegmentDataSabrPart processMedia(UMPPart part) {
|
||||
try {
|
||||
long position = part.data.getPosition();
|
||||
long headerId = decoder.readVarInt(part.data);
|
||||
long offset = part.data.getPosition() - position;
|
||||
int contentLength = part.size - (int) offset;
|
||||
|
||||
ProcessMediaResult result = processor.processMedia(headerId, contentLength, part.data);
|
||||
|
||||
return result.sabrPart;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private MediaSegmentEndSabrPart processMediaEnd(UMPPart part) {
|
||||
try {
|
||||
long headerId = decoder.readVarInt(part.data);
|
||||
Log.d(TAG, String.format("Header ID: %s", headerId));
|
||||
|
||||
ProcessMediaEndResult result = processor.processMediaEnd(headerId);
|
||||
|
||||
if (result.isNewSegment) {
|
||||
receivedNewSegments = true;
|
||||
}
|
||||
|
||||
return result.sabrPart;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private PoTokenStatusSabrPart processStreamProtectionStatus(UMPPart part) {
|
||||
StreamProtectionStatus sps;
|
||||
|
||||
try {
|
||||
sps = StreamProtectionStatus.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process StreamProtectionStatus: %s", sps));
|
||||
ProcessStreamProtectionStatusResult result = processor.processStreamProtectionStatus(sps);
|
||||
|
||||
return result.sabrPart;
|
||||
}
|
||||
|
||||
private void processSabrRedirect(UMPPart part) {
|
||||
SabrRedirect sabrRedirect;
|
||||
|
||||
try {
|
||||
sabrRedirect = SabrRedirect.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrRedirect: %s", sabrRedirect));
|
||||
|
||||
if (!sabrRedirect.hasRedirectUrl()) {
|
||||
Log.d(TAG, "Server requested to redirect to an invalid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setUrl(sabrRedirect.getRedirectUrl());
|
||||
}
|
||||
|
||||
private FormatInitializedSabrPart processFormatInitializationMetadata(UMPPart part) {
|
||||
FormatInitializationMetadata fmtInitMetadata;
|
||||
|
||||
try {
|
||||
fmtInitMetadata = FormatInitializationMetadata.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process FormatInitializationMetadata: %s", fmtInitMetadata));
|
||||
ProcessFormatInitializationMetadataResult result = processor.processFormatInitializationMetadata(fmtInitMetadata);
|
||||
|
||||
return result.sabrPart;
|
||||
}
|
||||
|
||||
private void processNextRequestPolicy(UMPPart part) {
|
||||
NextRequestPolicy nextRequestPolicy;
|
||||
|
||||
try {
|
||||
nextRequestPolicy = NextRequestPolicy.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process NextRequestPolicy: %s", nextRequestPolicy));
|
||||
processor.processNextRequestPolicy(nextRequestPolicy);
|
||||
}
|
||||
|
||||
private void processSabrError(UMPPart part) {
|
||||
SabrError sabrError;
|
||||
|
||||
try {
|
||||
sabrError = SabrError.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrError: %s", sabrError));
|
||||
throw new SabrStreamError(String.format("SABR Protocol Error: %s", sabrError));
|
||||
}
|
||||
|
||||
private void processSabrContextUpdate(UMPPart part) {
|
||||
SabrContextUpdate sabrCtxUpdate;
|
||||
|
||||
try {
|
||||
sabrCtxUpdate = SabrContextUpdate.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrContextUpdate: %s", sabrCtxUpdate));
|
||||
processor.processSabrContextUpdate(sabrCtxUpdate);
|
||||
}
|
||||
|
||||
private void processSabrContextSendingPolicy(UMPPart part) {
|
||||
SabrContextSendingPolicy sabrCtxSendingPolicy;
|
||||
|
||||
try {
|
||||
sabrCtxSendingPolicy = SabrContextSendingPolicy.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrContextSendingPolicy: %s", sabrCtxSendingPolicy));
|
||||
processor.processSabrContextSendingPolicy(sabrCtxSendingPolicy);
|
||||
}
|
||||
|
||||
private RefreshPlayerResponseSabrPart processReloadPlayerResponse(UMPPart part) {
|
||||
ReloadPlayerResponse reloadPlayerResponse;
|
||||
|
||||
try {
|
||||
reloadPlayerResponse = ReloadPlayerResponse.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process ReloadPlayerResponse: %s", reloadPlayerResponse));
|
||||
return new RefreshPlayerResponseSabrPart(
|
||||
RefreshPlayerResponseSabrPart.Reason.SABR_RELOAD_PLAYER_RESPONSE,
|
||||
reloadPlayerResponse.hasReloadPlaybackParams() && reloadPlayerResponse.getReloadPlaybackParams().hasToken()
|
||||
? reloadPlayerResponse.getReloadPlaybackParams().getToken() : null
|
||||
);
|
||||
}
|
||||
|
||||
private List<MediaSeekSabrPart> processLiveMetadata(UMPPart part) {
|
||||
LiveMetadata liveMetadata;
|
||||
|
||||
try {
|
||||
liveMetadata = LiveMetadata.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process LiveMetadata: %s", liveMetadata));
|
||||
return processor.processLiveMetadata(liveMetadata).seekSabrParts;
|
||||
}
|
||||
|
||||
private List<MediaSeekSabrPart> processSabrSeek(UMPPart part) {
|
||||
SabrSeek sabrSeek;
|
||||
|
||||
try {
|
||||
sabrSeek = SabrSeek.parseFrom(part.toStream());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("Process SabrSeek: %s", sabrSeek));
|
||||
return processor.processSabrSeek(sabrSeek).seekSabrParts;
|
||||
}
|
||||
|
||||
public static boolean contains(int[] array, int value) {
|
||||
for (int num : array) {
|
||||
if (num == value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private UMPPart nextKnownUMPPart(@NonNull ExtractorInput extractorInput) {
|
||||
UMPPart part;
|
||||
|
||||
while (true) {
|
||||
part = decoder.decode(extractorInput);
|
||||
|
||||
if (part == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (contains(KNOWN_PARTS, part.partId)) {
|
||||
break;
|
||||
} else {
|
||||
Log.d(TAG, String.format("Unknown part encountered: %s", part.partId));
|
||||
}
|
||||
}
|
||||
|
||||
return part;
|
||||
}
|
||||
|
||||
private String getUrl() {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
public static boolean equals(Object first, Object second) {
|
||||
if (first == null && second == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (first == null || second == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
private void setUrl(String url) {
|
||||
Log.d(TAG, String.format("New URL: %s", url));
|
||||
UrlQueryString newQueryString = UrlQueryStringFactory.parse(url);
|
||||
UrlQueryString oldQueryString = UrlQueryStringFactory.parse(this.url);
|
||||
String bn = newQueryString.get("id");
|
||||
String bc = oldQueryString.get("id");
|
||||
if (processor.isLive() && this.url != null && !equals(bn, bc)) {
|
||||
throw new SabrStreamError(String.format("Broadcast ID changed from %s to %s. The download will need to be restarted.", bc, bn));
|
||||
}
|
||||
this.url = url;
|
||||
if (equals(newQueryString.get("source"), "yt_live_broadcast")) {
|
||||
processor.setLive(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class MediaSegmentMismatchError extends SabrStreamError {
|
||||
public final long expectedSequenceNumber;
|
||||
public final long receivedSequenceNumber;
|
||||
|
||||
public MediaSegmentMismatchError(FormatId formatId, long expectedSequenceNumber, long receivedSequenceNumber) {
|
||||
super(String.format(
|
||||
"Segment sequence number mismatch for format %s: expected %s, received %s",
|
||||
formatId,
|
||||
expectedSequenceNumber,
|
||||
receivedSequenceNumber
|
||||
));
|
||||
this.receivedSequenceNumber = receivedSequenceNumber;
|
||||
this.expectedSequenceNumber = expectedSequenceNumber;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
public class PoTokenError extends SabrStreamError {
|
||||
public PoTokenError(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
public class SabrStreamConsumedError extends Exception {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
public class SabrStreamError extends RuntimeException {
|
||||
public SabrStreamError(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
public class AudioSelector extends FormatSelector {
|
||||
public AudioSelector(String displayName, boolean discardMedia) {
|
||||
super(displayName, discardMedia);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMimePrefix() {
|
||||
return "audio";
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user