mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-18 22:12:35 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8536861e09 | |||
| 71262da3c2 | |||
| 3ca6a1fd70 | |||
| 0d8c8de450 | |||
| 8ba2fe9972 | |||
| 7a7ef533cc | |||
| 5385549a43 | |||
| 04deffc66e | |||
| 852f563c9a | |||
| c84cea9ea1 | |||
| 5c162083d5 | |||
| 3230e7c0b4 | |||
| 8437825dd1 | |||
| 0fbe0bb438 | |||
| 34d2e62314 | |||
| 1075ded170 | |||
| 80bb15f3fb | |||
| 27a86a67f0 | |||
| 284b2a24f8 | |||
| 854d1506a6 | |||
| 811fd4e73e | |||
| 335988aa67 | |||
| 29a54fbed4 | |||
| 3a11d0d9d1 | |||
| 894e400819 | |||
| 09bc180d4f |
+1
-1
@@ -232,7 +232,7 @@ dependencies {
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||
|
||||
//Rust casting SDK
|
||||
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
|
||||
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.4.0') {
|
||||
// Polycentricandroid includes this
|
||||
exclude group: 'net.java.dev.jna'
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:windowSoftInputMode="adjustPan"
|
||||
|
||||
@@ -415,6 +415,8 @@ class VideoUrlSource {
|
||||
this.url = obj.url;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
this.language = obj?.language;
|
||||
this.original = obj?.original;
|
||||
}
|
||||
}
|
||||
class VideoUrlWidevineSource extends VideoUrlSource {
|
||||
@@ -512,6 +514,8 @@ class HLSSource {
|
||||
this.language = obj.language;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
this.language = obj?.language;
|
||||
this.original = obj?.original;
|
||||
}
|
||||
}
|
||||
class DashSource {
|
||||
@@ -525,6 +529,8 @@ class DashSource {
|
||||
this.language = obj.language;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
this.language = obj?.language;
|
||||
this.original = obj?.original;
|
||||
}
|
||||
}
|
||||
class DashWidevineSource extends DashSource {
|
||||
@@ -550,6 +556,7 @@ class DashManifestRawSource {
|
||||
this.language = obj.language ?? Language.UNKNOWN;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
this.original = obj?.original;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||
var primaryLanguage: Int = 0;
|
||||
|
||||
fun getPrimaryLanguage(context: Context): String? {
|
||||
fun getPrimaryLanguage(context: Context? = null): String? {
|
||||
return when(primaryLanguage) {
|
||||
0 -> "en";
|
||||
1 -> "es";
|
||||
@@ -725,11 +725,6 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var allowLinkLocalIpv4: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var experimentalCasting: Boolean = true
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.OptIn
|
||||
@@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
|
||||
import kotlin.collections.toList
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
@@ -573,6 +576,51 @@ class UISlideOverlays {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||
lang -> videoSources
|
||||
.filter { v -> v.language == lang }
|
||||
.map { it.height * it.width }
|
||||
.distinct()
|
||||
.map { res -> Pair(res, lang) }
|
||||
} else listOf();
|
||||
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||
var selectedLanguage: String? = null;
|
||||
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
|
||||
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||
|
||||
if(english != null)
|
||||
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||
if(originalLanguage != null)
|
||||
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||
setButtons(languageFilterLabels, selectedLanguage);
|
||||
onClick.subscribe { selected ->
|
||||
setSelected(selected);
|
||||
|
||||
videoSourceItems.forEach {
|
||||
val item = it.itemTag;
|
||||
if(item is IVideoSource) {
|
||||
if(item.language == selected)
|
||||
it.visibility = View.VISIBLE;
|
||||
else
|
||||
it.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else null;
|
||||
|
||||
if(languageFilters != null) items.add(languageFilters)
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
||||
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
||||
container.context,
|
||||
@@ -609,7 +657,13 @@ class UISlideOverlays {
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
).apply {
|
||||
videoSourceItems.add(this);
|
||||
if(selectedLanguage != null) {
|
||||
if(it.language != selectedLanguage)
|
||||
this.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is JSDashManifestRawSource -> {
|
||||
@@ -629,7 +683,13 @@ class UISlideOverlays {
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
).apply {
|
||||
videoSourceItems.add(this);
|
||||
if(selectedLanguage != null) {
|
||||
if(it.language != selectedLanguage)
|
||||
this.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is IHLSManifestSource -> {
|
||||
@@ -643,7 +703,13 @@ class UISlideOverlays {
|
||||
showHlsPicker(video, it, it.url, container)
|
||||
},
|
||||
invokeParent = false
|
||||
)
|
||||
).apply {
|
||||
videoSourceItems.add(this);
|
||||
if(selectedLanguage != null) {
|
||||
if(it.language != selectedLanguage)
|
||||
this.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
@@ -7,6 +7,10 @@ import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import com.futo.platformplayer.UIDialogs.ActionStyle
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.SessionAnnouncement
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import kotlinx.coroutines.*
|
||||
@@ -14,6 +18,7 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class UpdateDownloadService : Service() {
|
||||
|
||||
@@ -85,13 +90,16 @@ class UpdateDownloadService : Service() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) {
|
||||
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
|
||||
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)
|
||||
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
||||
|
||||
if(onProgress != null)
|
||||
onProgress.invoke(progress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +107,7 @@ class UpdateDownloadService : Service() {
|
||||
val apkFile = StateUpdate.getApkFile(this, version)
|
||||
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||
|
||||
var announcement: SessionAnnouncement? = null;
|
||||
try {
|
||||
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||
@@ -106,6 +115,14 @@ class UpdateDownloadService : Service() {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
|
||||
ImageVariable.fromResource(R.drawable.foreground));
|
||||
}
|
||||
catch(ex: Exception){
|
||||
Logger.e(TAG, "Failed to set progress announcement", ex);
|
||||
}
|
||||
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
|
||||
for (attempt in 0 until MAX_RETRIES) {
|
||||
@@ -115,7 +132,13 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
|
||||
try {
|
||||
performDownload(StateUpdate.APK_URL, partialFile, version)
|
||||
performDownload(StateUpdate.APK_URL, partialFile, version, {
|
||||
try {
|
||||
if (announcement != null)
|
||||
announcement?.setProgress(it);
|
||||
}
|
||||
catch(ex: Throwable) {}
|
||||
})
|
||||
|
||||
if (!cancelRequested) {
|
||||
if (apkFile.exists()) {
|
||||
@@ -145,6 +168,12 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (announcement != null) {
|
||||
StateAnnouncement.instance.closeAnnouncement(announcement.id);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable){}
|
||||
isDownloading = false
|
||||
cancelRequested = false
|
||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
@@ -152,7 +181,7 @@ class UpdateDownloadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun performDownload(url: String, partialFile: File, version: Int) {
|
||||
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
|
||||
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||
|
||||
@@ -204,7 +233,7 @@ class UpdateDownloadService : Service() {
|
||||
progress > 100 -> 100
|
||||
else -> progress
|
||||
}
|
||||
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
|
||||
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
|
||||
}
|
||||
} else {
|
||||
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||
@@ -250,6 +279,18 @@ class UpdateDownloadService : Service() {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||
}, ActionStyle.PRIMARY, true));
|
||||
|
||||
try {
|
||||
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.",
|
||||
AnnouncementType.SESSION,
|
||||
OffsetDateTime.now(), "update", "Install", {
|
||||
UpdateNotificationManager.cancelAll(ctx)
|
||||
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||
});
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
|
||||
updateDownloadedDialog = null
|
||||
|
||||
@@ -110,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
@@ -201,6 +202,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragLibraryVideos: LibraryVideosFragment;
|
||||
lateinit var _fragLibrarySearch: LibrarySearchFragment;
|
||||
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||
lateinit var _fragNotifications: NotificationOverlayView.Frag;
|
||||
lateinit var _fragSettings: SettingsFragment;
|
||||
lateinit var _fragDeveloper: DeveloperFragment;
|
||||
lateinit var _fragLogin: LoginFragment;
|
||||
@@ -389,6 +391,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragLibraryVideos = LibraryVideosFragment.newInstance();
|
||||
_fragLibraryFiles = LibraryFilesFragment.newInstance();
|
||||
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||
_fragNotifications = NotificationOverlayView.Frag();
|
||||
_fragSettings = SettingsFragment.newInstance();
|
||||
_fragDeveloper = DeveloperFragment.newInstance();
|
||||
_fragLogin = LoginFragment.newInstance();
|
||||
@@ -538,6 +541,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragLibrarySearch.topBar = _fragTopBarSearch;
|
||||
_fragSettings.topBar = _fragTopBarNavigation;
|
||||
_fragDeveloper.topBar = _fragTopBarNavigation;
|
||||
_fragNotifications.topBar = _fragTopBarGeneral;
|
||||
|
||||
_fragBrowser.topBar = _fragTopBarNavigation;
|
||||
|
||||
@@ -1368,6 +1372,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
LibraryVideosFragment::class -> _fragLibraryVideos as T;
|
||||
LibraryFilesFragment::class -> _fragLibraryFiles as T;
|
||||
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||
NotificationOverlayView.Frag::class -> _fragNotifications as T;
|
||||
SettingsFragment:: class -> _fragSettings as T;
|
||||
DeveloperFragment::class -> _fragDeveloper as T;
|
||||
LoginFragment::class -> _fragLogin as T;
|
||||
|
||||
+3
@@ -12,6 +12,9 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
constructor(url : String) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
+3
@@ -12,6 +12,9 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
constructor(url : String) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
+3
@@ -14,6 +14,9 @@ class HLSVariantVideoUrlSource(
|
||||
override val priority: Boolean,
|
||||
val url: String
|
||||
) : IVideoUrlSource {
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
override fun getVideoUrl(): String {
|
||||
return url
|
||||
}
|
||||
|
||||
+2
@@ -9,4 +9,6 @@ interface IVideoSource {
|
||||
val bitrate : Int?;
|
||||
val duration: Long;
|
||||
val priority: Boolean;
|
||||
val language: String?;
|
||||
val original: Boolean?;
|
||||
}
|
||||
+4
@@ -16,6 +16,10 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
|
||||
val filePath : String;
|
||||
val fileSize : Long;
|
||||
|
||||
|
||||
+3
@@ -19,6 +19,9 @@ open class VideoUrlSource(
|
||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
override fun getVideoUrl() : String {
|
||||
return url;
|
||||
}
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
||||
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
||||
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
||||
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||
original = obj.getOrNull(config, "original", contextName) ?: false;
|
||||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -39,6 +39,10 @@ open class JSDashManifestRawSource(
|
||||
private val ctx = "DashRawSource"
|
||||
private val cfg = plugin.config
|
||||
|
||||
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
|
||||
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
|
||||
|
||||
|
||||
override val container: String =
|
||||
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
|
||||
|
||||
@@ -185,6 +189,9 @@ class JSDashManifestMergingRawSource(
|
||||
override val priority: Boolean
|
||||
get() = video.priority;
|
||||
|
||||
override val language: String? get() = audio.language
|
||||
override val original: Boolean? get() = audio.original;
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
val videoDashDef = video.generateAsync(scope);
|
||||
val audioDashDef = audio.generateAsync(scope);
|
||||
|
||||
+6
@@ -21,6 +21,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String?;
|
||||
override val original: Boolean?;
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||
val contextName = "DashSource";
|
||||
val config = plugin.config;
|
||||
@@ -29,6 +32,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
||||
duration = _obj.getOrThrow(config, "duration", contextName);
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
|
||||
language = obj.getOrNull(config, "language", contextName);
|
||||
original = obj.getOrNull(config, "original", contextName);
|
||||
}
|
||||
|
||||
override fun getVideoUrl(): String {
|
||||
|
||||
+6
@@ -28,6 +28,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
||||
override val licenseUri: String
|
||||
override val hasLicenseRequestExecutor: Boolean
|
||||
|
||||
override val language: String?;
|
||||
override val original: Boolean?;
|
||||
|
||||
@Suppress("ConvertSecondaryConstructorToPrimary")
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||
val contextName = "DashWidevineSource"
|
||||
@@ -40,6 +43,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
||||
|
||||
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
||||
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
|
||||
|
||||
language = _obj.getOrNull(config, "language", contextName);
|
||||
original = _obj.getOrNull(config, "original", contextName);
|
||||
}
|
||||
|
||||
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
||||
language = _obj.getOrThrow(config, "language", contextName);
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||
original = obj.getOrNull(config, "original", contextName) ?: false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
@@ -21,6 +21,9 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
|
||||
override val language: String?;
|
||||
override val original: Boolean?;
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
||||
val contextName = "HLSSource";
|
||||
val config = plugin.config;
|
||||
@@ -30,5 +33,8 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
||||
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
|
||||
language = _obj.getOrNull(config, "language", contextName);
|
||||
original = _obj.getOrNull(config, "original", contextName);
|
||||
}
|
||||
}
|
||||
+3
@@ -44,6 +44,9 @@ open class JSVideoUrlSource(
|
||||
override var priority: Boolean =
|
||||
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
|
||||
|
||||
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
|
||||
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
|
||||
|
||||
override fun getVideoUrl(): String = url
|
||||
|
||||
override fun toString(): String =
|
||||
|
||||
+3
@@ -20,6 +20,9 @@ class LocalVideoContentSource: IVideoSource {
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||
|
||||
+3
@@ -20,6 +20,9 @@ class LocalVideoFileSource: IVideoSource {
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
override val language: String? = null;
|
||||
override val original: Boolean? = null;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.UUID
|
||||
|
||||
class AirPlayCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: https://nto.github.io/AirPlay
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = false;
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _started: Boolean = false;
|
||||
private var _sessionId: String? = null;
|
||||
private val _client = ManagedHttpClient();
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
||||
this.name = deviceInfo.name;
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
||||
this.port = deviceInfo.port;
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> {
|
||||
return addresses?.toList() ?: listOf();
|
||||
}
|
||||
|
||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
if (resumePosition > 0.0) {
|
||||
val pos = resumePosition / duration;
|
||||
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
|
||||
} else {
|
||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
|
||||
}
|
||||
|
||||
if (speed != null) {
|
||||
changeSpeed(speed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
post("scrub?position=${timeSeconds}");
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPlaying = true;
|
||||
post("rate?value=1.000000");
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPlaying = false;
|
||||
post("rate?value=0.000000");
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
post("stop");
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
post("stop");
|
||||
stop();
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val adrs = addresses ?: return;
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
|
||||
_started = true;
|
||||
_scopeIO?.cancel();
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
||||
_scopeIO?.launch {
|
||||
try {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (connectedSocket == null) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
connectedSocket.close();
|
||||
_sessionId = UUID.randomUUID().toString();
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
||||
delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val progressInfo = getProgress();
|
||||
if (progressInfo == null) {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
|
||||
val progressIndex = progressInfo.lowercase().indexOf("position: ");
|
||||
if (progressIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
|
||||
setTime(progress);
|
||||
|
||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
|
||||
if (durationIndex == -1) {
|
||||
delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
|
||||
setDuration(duration);
|
||||
delay(1000);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
|
||||
}
|
||||
};
|
||||
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping...");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
|
||||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
_scopeIO?.cancel();
|
||||
_scopeIO = null;
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
setSpeed(speed)
|
||||
post("rate?value=$speed")
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
}
|
||||
|
||||
private fun getProgress(): String? {
|
||||
val info = get("scrub");
|
||||
Logger.i(TAG, "Progress: ${info ?: "null"}");
|
||||
return info;
|
||||
}
|
||||
|
||||
private fun getPlaybackInfo(): String? {
|
||||
val playbackInfo = get("playback-info");
|
||||
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
|
||||
return playbackInfo;
|
||||
}
|
||||
|
||||
private fun getServerInfo(): String? {
|
||||
val serverInfo = get("server-info");
|
||||
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
|
||||
return serverInfo;
|
||||
}
|
||||
|
||||
private fun post(path: String): Boolean {
|
||||
try {
|
||||
val sessionId = _sessionId ?: return false;
|
||||
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"Content-Length" to "0",
|
||||
"X-Apple-Session-ID" to sessionId
|
||||
);
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
|
||||
Logger.i(TAG, "POST $url");
|
||||
val response = _client.post(url, headers);
|
||||
if (!response.isOk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to POST $path");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(path: String, contentType: String, body: String): Boolean {
|
||||
try {
|
||||
val sessionId = _sessionId ?: return false;
|
||||
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"X-Apple-Session-ID" to sessionId,
|
||||
"Content-Type" to contentType
|
||||
);
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
|
||||
Logger.i(TAG, "POST $url:\n$body");
|
||||
val response = _client.post(url, body, headers);
|
||||
if (!response.isOk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to POST $path $body");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(path: String): String? {
|
||||
val sessionId = _sessionId ?: return null;
|
||||
|
||||
try {
|
||||
val headers = hashMapOf(
|
||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
||||
"Content-Length" to "0",
|
||||
"User-Agent" to "MediaControl/1.0",
|
||||
"X-Apple-Session-ID" to sessionId
|
||||
);
|
||||
|
||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
||||
|
||||
Logger.i(TAG, "GET $url");
|
||||
val response = _client.get(url, headers);
|
||||
if (!response.isOk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.body == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.body.string();
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to GET $path");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch { action(); }
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "AirPlayCastingDevice";
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,289 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Build
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.fcast.sender_sdk.ApplicationInfo
|
||||
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
|
||||
import org.fcast.sender_sdk.KeyEvent
|
||||
import org.fcast.sender_sdk.MediaEvent
|
||||
import java.net.InetAddress
|
||||
import org.fcast.sender_sdk.PlaybackState
|
||||
import org.fcast.sender_sdk.Source
|
||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
||||
import org.fcast.sender_sdk.DeviceConnectionState
|
||||
import org.fcast.sender_sdk.DeviceFeature
|
||||
import org.fcast.sender_sdk.EventSubscription
|
||||
import org.fcast.sender_sdk.IpAddr
|
||||
import org.fcast.sender_sdk.LoadRequest
|
||||
import org.fcast.sender_sdk.MediaItemEventType
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
|
||||
abstract class CastingDevice {
|
||||
abstract val isReady: Boolean
|
||||
abstract val usedRemoteAddress: InetAddress?
|
||||
abstract val localAddress: InetAddress?
|
||||
abstract val name: String?
|
||||
abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
||||
abstract val onPlayChanged: Event1<Boolean>
|
||||
abstract val onTimeChanged: Event1<Double>
|
||||
abstract val onDurationChanged: Event1<Double>
|
||||
abstract val onVolumeChanged: Event1<Double>
|
||||
abstract val onSpeedChanged: Event1<Double>
|
||||
abstract var connectionState: CastConnectionState
|
||||
abstract val protocolType: CastProtocolType
|
||||
abstract var isPlaying: Boolean
|
||||
abstract val expectedCurrentTime: Double
|
||||
abstract var speed: Double
|
||||
abstract var time: Double
|
||||
abstract var duration: Double
|
||||
abstract var volume: Double
|
||||
abstract fun canSetVolume(): Boolean
|
||||
abstract fun canSetSpeed(): Boolean
|
||||
enum class CastConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
@Throws
|
||||
abstract fun resumePlayback()
|
||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST;
|
||||
|
||||
@Throws
|
||||
abstract fun pausePlayback()
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
|
||||
@Throws
|
||||
abstract fun stopPlayback()
|
||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||
encoder.encodeString(value.name)
|
||||
}
|
||||
|
||||
@Throws
|
||||
abstract fun seekTo(timeSeconds: Double)
|
||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||
val name = decoder.decodeString()
|
||||
return when (name) {
|
||||
"FASTCAST" -> FCAST // Handle the renamed case
|
||||
else -> CastProtocolType.valueOf(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws
|
||||
abstract fun changeVolume(timeSeconds: Double)
|
||||
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
||||
is IpAddr.V4 -> Inet4Address.getByAddress(
|
||||
byteArrayOf(
|
||||
addr.o1.toByte(),
|
||||
addr.o2.toByte(),
|
||||
addr.o3.toByte(),
|
||||
addr.o4.toByte()
|
||||
)
|
||||
)
|
||||
|
||||
@Throws
|
||||
abstract fun changeSpeed(speed: Double)
|
||||
is IpAddr.V6 -> Inet6Address.getByAddress(
|
||||
byteArrayOf(
|
||||
addr.o1.toByte(),
|
||||
addr.o2.toByte(),
|
||||
addr.o3.toByte(),
|
||||
addr.o4.toByte(),
|
||||
addr.o5.toByte(),
|
||||
addr.o6.toByte(),
|
||||
addr.o7.toByte(),
|
||||
addr.o8.toByte(),
|
||||
addr.o9.toByte(),
|
||||
addr.o10.toByte(),
|
||||
addr.o11.toByte(),
|
||||
addr.o12.toByte(),
|
||||
addr.o13.toByte(),
|
||||
addr.o14.toByte(),
|
||||
addr.o15.toByte(),
|
||||
addr.o16.toByte()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Throws
|
||||
abstract fun connect()
|
||||
// abstract class CastingDevice {
|
||||
class CastingDevice(val device: RsCastingDevice) {
|
||||
// abstract val isReady: Boolean
|
||||
// abstract val usedRemoteAddress: InetAddress?
|
||||
// abstract val localAddress: InetAddress?
|
||||
// abstract val name: String?
|
||||
// abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
||||
// abstract val onPlayChanged: Event1<Boolean>
|
||||
// abstract val onTimeChanged: Event1<Double>
|
||||
// abstract val onDurationChanged: Event1<Double>
|
||||
// abstract val onVolumeChanged: Event1<Double>
|
||||
// abstract val onSpeedChanged: Event1<Double>
|
||||
// abstract val onMediaItemEnd: Event0
|
||||
// abstract var connectionState: CastConnectionState
|
||||
// abstract val protocolType: CastProtocolType
|
||||
// abstract var isPlaying: Boolean
|
||||
// abstract val expectedCurrentTime: Double
|
||||
// abstract var speed: Double
|
||||
// abstract var time: Double
|
||||
// abstract var duration: Double
|
||||
// abstract var volume: Double
|
||||
// abstract fun canSetVolume(): Boolean
|
||||
// abstract fun canSetSpeed(): Boolean
|
||||
|
||||
@Throws
|
||||
abstract fun disconnect()
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||
abstract fun getAddresses(): List<InetAddress>
|
||||
// @Throws
|
||||
// abstract fun resumePlayback()
|
||||
|
||||
@Throws
|
||||
abstract fun loadVideo(
|
||||
// @Throws
|
||||
// abstract fun pausePlayback()
|
||||
|
||||
// @Throws
|
||||
// abstract fun stopPlayback()
|
||||
|
||||
// @Throws
|
||||
// abstract fun seekTo(timeSeconds: Double)
|
||||
|
||||
// @Throws
|
||||
// abstract fun changeVolume(timeSeconds: Double)
|
||||
|
||||
// @Throws
|
||||
// abstract fun changeSpeed(speed: Double)
|
||||
|
||||
// @Throws
|
||||
// abstract fun connect()
|
||||
|
||||
// @Throws
|
||||
// abstract fun disconnect()
|
||||
// abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||
// abstract fun getAddresses(): List<InetAddress>
|
||||
|
||||
// @Throws
|
||||
// abstract fun loadVideo(
|
||||
// streamType: String,
|
||||
// contentType: String,
|
||||
// contentId: String,
|
||||
// resumePosition: Double,
|
||||
// duration: Double,
|
||||
// speed: Double?,
|
||||
// metadata: Metadata?
|
||||
// )
|
||||
|
||||
// @Throws
|
||||
// fun loadContent(
|
||||
// contentType: String,
|
||||
// content: String,
|
||||
// resumePosition: Double,
|
||||
// duration: Double,
|
||||
// speed: Double?,
|
||||
// metadata: Metadata?
|
||||
// )
|
||||
|
||||
// fun ensureThreadStarted()
|
||||
|
||||
class EventHandler : RsDeviceEventHandler {
|
||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>()
|
||||
var onTimeChanged = Event1<Double>()
|
||||
var onDurationChanged = Event1<Double>()
|
||||
var onVolumeChanged = Event1<Double>()
|
||||
var onSpeedChanged = Event1<Double>()
|
||||
var onMediaItemEnd = Event0()
|
||||
|
||||
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||
onConnectionStateChanged.emit(state)
|
||||
}
|
||||
|
||||
override fun volumeChanged(volume: Double) {
|
||||
onVolumeChanged.emit(volume)
|
||||
}
|
||||
|
||||
override fun timeChanged(time: Double) {
|
||||
onTimeChanged.emit(time)
|
||||
}
|
||||
|
||||
override fun playbackStateChanged(state: PlaybackState) {
|
||||
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
||||
}
|
||||
|
||||
override fun durationChanged(duration: Double) {
|
||||
onDurationChanged.emit(duration)
|
||||
}
|
||||
|
||||
override fun speedChanged(speed: Double) {
|
||||
onSpeedChanged.emit(speed)
|
||||
}
|
||||
|
||||
override fun sourceChanged(source: Source) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun keyEvent(event: KeyEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun mediaEvent(event: MediaEvent) {
|
||||
if (event.type == MediaItemEventType.END) {
|
||||
onMediaItemEnd.emit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun playbackError(message: String) {
|
||||
Logger.e(TAG, "Playback error: $message")
|
||||
}
|
||||
}
|
||||
|
||||
val eventHandler = EventHandler()
|
||||
val isReady: Boolean
|
||||
get() = device.isReady()
|
||||
val name: String
|
||||
get() = device.name()
|
||||
var usedRemoteAddress: InetAddress? = null
|
||||
var localAddress: InetAddress? = null
|
||||
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||
|
||||
val onConnectionStateChanged =
|
||||
Event1<CastConnectionState>()
|
||||
val onPlayChanged: Event1<Boolean>
|
||||
get() = eventHandler.onPlayChanged
|
||||
val onTimeChanged: Event1<Double>
|
||||
get() = eventHandler.onTimeChanged
|
||||
val onDurationChanged: Event1<Double>
|
||||
get() = eventHandler.onDurationChanged
|
||||
val onVolumeChanged: Event1<Double>
|
||||
get() = eventHandler.onVolumeChanged
|
||||
val onSpeedChanged: Event1<Double>
|
||||
get() = eventHandler.onSpeedChanged
|
||||
val onMediaItemEnd: Event0
|
||||
get() = eventHandler.onMediaItemEnd
|
||||
|
||||
fun resumePlayback() = device.resumePlayback()
|
||||
fun pausePlayback() = device.pausePlayback()
|
||||
fun stopPlayback() = device.stopPlayback()
|
||||
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||
fun changeVolume(newVolume: Double) {
|
||||
device.changeVolume(newVolume)
|
||||
volume = newVolume
|
||||
}
|
||||
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||
fun connect() = device.connect(
|
||||
ApplicationInfo(
|
||||
"Grayjay Android",
|
||||
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
),
|
||||
eventHandler,
|
||||
1000.toULong()
|
||||
)
|
||||
|
||||
fun disconnect() = device.disconnect()
|
||||
|
||||
fun getDeviceInfo(): CastingDeviceInfo {
|
||||
val info = device.getDeviceInfo()
|
||||
return CastingDeviceInfo(
|
||||
info.name,
|
||||
when (info.protocol) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
},
|
||||
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
||||
port = info.port.toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||
ipAddrToInetAddress(it)
|
||||
}
|
||||
|
||||
fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
@@ -62,18 +291,107 @@ abstract class CastingDevice {
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Video(
|
||||
contentType = contentType,
|
||||
url = contentId,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
@Throws
|
||||
abstract fun loadContent(
|
||||
fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Content(
|
||||
contentType = contentType,
|
||||
content = content,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata,
|
||||
requestHeaders = null,
|
||||
)
|
||||
)
|
||||
|
||||
abstract fun ensureThreadStarted()
|
||||
}
|
||||
var connectionState = CastConnectionState.DISCONNECTED
|
||||
val protocolType: CastProtocolType
|
||||
get() = when (device.castingProtocol()) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
}
|
||||
var volume: Double = 1.0
|
||||
var duration: Double = 0.0
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
var speed: Double = 0.0
|
||||
var isPlaying: Boolean = false
|
||||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff =
|
||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff
|
||||
}
|
||||
|
||||
init {
|
||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||
when (newState) {
|
||||
is DeviceConnectionState.Connected -> {
|
||||
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
|
||||
try {
|
||||
device.subscribeEvent(EventSubscription.MediaItemEnd)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
|
||||
}
|
||||
}
|
||||
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
||||
}
|
||||
|
||||
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
||||
}
|
||||
|
||||
DeviceConnectionState.Disconnected -> {
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
if (newState == DeviceConnectionState.Disconnected) {
|
||||
try {
|
||||
Logger.i(TAG, "Stopping device")
|
||||
device.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to stop device: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
||||
eventHandler.onTimeChanged.subscribe {
|
||||
lastTimeChangeTime_ms = System.currentTimeMillis()
|
||||
time = it
|
||||
}
|
||||
eventHandler.onDurationChanged.subscribe { duration = it }
|
||||
eventHandler.onVolumeChanged.subscribe { volume = it }
|
||||
eventHandler.onSpeedChanged.subscribe { speed = it }
|
||||
}
|
||||
|
||||
fun ensureThreadStarted() {}
|
||||
|
||||
companion object {
|
||||
private val TAG = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Build
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import org.fcast.sender_sdk.ApplicationInfo
|
||||
import org.fcast.sender_sdk.GenericKeyEvent
|
||||
import org.fcast.sender_sdk.GenericMediaEvent
|
||||
import org.fcast.sender_sdk.PlaybackState
|
||||
import org.fcast.sender_sdk.Source
|
||||
import java.net.InetAddress
|
||||
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
|
||||
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
||||
import org.fcast.sender_sdk.DeviceConnectionState
|
||||
import org.fcast.sender_sdk.DeviceFeature
|
||||
import org.fcast.sender_sdk.IpAddr
|
||||
import org.fcast.sender_sdk.LoadRequest
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
|
||||
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
||||
is IpAddr.V4 -> Inet4Address.getByAddress(
|
||||
byteArrayOf(
|
||||
addr.o1.toByte(),
|
||||
addr.o2.toByte(),
|
||||
addr.o3.toByte(),
|
||||
addr.o4.toByte()
|
||||
)
|
||||
)
|
||||
|
||||
is IpAddr.V6 -> Inet6Address.getByAddress(
|
||||
byteArrayOf(
|
||||
addr.o1.toByte(),
|
||||
addr.o2.toByte(),
|
||||
addr.o3.toByte(),
|
||||
addr.o4.toByte(),
|
||||
addr.o5.toByte(),
|
||||
addr.o6.toByte(),
|
||||
addr.o7.toByte(),
|
||||
addr.o8.toByte(),
|
||||
addr.o9.toByte(),
|
||||
addr.o10.toByte(),
|
||||
addr.o11.toByte(),
|
||||
addr.o12.toByte(),
|
||||
addr.o13.toByte(),
|
||||
addr.o14.toByte(),
|
||||
addr.o15.toByte(),
|
||||
addr.o16.toByte()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
class EventHandler : RsDeviceEventHandler {
|
||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>()
|
||||
var onTimeChanged = Event1<Double>()
|
||||
var onDurationChanged = Event1<Double>()
|
||||
var onVolumeChanged = Event1<Double>()
|
||||
var onSpeedChanged = Event1<Double>()
|
||||
|
||||
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||
onConnectionStateChanged.emit(state)
|
||||
}
|
||||
|
||||
override fun volumeChanged(volume: Double) {
|
||||
onVolumeChanged.emit(volume)
|
||||
}
|
||||
|
||||
override fun timeChanged(time: Double) {
|
||||
onTimeChanged.emit(time)
|
||||
}
|
||||
|
||||
override fun playbackStateChanged(state: PlaybackState) {
|
||||
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
||||
}
|
||||
|
||||
override fun durationChanged(duration: Double) {
|
||||
onDurationChanged.emit(duration)
|
||||
}
|
||||
|
||||
override fun speedChanged(speed: Double) {
|
||||
onSpeedChanged.emit(speed)
|
||||
}
|
||||
|
||||
override fun sourceChanged(source: Source) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun keyEvent(event: GenericKeyEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun mediaEvent(event: GenericMediaEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun playbackError(message: String) {
|
||||
Logger.e(TAG, "Playback error: $message")
|
||||
}
|
||||
}
|
||||
|
||||
val eventHandler = EventHandler()
|
||||
override val isReady: Boolean
|
||||
get() = device.isReady()
|
||||
override val name: String
|
||||
get() = device.name()
|
||||
override var usedRemoteAddress: InetAddress? = null
|
||||
override var localAddress: InetAddress? = null
|
||||
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||
|
||||
override val onConnectionStateChanged =
|
||||
Event1<CastConnectionState>()
|
||||
override val onPlayChanged: Event1<Boolean>
|
||||
get() = eventHandler.onPlayChanged
|
||||
override val onTimeChanged: Event1<Double>
|
||||
get() = eventHandler.onTimeChanged
|
||||
override val onDurationChanged: Event1<Double>
|
||||
get() = eventHandler.onDurationChanged
|
||||
override val onVolumeChanged: Event1<Double>
|
||||
get() = eventHandler.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double>
|
||||
get() = eventHandler.onSpeedChanged
|
||||
|
||||
override fun resumePlayback() = device.resumePlayback()
|
||||
override fun pausePlayback() = device.pausePlayback()
|
||||
override fun stopPlayback() = device.stopPlayback()
|
||||
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||
override fun changeVolume(newVolume: Double) {
|
||||
device.changeVolume(newVolume)
|
||||
volume = newVolume
|
||||
}
|
||||
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||
override fun connect() = device.connect(
|
||||
ApplicationInfo(
|
||||
"Grayjay Android",
|
||||
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
),
|
||||
eventHandler,
|
||||
1000.toULong()
|
||||
)
|
||||
|
||||
override fun disconnect() = device.disconnect()
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
val info = device.getDeviceInfo()
|
||||
return CastingDeviceInfo(
|
||||
info.name,
|
||||
when (info.protocol) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
},
|
||||
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
||||
port = info.port.toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||
ipAddrToInetAddress(it)
|
||||
}
|
||||
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Video(
|
||||
contentType = contentType,
|
||||
url = contentId,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata
|
||||
)
|
||||
)
|
||||
|
||||
override fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Content(
|
||||
contentType = contentType,
|
||||
content = content,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata,
|
||||
)
|
||||
)
|
||||
|
||||
override var connectionState = CastConnectionState.DISCONNECTED
|
||||
override val protocolType: CastProtocolType
|
||||
get() = when (device.castingProtocol()) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
}
|
||||
override var volume: Double = 1.0
|
||||
override var duration: Double = 0.0
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
override var time: Double = 0.0
|
||||
override var speed: Double = 0.0
|
||||
override var isPlaying: Boolean = false
|
||||
|
||||
override val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff =
|
||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff
|
||||
}
|
||||
|
||||
init {
|
||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||
when (newState) {
|
||||
is DeviceConnectionState.Connected -> {
|
||||
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
||||
}
|
||||
|
||||
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
||||
}
|
||||
|
||||
DeviceConnectionState.Disconnected -> {
|
||||
connectionState = CastConnectionState.DISCONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
if (newState == DeviceConnectionState.Disconnected) {
|
||||
try {
|
||||
Logger.i(TAG, "Stopping device")
|
||||
device.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to stop device: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
||||
eventHandler.onTimeChanged.subscribe {
|
||||
lastTimeChangeTime_ms = System.currentTimeMillis()
|
||||
time = it
|
||||
}
|
||||
eventHandler.onDurationChanged.subscribe { duration = it }
|
||||
eventHandler.onVolumeChanged.subscribe { volume = it }
|
||||
eventHandler.onSpeedChanged.subscribe { speed = it }
|
||||
}
|
||||
|
||||
override fun ensureThreadStarted() {}
|
||||
|
||||
companion object {
|
||||
private val TAG = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import java.net.InetAddress
|
||||
|
||||
enum class CastConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST;
|
||||
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||
encoder.encodeString(value.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||
val name = decoder.decodeString()
|
||||
return when (name) {
|
||||
"FASTCAST" -> FCAST // Handle the renamed case
|
||||
else -> CastProtocolType.valueOf(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CastingDeviceLegacy {
|
||||
abstract val protocol: CastProtocolType;
|
||||
abstract val isReady: Boolean;
|
||||
abstract var usedRemoteAddress: InetAddress?;
|
||||
abstract var localAddress: InetAddress?;
|
||||
abstract val canSetVolume: Boolean;
|
||||
abstract val canSetSpeed: Boolean;
|
||||
|
||||
var name: String? = null;
|
||||
var isPlaying: Boolean = false
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
if (changed) {
|
||||
onPlayChanged.emit(value);
|
||||
}
|
||||
};
|
||||
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
||||
time = value
|
||||
lastTimeChangeTime_ms = changeTime_ms
|
||||
onTimeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastDurationChangeTime_ms: Long = 0
|
||||
var duration: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
||||
duration = value
|
||||
lastDurationChangeTime_ms = changeTime_ms
|
||||
onDurationChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastVolumeChangeTime_ms: Long = 0
|
||||
var volume: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
||||
volume = value
|
||||
lastVolumeChangeTime_ms = changeTime_ms
|
||||
onVolumeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastSpeedChangeTime_ms: Long = 0
|
||||
var speed: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
||||
speed = value
|
||||
lastSpeedChangeTime_ms = changeTime_ms
|
||||
onSpeedChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff =
|
||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff;
|
||||
};
|
||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
|
||||
if (changed) {
|
||||
onConnectionStateChanged.emit(value);
|
||||
}
|
||||
};
|
||||
|
||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>();
|
||||
var onTimeChanged = Event1<Double>();
|
||||
var onDurationChanged = Event1<Double>();
|
||||
var onVolumeChanged = Event1<Double>();
|
||||
var onSpeedChanged = Event1<Double>();
|
||||
|
||||
abstract fun stopCasting();
|
||||
|
||||
abstract fun seekVideo(timeSeconds: Double);
|
||||
abstract fun stopVideo();
|
||||
abstract fun pauseVideo();
|
||||
abstract fun resumeVideo();
|
||||
abstract fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
);
|
||||
|
||||
abstract fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
);
|
||||
|
||||
open fun changeVolume(volume: Double) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
open fun changeSpeed(speed: Double) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
abstract fun start();
|
||||
abstract fun stop();
|
||||
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
||||
|
||||
abstract fun getAddresses(): List<InetAddress>;
|
||||
}
|
||||
|
||||
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
|
||||
override val isReady: Boolean get() = inner.isReady
|
||||
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
|
||||
override val localAddress: InetAddress? get() = inner.localAddress
|
||||
override val name: String? get() = inner.name
|
||||
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
|
||||
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
|
||||
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
|
||||
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
||||
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
||||
override var connectionState: CastConnectionState
|
||||
get() = inner.connectionState
|
||||
set(_) = Unit
|
||||
override val protocolType: CastProtocolType get() = inner.protocol
|
||||
override var isPlaying: Boolean
|
||||
get() = inner.isPlaying
|
||||
set(_) = Unit
|
||||
override val expectedCurrentTime: Double
|
||||
get() = inner.expectedCurrentTime
|
||||
override var speed: Double
|
||||
get() = inner.speed
|
||||
set(_) = Unit
|
||||
override var time: Double
|
||||
get() = inner.time
|
||||
set(_) = Unit
|
||||
override var duration: Double
|
||||
get() = inner.duration
|
||||
set(_) = Unit
|
||||
override var volume: Double
|
||||
get() = inner.volume
|
||||
set(_) = Unit
|
||||
|
||||
override fun canSetVolume(): Boolean = inner.canSetVolume
|
||||
override fun canSetSpeed(): Boolean = inner.canSetSpeed
|
||||
override fun resumePlayback() = inner.resumeVideo()
|
||||
override fun pausePlayback() = inner.pauseVideo()
|
||||
override fun stopPlayback() = inner.stopVideo()
|
||||
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
|
||||
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
|
||||
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
|
||||
override fun connect() = inner.start()
|
||||
override fun disconnect() = inner.stop()
|
||||
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
|
||||
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
|
||||
|
||||
override fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
|
||||
|
||||
override fun ensureThreadStarted() = when (inner) {
|
||||
is FCastCastingDevice -> inner.ensureThreadStarted()
|
||||
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -1,736 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.protos.ChromeCast
|
||||
import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: https://developers.google.com/cast/docs/media/messages
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = true;
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
|
||||
private var _streamType: String? = null;
|
||||
private var _contentType: String? = null;
|
||||
private var _contentId: String? = null;
|
||||
|
||||
private var _socket: SSLSocket? = null;
|
||||
private var _outputStream: DataOutputStream? = null;
|
||||
private var _outputStreamLock = Object();
|
||||
private var _inputStream: DataInputStream? = null;
|
||||
private var _inputStreamLock = Object();
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _requestId = 1;
|
||||
private var _started: Boolean = false;
|
||||
private var _sessionId: String? = null;
|
||||
private var _transportId: String? = null;
|
||||
private var _launching = false;
|
||||
private var _mediaSessionId: Int? = null;
|
||||
private var _thread: Thread? = null;
|
||||
private var _pingThread: Thread? = null;
|
||||
private var _launchRetries = 0
|
||||
private val MAX_LAUNCH_RETRIES = 3
|
||||
private var _lastLaunchTime_ms = 0L
|
||||
private var _retryJob: Job? = null
|
||||
private var _autoLaunchEnabled = true
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
||||
this.name = deviceInfo.name;
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
||||
this.port = deviceInfo.port;
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> {
|
||||
return addresses?.toList() ?: listOf();
|
||||
}
|
||||
|
||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
_streamType = streamType;
|
||||
_contentType = contentType;
|
||||
_contentId = contentId;
|
||||
|
||||
playVideo();
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
//TODO: Can maybe be implemented by sending data:contentType,base64...
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
private fun connectMediaChannel(transportId: String) {
|
||||
val connectObject = JSONObject();
|
||||
connectObject.put("type", "CONNECT");
|
||||
connectObject.put("connType", 0);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
||||
}
|
||||
|
||||
private fun requestMediaStatus() {
|
||||
val transportId = _transportId ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "GET_STATUS");
|
||||
loadObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
private fun playVideo() {
|
||||
val transportId = _transportId ?: return;
|
||||
val contentId = _contentId ?: return;
|
||||
val streamType = _streamType ?: return;
|
||||
val contentType = _contentType ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "LOAD");
|
||||
|
||||
val mediaObject = JSONObject();
|
||||
mediaObject.put("contentId", contentId);
|
||||
mediaObject.put("streamType", streamType);
|
||||
mediaObject.put("contentType", contentType);
|
||||
|
||||
if (time > 0.0) {
|
||||
val seekTime = time;
|
||||
loadObject.put("currentTime", seekTime);
|
||||
}
|
||||
|
||||
loadObject.put("media", mediaObject);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
|
||||
|
||||
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
|
||||
val json = loadObject.toString().replace("\\/","/");
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
|
||||
|
||||
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
|
||||
setSpeed(speedClamped)
|
||||
val mediaSessionId = _mediaSessionId ?: return
|
||||
val transportId = _transportId ?: return
|
||||
val setSpeedObject = JSONObject().apply {
|
||||
put("type", "SET_PLAYBACK_RATE")
|
||||
put("mediaSessionId", mediaSessionId)
|
||||
put("playbackRate", speedClamped)
|
||||
put("requestId", _requestId++)
|
||||
}
|
||||
|
||||
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
|
||||
}
|
||||
|
||||
override fun changeVolume(volume: Double) {
|
||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVolume(volume)
|
||||
val setVolumeObject = JSONObject();
|
||||
setVolumeObject.put("type", "SET_VOLUME");
|
||||
|
||||
val volumeObject = JSONObject();
|
||||
volumeObject.put("level", volume)
|
||||
setVolumeObject.put("volume", volumeObject);
|
||||
|
||||
setVolumeObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
val transportId = _transportId ?: return;
|
||||
val mediaSessionId = _mediaSessionId ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "SEEK");
|
||||
loadObject.put("mediaSessionId", mediaSessionId);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
loadObject.put("currentTime", timeSeconds);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val transportId = _transportId ?: return;
|
||||
val mediaSessionId = _mediaSessionId ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "PLAY");
|
||||
loadObject.put("mediaSessionId", mediaSessionId);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val transportId = _transportId ?: return;
|
||||
val mediaSessionId = _mediaSessionId ?: return;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "PAUSE");
|
||||
loadObject.put("mediaSessionId", mediaSessionId);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val transportId = _transportId ?: return;
|
||||
val mediaSessionId = _mediaSessionId ?: return;
|
||||
_contentId = null;
|
||||
_contentType = null;
|
||||
_streamType = null;
|
||||
|
||||
val loadObject = JSONObject();
|
||||
loadObject.put("type", "STOP");
|
||||
loadObject.put("mediaSessionId", mediaSessionId);
|
||||
loadObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
||||
}
|
||||
|
||||
private fun launchPlayer() {
|
||||
if (invokeInIOScopeIfRequired(::launchPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val launchObject = JSONObject();
|
||||
launchObject.put("type", "LAUNCH");
|
||||
launchObject.put("appId", "CC1AD845");
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
_lastLaunchTime_ms = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun getStatus() {
|
||||
if (invokeInIOScopeIfRequired(::getStatus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val launchObject = JSONObject();
|
||||
launchObject.put("type", "GET_STATUS");
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch { action(); }
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
val sessionId = _sessionId;
|
||||
if (sessionId != null) {
|
||||
val launchObject = JSONObject();
|
||||
launchObject.put("type", "STOP");
|
||||
launchObject.put("sessionId", sessionId);
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
|
||||
_contentId = null;
|
||||
_contentType = null;
|
||||
_streamType = null;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_transportId = null;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
||||
stop();
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
|
||||
_autoLaunchEnabled = true
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
||||
_launching = true;
|
||||
|
||||
ensureThreadsStarted();
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
fun ensureThreadsStarted() {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
val thread = _thread
|
||||
val pingThread = _pingThread
|
||||
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
|
||||
Log.i(TAG, "Restarting threads because one of the threads has died")
|
||||
|
||||
_scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
var connectedSocket: Socket? = null
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
||||
if (resultSocket == null) {
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
connectedSocket = resultSocket
|
||||
usedRemoteAddress = connectedSocket.inetAddress;
|
||||
localAddress = connectedSocket.localAddress;
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, null);
|
||||
|
||||
val factory = sslContext.socketFactory;
|
||||
|
||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
if (connectedSocket != null) {
|
||||
Logger.i(TAG, "Using connected socket.")
|
||||
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
|
||||
connectedSocket = null
|
||||
} else {
|
||||
Logger.i(TAG, "Using new socket.")
|
||||
val s = Socket().apply { this.connect(address, 2000) }
|
||||
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
|
||||
}
|
||||
|
||||
_socket?.startHandshake();
|
||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
||||
|
||||
try {
|
||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
||||
_inputStream = DataInputStream(_socket?.inputStream);
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
|
||||
try {
|
||||
val connectObject = JSONObject();
|
||||
connectObject.put("type", "CONNECT");
|
||||
connectObject.put("connType", 0);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
|
||||
_socket?.close();
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
getStatus();
|
||||
|
||||
val buffer = ByteArray(409600);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
|
||||
val message = synchronized(_inputStreamLock)
|
||||
{
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
val b2 = inputStream.readUnsignedByte();
|
||||
val b3 = inputStream.readUnsignedByte();
|
||||
val b4 = inputStream.readUnsignedByte();
|
||||
val size =
|
||||
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
return@synchronized null
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
inputStream.read(buffer, 0, size);
|
||||
|
||||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $msg");
|
||||
}
|
||||
return@synchronized msg
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.apply { start() };
|
||||
|
||||
//Start ping loop
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
val pingObject = JSONObject();
|
||||
pingObject.put("type", "PING");
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.");
|
||||
}
|
||||
|
||||
Thread.sleep(5000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
}.apply { start() };
|
||||
} else {
|
||||
Log.i(TAG, "Threads still alive, not restarted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
||||
try {
|
||||
val castMessage = ChromeCast.CastMessage.newBuilder()
|
||||
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
|
||||
.setSourceId(sourceId)
|
||||
.setDestinationId(destinationId)
|
||||
.setNamespace(namespace)
|
||||
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
|
||||
.setPayloadUtf8(json)
|
||||
.build();
|
||||
|
||||
sendMessage(castMessage.toByteArray());
|
||||
|
||||
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
//Log.d(TAG, "Sent channel message: $castMessage");
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(message: ChromeCast.CastMessage) {
|
||||
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
|
||||
val jsonObject = JSONObject(message.payloadUtf8);
|
||||
val type = jsonObject.getString("type");
|
||||
if (type == "RECEIVER_STATUS") {
|
||||
val status = jsonObject.getJSONObject("status");
|
||||
|
||||
var sessionIsRunning = false;
|
||||
if (status.has("applications")) {
|
||||
val applications = status.getJSONArray("applications");
|
||||
|
||||
for (i in 0 until applications.length()) {
|
||||
val applicationUpdate = applications.getJSONObject(i);
|
||||
|
||||
val appId = applicationUpdate.getString("appId");
|
||||
Logger.i(TAG, "Status update received appId (appId: $appId)");
|
||||
|
||||
if (appId == "CC1AD845") {
|
||||
sessionIsRunning = true;
|
||||
_autoLaunchEnabled = false
|
||||
|
||||
if (_sessionId == null) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_sessionId = applicationUpdate.getString("sessionId");
|
||||
_launchRetries = 0
|
||||
|
||||
val transportId = applicationUpdate.getString("transportId");
|
||||
connectMediaChannel(transportId);
|
||||
Logger.i(TAG, "Connected to media channel $transportId");
|
||||
_transportId = transportId;
|
||||
|
||||
requestMediaStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionIsRunning) {
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
_transportId = null
|
||||
|
||||
if (_autoLaunchEnabled) {
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else {
|
||||
// Maybe the first GET_STATUS came back empty; still try launching
|
||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||
_launching = true
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
}
|
||||
} else {
|
||||
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
|
||||
Logger.i(TAG, "Unable to start media receiver on device")
|
||||
stop()
|
||||
}
|
||||
} else {
|
||||
if (_retryJob == null) {
|
||||
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
||||
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
||||
delay(5000)
|
||||
getStatus()
|
||||
_retryJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
_autoLaunchEnabled = false
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
//val volumeControlType = volume.getString("controlType");
|
||||
val volumeLevel = volume.getString("level").toDouble();
|
||||
val volumeMuted = volume.getBoolean("muted");
|
||||
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
|
||||
setVolume(if (volumeMuted) 0.0 else volumeLevel);
|
||||
|
||||
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
|
||||
} else if (type == "MEDIA_STATUS") {
|
||||
val statuses = jsonObject.getJSONArray("status");
|
||||
for (i in 0 until statuses.length()) {
|
||||
val status = statuses.getJSONObject(i);
|
||||
_mediaSessionId = status.getInt("mediaSessionId");
|
||||
|
||||
val playerState = status.getString("playerState");
|
||||
val currentTime = status.getDouble("currentTime");
|
||||
if (status.has("media")) {
|
||||
val media = status.getJSONObject("media")
|
||||
if (media.has("duration")) {
|
||||
setDuration(media.getDouble("duration"))
|
||||
}
|
||||
}
|
||||
|
||||
isPlaying = playerState == "PLAYING";
|
||||
if (isPlaying || playerState == "PAUSED") {
|
||||
setTime(currentTime);
|
||||
}
|
||||
|
||||
val playbackRate = status.getInt("playbackRate");
|
||||
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
|
||||
|
||||
if (_contentType == null) {
|
||||
stopVideo();
|
||||
}
|
||||
}
|
||||
|
||||
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
|
||||
if (needsLoad && _contentId != null && _mediaSessionId == null) {
|
||||
Logger.i(TAG, "Receiver idle, sending initial LOAD")
|
||||
playVideo()
|
||||
}
|
||||
} else if (type == "CLOSE") {
|
||||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stopCasting();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw Exception("Payload type ${message.payloadType} is not implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMessage(data: ByteArray) {
|
||||
val outputStream = _outputStream;
|
||||
if (outputStream == null) {
|
||||
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized(_outputStreamLock)
|
||||
{
|
||||
val serializedSizeBE = ByteArray(4);
|
||||
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
|
||||
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
|
||||
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
|
||||
serializedSizeBE[3] = (data.size and 0xff).toByte();
|
||||
outputStream.write(serializedSizeBE);
|
||||
outputStream.write(data);
|
||||
}
|
||||
|
||||
//Log.d(TAG, "Sent ${data.size} bytes.");
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping...");
|
||||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_contentId = null
|
||||
_contentType = null
|
||||
_streamType = null
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
||||
if (scopeIO != null && socket != null) {
|
||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
||||
|
||||
scopeIO.launch {
|
||||
socket.close();
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
scopeIO.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
||||
}
|
||||
} else {
|
||||
scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
||||
}
|
||||
|
||||
_pingThread = null;
|
||||
_thread = null;
|
||||
_scopeIO = null;
|
||||
_socket = null;
|
||||
_outputStream = null;
|
||||
_inputStream = null;
|
||||
_mediaSessionId = null;
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "ChromecastCastingDevice";
|
||||
|
||||
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,636 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
|
||||
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSeekMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
|
||||
import com.futo.platformplayer.casting.models.FCastVersionMessage
|
||||
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.getConnectedSocket
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.toHexString
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.PrivateKey
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyAgreement
|
||||
import javax.crypto.spec.DHParameterSpec
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
enum class Opcode(val value: Byte) {
|
||||
None(0),
|
||||
Play(1),
|
||||
Pause(2),
|
||||
Resume(3),
|
||||
Stop(4),
|
||||
Seek(5),
|
||||
PlaybackUpdate(6),
|
||||
VolumeUpdate(7),
|
||||
SetVolume(8),
|
||||
PlaybackError(9),
|
||||
SetSpeed(10),
|
||||
Version(11),
|
||||
Ping(12),
|
||||
Pong(13);
|
||||
|
||||
companion object {
|
||||
private val _map = entries.associateBy { it.value }
|
||||
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
|
||||
}
|
||||
}
|
||||
|
||||
class FCastCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: TODO
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
||||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = true;
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
|
||||
private var _socket: Socket? = null;
|
||||
private var _outputStream: OutputStream? = null;
|
||||
private var _inputStream: InputStream? = null;
|
||||
private var _scopeIO: CoroutineScope? = null;
|
||||
private var _started: Boolean = false;
|
||||
private var _version: Long = 1;
|
||||
private var _thread: Thread? = null
|
||||
private var _pingThread: Thread? = null
|
||||
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
||||
private var _outputStreamLock = Object()
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
||||
this.name = deviceInfo.name;
|
||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
||||
this.port = deviceInfo.port;
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> {
|
||||
return addresses?.toList() ?: listOf();
|
||||
}
|
||||
|
||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO: Remove this later, temporary for the transition
|
||||
if (_version <= 1L) {
|
||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
url = contentId,
|
||||
time = resumePosition,
|
||||
speed = speed
|
||||
));
|
||||
|
||||
setSpeed(speed ?: 1.0);
|
||||
}
|
||||
|
||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
||||
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO: Remove this later, temporary for the transition
|
||||
if (_version <= 1L) {
|
||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
||||
|
||||
setTime(resumePosition);
|
||||
setDuration(duration);
|
||||
send(Opcode.Play, FCastPlayMessage(
|
||||
container = contentType,
|
||||
content = content,
|
||||
time = resumePosition,
|
||||
speed = speed
|
||||
));
|
||||
|
||||
setSpeed(speed ?: 1.0);
|
||||
}
|
||||
|
||||
override fun changeVolume(volume: Double) {
|
||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVolume(volume);
|
||||
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSpeed(speed);
|
||||
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
|
||||
}
|
||||
|
||||
override fun seekVideo(timeSeconds: Double) {
|
||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(Opcode.Seek, FCastSeekMessage(
|
||||
time = timeSeconds
|
||||
));
|
||||
}
|
||||
|
||||
override fun resumeVideo() {
|
||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(Opcode.Resume);
|
||||
}
|
||||
|
||||
override fun pauseVideo() {
|
||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(Opcode.Pause);
|
||||
}
|
||||
|
||||
override fun stopVideo() {
|
||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(Opcode.Stop);
|
||||
}
|
||||
|
||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
||||
_scopeIO?.launch {
|
||||
try {
|
||||
action();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to invoke in IO scope.", e)
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override fun stopCasting() {
|
||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopVideo();
|
||||
|
||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
||||
stop();
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
|
||||
_started = true;
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
||||
ensureThreadStarted();
|
||||
Logger.i(TAG, "Started.");
|
||||
}
|
||||
|
||||
fun ensureThreadStarted() {
|
||||
val adrs = addresses ?: return;
|
||||
|
||||
val thread = _thread
|
||||
val pingThread = _pingThread
|
||||
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
|
||||
Log.i(TAG, "(Re)starting thread because the thread has died")
|
||||
|
||||
_scopeIO?.let {
|
||||
it.cancel()
|
||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
||||
}
|
||||
|
||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
_thread = Thread {
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Log.i(TAG, "Connection thread started.")
|
||||
|
||||
var connectedSocket: Socket? = null
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
|
||||
|
||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
||||
|
||||
if (resultSocket == null) {
|
||||
Log.i(TAG, "Connection failed, waiting 1 seconds.")
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Connection succeeded.")
|
||||
|
||||
connectedSocket = resultSocket
|
||||
usedRemoteAddress = connectedSocket.inetAddress
|
||||
localAddress = connectedSocket.localAddress
|
||||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
||||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
Logger.i(TAG, "Connecting to FastCast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
if (connectedSocket != null) {
|
||||
Logger.i(TAG, "Using connected socket.");
|
||||
_socket = connectedSocket
|
||||
connectedSocket = null
|
||||
} else {
|
||||
Logger.i(TAG, "Using new socket.");
|
||||
_socket = Socket().apply { this.connect(address, 2000) };
|
||||
}
|
||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
||||
|
||||
_outputStream = _socket?.outputStream;
|
||||
_inputStream = _socket?.inputStream;
|
||||
} catch (e: IOException) {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
Logger.i(TAG, "Started receiving.");
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
|
||||
var headerBytesRead = 0
|
||||
while (headerBytesRead < 4) {
|
||||
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
|
||||
if (read == -1)
|
||||
throw Exception("Stream closed")
|
||||
headerBytesRead += read
|
||||
}
|
||||
|
||||
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
||||
break
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
var bytesRead = 0
|
||||
while (bytesRead < size) {
|
||||
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
|
||||
if (read == -1)
|
||||
throw Exception("Stream closed")
|
||||
bytesRead += read
|
||||
}
|
||||
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
|
||||
val opcode = messageBytes[0];
|
||||
var json: String? = null;
|
||||
if (size > 1) {
|
||||
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(Opcode.find(opcode), json);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e)
|
||||
break
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
||||
break
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Exception while receiving.", e);
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped connection loop.");
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}.apply { start() }
|
||||
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
while (_scopeIO?.isActive == true) {
|
||||
if (connectionState == CastConnectionState.CONNECTED) {
|
||||
try {
|
||||
send(Opcode.Ping)
|
||||
if (System.currentTimeMillis() - _lastPongTime > 15000) {
|
||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
|
||||
try {
|
||||
_socket?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.")
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
Logger.i(TAG, "Stopped ping loop.")
|
||||
}.apply { start() }
|
||||
} else {
|
||||
Log.i(TAG, "Thread was still alive, not restarted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(opcode: Opcode, json: String? = null) {
|
||||
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
|
||||
|
||||
when (opcode) {
|
||||
Opcode.PlaybackUpdate -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got playback update without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
|
||||
setTime(playbackUpdate.time, playbackUpdate.generationTime);
|
||||
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
|
||||
isPlaying = when (playbackUpdate.state) {
|
||||
1 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
Opcode.VolumeUpdate -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got volume update without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
|
||||
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
|
||||
}
|
||||
Opcode.PlaybackError -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got playback error without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
|
||||
Logger.e(TAG, "Remote casting playback error received: $playbackError")
|
||||
}
|
||||
Opcode.Version -> {
|
||||
if (json == null) {
|
||||
Logger.w(TAG, "Got version without JSON, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
|
||||
_version = version.version;
|
||||
Logger.i(TAG, "Remote version received: $version")
|
||||
}
|
||||
Opcode.Ping -> send(Opcode.Pong)
|
||||
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(opcode: Opcode, message: String? = null) {
|
||||
ensureNotMainThread()
|
||||
|
||||
synchronized (_outputStreamLock) {
|
||||
try {
|
||||
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
|
||||
val size = 1 + data.size
|
||||
val outputStream = _outputStream
|
||||
if (outputStream == null) {
|
||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
|
||||
return
|
||||
}
|
||||
|
||||
val serializedSizeLE = ByteArray(4)
|
||||
serializedSizeLE[0] = (size and 0xff).toByte()
|
||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
|
||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
|
||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
|
||||
outputStream.write(serializedSizeLE)
|
||||
|
||||
val opcodeBytes = ByteArray(1)
|
||||
opcodeBytes[0] = opcode.value
|
||||
outputStream.write(opcodeBytes)
|
||||
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to send message.", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> send(opcode: Opcode, message: T) {
|
||||
try {
|
||||
send(opcode, message?.let { Json.encodeToString(it) })
|
||||
} catch (e: Throwable) {
|
||||
Log.i(TAG, "Failed to encode message to string.", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Logger.i(TAG, "Stopping...");
|
||||
usedRemoteAddress = null;
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
//TODO: Kill and/or join thread?
|
||||
_thread = null;
|
||||
_pingThread = null;
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
||||
if (scopeIO != null && socket != null) {
|
||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
||||
|
||||
scopeIO.launch {
|
||||
socket.close();
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
scopeIO.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
||||
}
|
||||
} else {
|
||||
scopeIO?.cancel();
|
||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
||||
}
|
||||
|
||||
_scopeIO = null;
|
||||
_socket = null;
|
||||
_outputStream = null;
|
||||
_inputStream = null;
|
||||
connectionState = CastConnectionState.DISCONNECTED;
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "FCastCastingDevice";
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
|
||||
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
|
||||
}
|
||||
|
||||
fun generateKeyPair(): KeyPair {
|
||||
//modp14
|
||||
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
|
||||
val g = BigInteger("2", 16)
|
||||
val dhSpec = DHParameterSpec(p, g)
|
||||
|
||||
val keyGen = KeyPairGenerator.getInstance("DH")
|
||||
keyGen.initialize(dhSpec)
|
||||
|
||||
return keyGen.generateKeyPair()
|
||||
}
|
||||
|
||||
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
|
||||
val keyFactory = KeyFactory.getInstance("DH")
|
||||
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
|
||||
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
|
||||
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
|
||||
|
||||
val keyAgreement = KeyAgreement.getInstance("DH")
|
||||
keyAgreement.init(privateKey)
|
||||
keyAgreement.doPhase(receivedPublicKey, true)
|
||||
|
||||
val sharedSecret = keyAgreement.generateSecret()
|
||||
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
val hashedSecret = sha256.digest(sharedSecret)
|
||||
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
|
||||
|
||||
return SecretKeySpec(hashedSecret, "AES")
|
||||
}
|
||||
|
||||
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
|
||||
val iv = cipher.iv
|
||||
val json = Json.encodeToString(decryptedMessage)
|
||||
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
|
||||
return FCastEncryptedMessage(
|
||||
version = 1,
|
||||
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
|
||||
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
)
|
||||
}
|
||||
|
||||
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
|
||||
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
|
||||
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
||||
val decryptedJson = cipher.doFinal(encrypted)
|
||||
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
@@ -40,6 +41,7 @@ import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAud
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.awaitCancelConverted
|
||||
import com.futo.platformplayer.builders.DashBuilder
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
@@ -56,16 +58,22 @@ import com.futo.platformplayer.views.casting.CastView.Companion
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fcast.sender_sdk.CastContext
|
||||
import org.fcast.sender_sdk.DeviceInfo
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
import java.net.Inet6Address
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
||||
|
||||
abstract class StateCasting {
|
||||
class StateCasting {
|
||||
val _scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
val _scopeMain = CoroutineScope(Dispatchers.Main);
|
||||
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
||||
@@ -82,6 +90,7 @@ abstract class StateCasting {
|
||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||
val onActiveDeviceDurationChanged = Event1<Double>();
|
||||
val onActiveDeviceVolumeChanged = Event1<Double>();
|
||||
val onActiveDeviceMediaItemEnd = Event0()
|
||||
var activeDevice: CastingDevice? = null;
|
||||
private var _videoExecutor: JSRequestExecutor? = null
|
||||
private var _audioExecutor: JSRequestExecutor? = null
|
||||
@@ -90,15 +99,163 @@ abstract class StateCasting {
|
||||
val isCasting: Boolean get() = activeDevice != null;
|
||||
private val _castId = AtomicInteger(0)
|
||||
|
||||
abstract fun handleUrl(url: String)
|
||||
abstract fun onStop()
|
||||
abstract fun start(context: Context)
|
||||
abstract fun stop()
|
||||
private val _context = CastContext()
|
||||
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
||||
|
||||
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice?
|
||||
abstract fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
|
||||
): Job?
|
||||
class DiscoveryEventHandler(
|
||||
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
||||
private val onDeviceRemoved: (String) -> Unit,
|
||||
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
||||
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
||||
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
||||
onDeviceAdded(deviceInfo)
|
||||
}
|
||||
|
||||
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
||||
onDeviceUpdated(deviceInfo)
|
||||
}
|
||||
|
||||
override fun deviceRemoved(deviceName: String) {
|
||||
onDeviceRemoved(deviceName)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
if (BuildConfig.DEBUG) {
|
||||
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUrl(url: String) {
|
||||
try {
|
||||
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
||||
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
||||
connectDevice(CastingDevice(foundDevice))
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle URL: $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun onStop() {
|
||||
val ad = activeDevice ?: return
|
||||
_resumeCastingDevice = ad.getDeviceInfo()
|
||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||
Logger.i(TAG, "Stopping active device because of onStop.")
|
||||
try {
|
||||
ad.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to disconnect from device: $e")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start(context: Context) {
|
||||
if (_started)
|
||||
return
|
||||
_started = true
|
||||
|
||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||
_resumeCastingDevice = null
|
||||
|
||||
Logger.i(TAG, "CastingService starting...")
|
||||
|
||||
_castServer.start()
|
||||
enableDeveloper(true)
|
||||
|
||||
Logger.i(TAG, "CastingService started.")
|
||||
|
||||
_deviceDiscoverer = NsdDeviceDiscoverer(
|
||||
context,
|
||||
DiscoveryEventHandler(
|
||||
{ deviceInfo -> // Added
|
||||
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
||||
val device = _context.createDeviceFromInfo(deviceInfo)
|
||||
val deviceHandle = CastingDevice(device)
|
||||
devices[deviceHandle.device.name()] = deviceHandle
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceAdded.emit(deviceHandle)
|
||||
}
|
||||
},
|
||||
{ deviceName -> // Removed
|
||||
invokeInMainScopeIfRequired {
|
||||
if (devices.containsKey(deviceName)) {
|
||||
val device = devices.remove(deviceName)
|
||||
if (device != null) {
|
||||
onDeviceRemoved.emit(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deviceInfo -> // Updated
|
||||
Logger.i(TAG, "Device updated: $deviceInfo")
|
||||
val handle = devices[deviceInfo.name]
|
||||
if (handle != null && handle is CastingDevice) {
|
||||
handle.device.setPort(deviceInfo.port)
|
||||
handle.device.setAddresses(deviceInfo.addresses)
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceChanged.emit(handle)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (!_started) {
|
||||
return
|
||||
}
|
||||
|
||||
_started = false
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
_scopeIO.cancel()
|
||||
_scopeMain.cancel()
|
||||
|
||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||
val d = activeDevice
|
||||
activeDevice = null
|
||||
try {
|
||||
d?.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to disconnect device: $e")
|
||||
}
|
||||
|
||||
_castServer.stop()
|
||||
_castServer.removeAllHandlers()
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_deviceDiscoverer = null
|
||||
}
|
||||
|
||||
fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? = null
|
||||
|
||||
fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? {
|
||||
try {
|
||||
val rsAddrs =
|
||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
||||
val rsDeviceInfo = RsDeviceInfo(
|
||||
name = deviceInfo.name,
|
||||
protocol = when (deviceInfo.type) {
|
||||
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||
else -> throw IllegalArgumentException()
|
||||
},
|
||||
addresses = rsAddrs,
|
||||
port = deviceInfo.port.toUShort(),
|
||||
)
|
||||
|
||||
return CastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
val ad = activeDevice
|
||||
@@ -145,6 +302,7 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.clear();
|
||||
device.onVolumeChanged.clear();
|
||||
device.onDurationChanged.clear();
|
||||
device.onMediaItemEnd.clear();
|
||||
ad.disconnect()
|
||||
}
|
||||
|
||||
@@ -159,6 +317,7 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.clear();
|
||||
device.onVolumeChanged.clear();
|
||||
device.onDurationChanged.clear();
|
||||
device.onMediaItemEnd.clear();
|
||||
activeDevice = null;
|
||||
}
|
||||
|
||||
@@ -222,6 +381,9 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.subscribe {
|
||||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||
};
|
||||
device.onMediaItemEnd.subscribe {
|
||||
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
|
||||
}
|
||||
|
||||
try {
|
||||
device.connect();
|
||||
@@ -232,6 +394,7 @@ abstract class StateCasting {
|
||||
device.onTimeChanged.clear();
|
||||
device.onVolumeChanged.clear();
|
||||
device.onDurationChanged.clear();
|
||||
device.onMediaItemEnd.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1233,6 +1396,47 @@ abstract class StateCasting {
|
||||
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
}
|
||||
|
||||
private fun escapeXml(s: String): String =
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
private fun injectSubtitleAdaptationSet(
|
||||
mpd: String,
|
||||
subtitleUrl: String,
|
||||
mimeType: String,
|
||||
lang: String = "und",
|
||||
label: String = "Subtitles"
|
||||
): String {
|
||||
val mt = mimeType.lowercase()
|
||||
val codecs = when (mt) {
|
||||
"text/vtt", "text/webvtt" -> "wvtt"
|
||||
"application/ttml+xml", "application/ttml" -> "stpp"
|
||||
else -> null
|
||||
}
|
||||
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
|
||||
|
||||
val adaptation = """
|
||||
<AdaptationSet id="123456" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
|
||||
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
|
||||
<Label>${escapeXml(label)}</Label>
|
||||
<Representation id="123457"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
|
||||
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
""".trimIndent()
|
||||
|
||||
val periodClose = Regex("</Period\\s*>", RegexOption.IGNORE_CASE)
|
||||
|
||||
return if (periodClose.containsMatchIn(mpd)) {
|
||||
mpd.replaceFirst(periodClose, adaptation + "\n</Period>")
|
||||
} else {
|
||||
mpd
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
@@ -1254,30 +1458,42 @@ abstract class StateCasting {
|
||||
val videoUrl = url + videoPath
|
||||
val audioUrl = url + audioPath
|
||||
|
||||
val subtitleMimeTypeFull = subtitleSource?.format ?: "text/vtt"
|
||||
val subtitleMimeTypeForMpd = subtitleMimeTypeFull.substringBefore(';').trim()
|
||||
|
||||
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
||||
return@withContext subtitleSource.getSubtitlesURI();
|
||||
} else null;
|
||||
subtitleSource.getSubtitlesURI()
|
||||
} else null
|
||||
|
||||
var subtitlesUrl: String? = null;
|
||||
var subtitlesUrl: String? = null
|
||||
if (subtitlesUri != null) {
|
||||
if(subtitlesUri.scheme == "file") {
|
||||
var content: String? = null;
|
||||
val inputStream = contentResolver.openInputStream(subtitlesUri);
|
||||
inputStream?.use { stream ->
|
||||
val reader = stream.bufferedReader();
|
||||
content = reader.use { it.readText() };
|
||||
when (subtitlesUri.scheme) {
|
||||
"file", "content" -> {
|
||||
val content = withContext(Dispatchers.IO) {
|
||||
contentResolver.openInputStream(subtitlesUri)?.use { stream ->
|
||||
stream.bufferedReader().use { it.readText() }
|
||||
}
|
||||
}
|
||||
|
||||
if (!content.isNullOrEmpty()) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content, subtitleMimeTypeFull)
|
||||
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||
true
|
||||
).withTag("castDashRaw")
|
||||
|
||||
subtitlesUrl = url + subtitlePath
|
||||
}
|
||||
}
|
||||
|
||||
if (content != null) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("cast");
|
||||
"http", "https" -> {
|
||||
// Receiver will fetch directly (works only if it doesn’t need auth/headers)
|
||||
subtitlesUrl = subtitlesUri.toString()
|
||||
}
|
||||
|
||||
subtitlesUrl = url + subtitlePath;
|
||||
} else {
|
||||
subtitlesUrl = subtitlesUri.toString();
|
||||
else -> {
|
||||
Logger.w(TAG, "Unsupported subtitlesUri scheme: ${subtitlesUri.scheme}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1323,8 +1539,22 @@ abstract class StateCasting {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
if (subtitlesUrl != null) {
|
||||
dashContent = injectSubtitleAdaptationSet(
|
||||
dashContent,
|
||||
subtitlesUrl!!,
|
||||
subtitleMimeTypeForMpd
|
||||
)
|
||||
}
|
||||
|
||||
var hasAudioInDash = false
|
||||
for (representation in representationRegex.findAll(dashContent)) {
|
||||
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
||||
|
||||
if (mediaType.startsWith("audio/")) {
|
||||
hasAudioInDash = true
|
||||
}
|
||||
|
||||
dashContent = mediaInitializationRegex.replace(dashContent) {
|
||||
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
|
||||
return@replace it.value
|
||||
@@ -1348,16 +1578,20 @@ abstract class StateCasting {
|
||||
throw Exception("Audio source without request executor not supported")
|
||||
}
|
||||
|
||||
if (audioSource != null && audioSource.hasRequestExecutor) {
|
||||
val oldExecutor = _audioExecutor;
|
||||
oldExecutor?.closeAsync();
|
||||
_audioExecutor = audioSource.getRequestExecutor()
|
||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||
val oldVideoExecutor = _videoExecutor
|
||||
oldVideoExecutor?.closeAsync()
|
||||
_videoExecutor = videoSource.getRequestExecutor()
|
||||
}
|
||||
|
||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||
val oldExecutor = _videoExecutor;
|
||||
oldExecutor?.closeAsync();
|
||||
_videoExecutor = videoSource.getRequestExecutor()
|
||||
if (audioSource != null) {
|
||||
val oldExecutor = _audioExecutor
|
||||
oldExecutor?.closeAsync()
|
||||
_audioExecutor = audioSource.getRequestExecutor()
|
||||
} else if (hasAudioInDash && videoSource != null) {
|
||||
val oldExecutor = _audioExecutor
|
||||
oldExecutor?.closeAsync()
|
||||
_audioExecutor = _videoExecutor
|
||||
}
|
||||
|
||||
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
|
||||
@@ -1388,7 +1622,7 @@ abstract class StateCasting {
|
||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||
).withTag("castDashRaw");
|
||||
}
|
||||
if (audioSource != null) {
|
||||
if (audioSource != null || (audioSource == null && hasAudioInDash)) {
|
||||
_castServer.addHandlerWithAllowAllOptions(
|
||||
HttpFunctionHandler("GET", audioPath) { httpContext ->
|
||||
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
||||
@@ -1453,11 +1687,7 @@ abstract class StateCasting {
|
||||
}
|
||||
|
||||
companion object {
|
||||
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
|
||||
StateCastingExp()
|
||||
} else {
|
||||
StateCastingLegacy()
|
||||
}
|
||||
var instance = StateCasting()
|
||||
private val representationRegex = Regex(
|
||||
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
|
||||
RegexOption.DOT_MATCHES_ALL
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
import org.fcast.sender_sdk.CastContext
|
||||
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
||||
|
||||
class StateCastingExp : StateCasting() {
|
||||
private val _context = CastContext()
|
||||
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
||||
|
||||
class DiscoveryEventHandler(
|
||||
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
||||
private val onDeviceRemoved: (String) -> Unit,
|
||||
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
||||
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
||||
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
||||
onDeviceAdded(deviceInfo)
|
||||
}
|
||||
|
||||
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
||||
onDeviceUpdated(deviceInfo)
|
||||
}
|
||||
|
||||
override fun deviceRemoved(deviceName: String) {
|
||||
onDeviceRemoved(deviceName)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
if (BuildConfig.DEBUG) {
|
||||
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleUrl(url: String) {
|
||||
try {
|
||||
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
||||
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
||||
connectDevice(CastingDeviceExp(foundDevice))
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle URL: $e")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
val ad = activeDevice ?: return
|
||||
_resumeCastingDevice = ad.getDeviceInfo()
|
||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||
Logger.i(TAG, "Stopping active device because of onStop.")
|
||||
try {
|
||||
ad.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to disconnect from device: $e")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun start(context: Context) {
|
||||
if (_started)
|
||||
return
|
||||
_started = true
|
||||
|
||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||
_resumeCastingDevice = null
|
||||
|
||||
Logger.i(TAG, "CastingService starting...")
|
||||
|
||||
_castServer.start()
|
||||
enableDeveloper(true)
|
||||
|
||||
Logger.i(TAG, "CastingService started.")
|
||||
|
||||
_deviceDiscoverer = NsdDeviceDiscoverer(
|
||||
context,
|
||||
DiscoveryEventHandler(
|
||||
{ deviceInfo -> // Added
|
||||
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
||||
val device = _context.createDeviceFromInfo(deviceInfo)
|
||||
val deviceHandle = CastingDeviceExp(device)
|
||||
devices[deviceHandle.device.name()] = deviceHandle
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceAdded.emit(deviceHandle)
|
||||
}
|
||||
},
|
||||
{ deviceName -> // Removed
|
||||
invokeInMainScopeIfRequired {
|
||||
if (devices.containsKey(deviceName)) {
|
||||
val device = devices.remove(deviceName)
|
||||
if (device != null) {
|
||||
onDeviceRemoved.emit(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deviceInfo -> // Updated
|
||||
Logger.i(TAG, "Device updated: $deviceInfo")
|
||||
val handle = devices[deviceInfo.name]
|
||||
if (handle != null && handle is CastingDeviceExp) {
|
||||
handle.device.setPort(deviceInfo.port)
|
||||
handle.device.setAddresses(deviceInfo.addresses)
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceChanged.emit(handle)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stop() {
|
||||
if (!_started) {
|
||||
return
|
||||
}
|
||||
|
||||
_started = false
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
_scopeIO.cancel()
|
||||
_scopeMain.cancel()
|
||||
|
||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||
val d = activeDevice
|
||||
activeDevice = null
|
||||
try {
|
||||
d?.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to disconnect device: $e")
|
||||
}
|
||||
|
||||
_castServer.stop()
|
||||
_castServer.removeAllHandlers()
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_deviceDiscoverer = null
|
||||
}
|
||||
|
||||
override fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? = null
|
||||
|
||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp? {
|
||||
try {
|
||||
val rsAddrs =
|
||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
||||
val rsDeviceInfo = RsDeviceInfo(
|
||||
name = deviceInfo.name,
|
||||
protocol = when (deviceInfo.type) {
|
||||
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||
else -> throw IllegalArgumentException()
|
||||
},
|
||||
addresses = rsAddrs,
|
||||
port = deviceInfo.port.toUShort(),
|
||||
)
|
||||
|
||||
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateCastingExp"
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.InetAddress
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class StateCastingLegacy : StateCasting() {
|
||||
private var _nsdManager: NsdManager? = null
|
||||
|
||||
private val _discoveryListeners = mapOf(
|
||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||
)
|
||||
|
||||
override fun handleUrl(url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
if (uri.scheme != "fcast") {
|
||||
throw Exception("Expected scheme to be FCast")
|
||||
}
|
||||
|
||||
val type = uri.host
|
||||
if (type != "r") {
|
||||
throw Exception("Expected type r")
|
||||
}
|
||||
|
||||
val connectionInfo = uri.pathSegments[0]
|
||||
val json =
|
||||
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
.toString(Charsets.UTF_8)
|
||||
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
|
||||
val tcpService = networkConfig.services.first { v -> v.type == 0 }
|
||||
|
||||
val foundInfo = addRememberedDevice(
|
||||
CastingDeviceInfo(
|
||||
name = networkConfig.name,
|
||||
type = CastProtocolType.FCAST,
|
||||
addresses = networkConfig.addresses.toTypedArray(),
|
||||
port = tcpService.port
|
||||
)
|
||||
)
|
||||
|
||||
if (foundInfo != null) {
|
||||
connectDevice(deviceFromInfo(foundInfo))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
val ad = activeDevice ?: return;
|
||||
_resumeCastingDevice = ad.getDeviceInfo()
|
||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||
Logger.i(TAG, "Stopping active device because of onStop.");
|
||||
ad.disconnect();
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun start(context: Context) {
|
||||
if (_started)
|
||||
return;
|
||||
_started = true;
|
||||
|
||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||
_resumeCastingDevice = null;
|
||||
|
||||
Logger.i(TAG, "CastingService starting...");
|
||||
|
||||
_castServer.start();
|
||||
enableDeveloper(true);
|
||||
|
||||
Logger.i(TAG, "CastingService started.");
|
||||
|
||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
startDiscovering()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
try {
|
||||
stopServiceDiscovery(it.value)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stop() {
|
||||
if (!_started)
|
||||
return;
|
||||
|
||||
_started = false;
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
stopDiscovering()
|
||||
_scopeIO.cancel();
|
||||
_scopeMain.cancel();
|
||||
|
||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||
val d = activeDevice;
|
||||
activeDevice = null;
|
||||
d?.disconnect();
|
||||
|
||||
_castServer.stop();
|
||||
_castServer.removeAllHandlers();
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_nsdManager = null
|
||||
}
|
||||
|
||||
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||
return object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(regType: String) {
|
||||
Log.d(TAG, "Service discovery started for $regType")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceLost(service: NsdServiceInfo) {
|
||||
Log.e(TAG, "service lost: $service")
|
||||
// TODO: Handle service lost, e.g., remove device
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceFound(service: NsdServiceInfo) {
|
||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
service.hostAddresses.toTypedArray()
|
||||
} else {
|
||||
arrayOf(service.host)
|
||||
}
|
||||
addOrUpdate(service.serviceName, addresses, service.port)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
_nsdManager?.registerServiceInfoCallback(
|
||||
service,
|
||||
{ it.run() },
|
||||
object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||
addOrUpdate(
|
||||
serviceInfo.serviceName,
|
||||
serviceInfo.hostAddresses.toTypedArray(),
|
||||
serviceInfo.port
|
||||
)
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
Log.v(TAG, "onServiceLost: $service")
|
||||
// TODO: Handle service lost
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "Resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||
addOrUpdate(
|
||||
serviceInfo.serviceName,
|
||||
arrayOf(serviceInfo.host),
|
||||
serviceInfo.port
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? {
|
||||
val d = activeDevice;
|
||||
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
|
||||
return _scopeMain.launch {
|
||||
while (true) {
|
||||
val device = instance.activeDevice
|
||||
if (device == null || !device.isPlaying) {
|
||||
break
|
||||
}
|
||||
|
||||
delay(1000)
|
||||
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
|
||||
setTime(time_ms)
|
||||
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
|
||||
return CastingDeviceLegacyWrapper(
|
||||
when (deviceInfo.type) {
|
||||
CastProtocolType.CHROMECAST -> {
|
||||
ChromecastCastingDevice(deviceInfo);
|
||||
}
|
||||
|
||||
CastProtocolType.AIRPLAY -> {
|
||||
AirPlayCastingDevice(deviceInfo);
|
||||
}
|
||||
|
||||
CastProtocolType.FCAST -> {
|
||||
FCastCastingDevice(deviceInfo);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun addOrUpdateChromeCastDevice(
|
||||
name: String,
|
||||
addresses: Array<InetAddress>,
|
||||
port: Int
|
||||
) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = {
|
||||
CastingDeviceLegacyWrapper(
|
||||
ChromecastCastingDevice(
|
||||
name,
|
||||
addresses,
|
||||
port
|
||||
)
|
||||
)
|
||||
},
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.addresses = addresses;
|
||||
d.inner.port = port;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = {
|
||||
CastingDeviceLegacyWrapper(
|
||||
AirPlayCastingDevice(
|
||||
name,
|
||||
addresses,
|
||||
port
|
||||
)
|
||||
)
|
||||
},
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.port = port;
|
||||
d.inner.addresses = addresses;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.port = port;
|
||||
d.inner.addresses = addresses;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private inline fun addOrUpdateCastDevice(
|
||||
name: String,
|
||||
deviceFactory: () -> CastingDevice,
|
||||
deviceUpdater: (device: CastingDevice) -> Boolean
|
||||
) {
|
||||
var invokeEvents: (() -> Unit)? = null;
|
||||
|
||||
synchronized(devices) {
|
||||
val device = devices[name];
|
||||
if (device != null) {
|
||||
val changed = deviceUpdater(device);
|
||||
if (changed) {
|
||||
invokeEvents = {
|
||||
onDeviceChanged.emit(device);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val newDevice = deviceFactory();
|
||||
this.devices[name] = newDevice
|
||||
|
||||
invokeEvents = {
|
||||
onDeviceAdded.emit(newDevice);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
invokeEvents?.let { _scopeMain.launch { it(); }; };
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class FCastNetworkConfig(
|
||||
val name: String,
|
||||
val addresses: List<String>,
|
||||
val services: List<FCastService>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class FCastService(
|
||||
val port: Int,
|
||||
val type: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateCastingLegacy"
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.futo.platformplayer.casting.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class FCastPlayMessage(
|
||||
val container: String,
|
||||
val url: String? = null,
|
||||
val content: String? = null,
|
||||
val time: Double? = null,
|
||||
val speed: Double? = null
|
||||
) { }
|
||||
|
||||
@Serializable
|
||||
data class FCastSeekMessage(
|
||||
val time: Double
|
||||
) { }
|
||||
|
||||
@Serializable
|
||||
data class FCastPlaybackUpdateMessage(
|
||||
val generationTime: Long,
|
||||
val time: Double,
|
||||
val duration: Double,
|
||||
val state: Int,
|
||||
val speed: Double
|
||||
) { }
|
||||
|
||||
|
||||
@Serializable
|
||||
data class FCastVolumeUpdateMessage(
|
||||
val generationTime: Long,
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastSetVolumeMessage(
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastSetSpeedMessage(
|
||||
val speed: Double
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastPlaybackErrorMessage(
|
||||
val message: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastVersionMessage(
|
||||
val version: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastKeyExchangeMessage(
|
||||
val version: Long,
|
||||
val publicKey: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastDecryptedMessage(
|
||||
val opcode: Long,
|
||||
val message: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FCastEncryptedMessage(
|
||||
val version: Long,
|
||||
val iv: String?,
|
||||
val blob: String
|
||||
)
|
||||
@@ -40,13 +40,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
||||
_buttonTutorial = findViewById(R.id.button_tutorial)
|
||||
|
||||
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
|
||||
R.array.exp_casting_device_type_array
|
||||
} else {
|
||||
R.array.casting_device_type_array
|
||||
}
|
||||
|
||||
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
|
||||
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
_spinnerType.adapter = adapter;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
@@ -174,13 +173,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
_textType.text = "AirPlay";
|
||||
}
|
||||
CastProtocolType.FCAST -> {
|
||||
_imageDevice.setImageResource(
|
||||
if (Settings.instance.casting.experimentalCasting) {
|
||||
R.drawable.ic_exp_fc
|
||||
} else {
|
||||
R.drawable.ic_fc
|
||||
}
|
||||
)
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc)
|
||||
_textType.text = "FCast";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import com.arthenica.ffmpegkit.StatisticsCallback
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
@@ -40,10 +42,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
|
||||
import com.futo.platformplayer.exceptions.DownloadException
|
||||
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
@@ -86,6 +91,9 @@ import kotlin.time.times
|
||||
class VideoDownload {
|
||||
var state: State = State.QUEUED;
|
||||
|
||||
@Contextual
|
||||
@Transient
|
||||
var plugin: IPlatformClient? = null;
|
||||
var video: SerializedPlatformVideo? = null;
|
||||
var videoDetails: SerializedPlatformVideoDetails? = null;
|
||||
|
||||
@@ -101,6 +109,7 @@ class VideoDownload {
|
||||
|
||||
var videoSource: VideoUrlSource?;
|
||||
var audioSource: AudioUrlSource?;
|
||||
var overrideResultAudioSource: IAudioSource? = null;
|
||||
@Contextual
|
||||
@Transient
|
||||
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
|
||||
@@ -270,7 +279,7 @@ class VideoDownload {
|
||||
|
||||
//Fetch full video object and determine source
|
||||
if(video != null && videoDetails == null) {
|
||||
val original = StatePlatform.instance.getContentDetails(video!!.url).await();
|
||||
val original = if (plugin != null) plugin!!.getContentDetails(video!!.url) else StatePlatform.instance.getContentDetails(video!!.url)?.await();
|
||||
if(original !is IPlatformVideoDetails)
|
||||
throw IllegalStateException("Original content is not media?");
|
||||
|
||||
@@ -437,6 +446,11 @@ class VideoDownload {
|
||||
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
|
||||
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
|
||||
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
|
||||
if(actualVideoSource is JSDashManifestRawSource && actualAudioSource == null) {
|
||||
audioFileNameBase = "${videoDetails!!.id.value!!}-[unknown]".sanitizeFileName();
|
||||
audioFileNameExt = videoAudioContainerToExtension(actualVideoSource!!.container);
|
||||
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
|
||||
}
|
||||
}
|
||||
if(actualAudioSource != null) {
|
||||
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
|
||||
@@ -490,7 +504,11 @@ class VideoDownload {
|
||||
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
||||
if(actualAudioSource == null)
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 3,
|
||||
File(downloadDir, audioFileName!!));
|
||||
else
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 1);
|
||||
}
|
||||
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
|
||||
});
|
||||
@@ -530,7 +548,7 @@ class VideoDownload {
|
||||
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
|
||||
}
|
||||
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
|
||||
});
|
||||
@@ -589,38 +607,54 @@ class VideoDownload {
|
||||
}
|
||||
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" })
|
||||
require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val concatInput = buildString {
|
||||
append("concat:")
|
||||
append(
|
||||
segmentFiles.joinToString("|") { file ->
|
||||
file.absolutePath
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
|
||||
|
||||
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
//No callback
|
||||
}
|
||||
|
||||
val executorService = Executors.newSingleThreadExecutor()
|
||||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
|
||||
val session = FFmpegKit.executeAsync(
|
||||
cmd,
|
||||
{ completedSession ->
|
||||
executorService.shutdown()
|
||||
|
||||
if (ReturnCode.isSuccess(completedSession.returnCode)) {
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
|
||||
"Command cancelled"
|
||||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
"Command failed with state '${completedSession.state}' " +
|
||||
"and return code ${completedSession.returnCode}, " +
|
||||
"stack trace ${completedSession.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
{ Logger.v(TAG, it.message) },
|
||||
{ log ->
|
||||
Logger.v(TAG, log.message)
|
||||
},
|
||||
statisticsCallback,
|
||||
executorService
|
||||
)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
session.cancel()
|
||||
executorService.shutdownNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -856,14 +890,19 @@ class VideoDownload {
|
||||
return downloadedTotalLength
|
||||
}
|
||||
|
||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit, downloadType: Int = 0, targetFileAudio: File? = null): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
if(targetFileAudio?.exists() ?: false)
|
||||
targetFileAudio.delete();
|
||||
|
||||
targetFile.createNewFile();
|
||||
targetFileAudio?.createNewFile();
|
||||
|
||||
val sourceLength: Long?;
|
||||
val sourceLengthAudio: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
|
||||
|
||||
var executor: JSRequestExecutor? = null;
|
||||
try{
|
||||
@@ -874,14 +913,27 @@ class VideoDownload {
|
||||
throw IllegalStateException("No manifest after generation");
|
||||
|
||||
//TODO: Temporary naive assume single-sourced dash
|
||||
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest);
|
||||
if(foundTemplate == null || foundTemplate.groupValues.size != 3)
|
||||
val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
|
||||
val foundTemplate = when(downloadType) {
|
||||
1 -> foundTemplates.find({ it.groupValues[1].contains("video/") });
|
||||
2 -> foundTemplates.find({ it.groupValues[1].contains("audio/") });
|
||||
else -> foundTemplates.find({ it.groupValues[1].contains("video/") });
|
||||
}
|
||||
if(foundTemplate == null || foundTemplate.groupValues.size != 4)
|
||||
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
|
||||
val foundTemplateUrl = foundTemplate.groupValues[1];
|
||||
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]);
|
||||
val foundTemplateUrl = foundTemplate.groupValues[2];
|
||||
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
|
||||
if(foundCues.count() <= 0)
|
||||
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
|
||||
|
||||
val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
|
||||
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
|
||||
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
|
||||
val foundCues2Downloaded = hashSetOf<MatchResult>();
|
||||
|
||||
if(foundTemplate2 != null)
|
||||
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
|
||||
|
||||
executor = if(source is JSSource && source.hasRequestExecutor)
|
||||
source.getRequestExecutor();
|
||||
else
|
||||
@@ -896,13 +948,17 @@ class VideoDownload {
|
||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||
|
||||
var written: Long = 0;
|
||||
var written2: Long = 0;
|
||||
var indexCounter = 0;
|
||||
var indexCounter2 = 0;
|
||||
onProgress(foundCues.count().toLong(), 0, 0);
|
||||
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
|
||||
val lastCue = foundCues.lastOrNull();
|
||||
for(cue in foundCues) {
|
||||
val t = cue.groupValues[1];
|
||||
val d = cue.groupValues[2];
|
||||
|
||||
|
||||
Logger.i(TAG, "Downloading cue ${indexCounter}")
|
||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||
val modified = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
@@ -918,17 +974,60 @@ class VideoDownload {
|
||||
speedTracker.addWork(data.size.toLong());
|
||||
written += data.size;
|
||||
|
||||
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed);
|
||||
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
|
||||
|
||||
|
||||
indexCounter++;
|
||||
|
||||
if(foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
|
||||
val toDownload = if(lastCue != null && cue == lastCue)
|
||||
foundCues2.filter { !foundCues2Downloaded.contains(it) }.toList() else
|
||||
foundCues2.filter { !foundCues2Downloaded.contains(it) && (it.groupValues[1].toLong()) < t.toLong() }.toList();
|
||||
Logger.i(TAG, "Downloading audio cues (${toDownload.size})")
|
||||
for(cue2 in toDownload) {
|
||||
val index2 = foundCues2.indexOf(cue2);
|
||||
val t2 = cue2.groupValues[1];
|
||||
val d2 = cue2.groupValues[2];
|
||||
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
|
||||
val modified2 = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
|
||||
else {
|
||||
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
|
||||
if(!resp.isOk)
|
||||
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||
resp.body!!.bytes()
|
||||
}
|
||||
fileStream2.write(data, 0, data.size);
|
||||
speedTracker.addWork(data.size.toLong());
|
||||
written2 += data.size;
|
||||
indexCounter2++;
|
||||
|
||||
foundCues2Downloaded.add(cue2);
|
||||
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceLength = written;
|
||||
sourceLengthAudio = written2;
|
||||
|
||||
Logger.i(TAG, "$name downloadSource Finished");
|
||||
}
|
||||
catch(scriptEx: ScriptReloadRequiredException) {
|
||||
if(targetFile.exists() ?: false)
|
||||
targetFile.delete();
|
||||
if(targetFileAudio?.exists() ?: false)
|
||||
targetFileAudio.delete();
|
||||
|
||||
createNewPluginClient();
|
||||
throw scriptEx;
|
||||
}
|
||||
catch(ioex: IOException) {
|
||||
if(targetFile.exists() ?: false)
|
||||
targetFile.delete();
|
||||
if(targetFileAudio?.exists() ?: false)
|
||||
targetFileAudio.delete();
|
||||
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||
throw Exception("Not enough space on device", ioex);
|
||||
else
|
||||
@@ -937,14 +1036,37 @@ class VideoDownload {
|
||||
catch(ex: Throwable) {
|
||||
if(targetFile.exists() ?: false)
|
||||
targetFile.delete();
|
||||
if(targetFileAudio?.exists() ?: false)
|
||||
targetFileAudio.delete();
|
||||
throw ex;
|
||||
}
|
||||
finally {
|
||||
fileStream.close();
|
||||
fileStream2?.close();
|
||||
executor?.closeAsync()
|
||||
}
|
||||
if(sourceLengthAudio != null && sourceLengthAudio > 0)
|
||||
audioFileSize = sourceLengthAudio
|
||||
return sourceLength!!;
|
||||
}
|
||||
|
||||
fun createNewPluginClient() {
|
||||
UIDialogs.appToast("Download creating new client at request of plugin");
|
||||
cleanupPluginClient();
|
||||
plugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }?.getCopy(false, true);
|
||||
plugin?.initialize();
|
||||
}
|
||||
fun cleanupPluginClient() {
|
||||
val oldPlugin = plugin;
|
||||
plugin = null;
|
||||
try {
|
||||
oldPlugin?.disable();
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to dispose download client: ${ex.message}" , ex);
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
@@ -1304,7 +1426,7 @@ class VideoDownload {
|
||||
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
|
||||
}
|
||||
}
|
||||
if(audioSourceToUse != null) {
|
||||
if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
|
||||
if(audioFilePath == null)
|
||||
throw IllegalStateException("Missing audio file name after download");
|
||||
val expectedFile = File(audioFilePath!!);
|
||||
@@ -1327,7 +1449,7 @@ class VideoDownload {
|
||||
Logger.i(TAG, "VideoDownload Complete [${name}]");
|
||||
val existing = StateDownloads.instance.getCachedVideo(id);
|
||||
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
|
||||
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
|
||||
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(overrideResultAudioSource ?: audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
|
||||
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
|
||||
|
||||
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
|
||||
@@ -1369,6 +1491,10 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanup(){
|
||||
cleanupPluginClient()
|
||||
}
|
||||
|
||||
enum class State {
|
||||
QUEUED,
|
||||
PREPARING,
|
||||
@@ -1392,6 +1518,8 @@ class VideoDownload {
|
||||
const val GROUP_WATCHLATER= "WatchLater";
|
||||
|
||||
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
|
||||
val REGEX_DASH_TEMPLATE_WITH_MIME = Regex("<Representation.*?mimeType=\\\"(.*?)\\\".*?>.*?<SegmentTemplate .*?media=\\\"(.*?)\\\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
|
||||
val REGEX_CODECS = Regex("codecs=\\\"(.*?)\\\"")
|
||||
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
|
||||
|
||||
fun videoContainerToExtension(container: String): String? {
|
||||
@@ -1411,6 +1539,16 @@ class VideoDownload {
|
||||
return "video";//throw IllegalStateException("Unknown container: " + container)
|
||||
}
|
||||
|
||||
//TODO: Change usages of this to an accurate container instead of infering it.
|
||||
fun videoAudioContainerToExtension(container: String): String? {
|
||||
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
|
||||
return "mp4a";
|
||||
else if (container.contains("video/webm"))
|
||||
return "webm";
|
||||
else
|
||||
return "mp4a";//throw IllegalStateException("Unknown container: " + container)
|
||||
}
|
||||
|
||||
fun audioContainerToExtension(container: String): String {
|
||||
if (container.contains("audio/mp4"))
|
||||
return "mp4a";
|
||||
|
||||
@@ -47,7 +47,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
private val _progressBar: ProgressBar;
|
||||
private val _spinnerSortBy: Spinner;
|
||||
private val _containerSortBy: LinearLayout;
|
||||
private val _announcementView: AnnouncementView;
|
||||
//private val _announcementView: AnnouncementView;
|
||||
private val _tagsView: TagsView;
|
||||
private val _textCentered: TextView;
|
||||
private val _emptyPagerContainer: FrameLayout;
|
||||
@@ -87,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
_textCentered = findViewById(R.id.text_centered);
|
||||
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
|
||||
_progressBar = findViewById(R.id.progress_bar);
|
||||
_announcementView = findViewById(R.id.announcement_view)
|
||||
//_announcementView = findViewById(R.id.announcement_view)
|
||||
_progressBar.inactiveColor = Color.TRANSPARENT;
|
||||
|
||||
_swipeRefresh = findViewById(R.id.swipe_refresh);
|
||||
@@ -192,7 +192,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
}
|
||||
|
||||
protected fun showAnnouncementView() {
|
||||
_announcementView.visibility = View.VISIBLE
|
||||
//_announcementView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
|
||||
|
||||
+69
-15
@@ -33,6 +33,7 @@ import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
@@ -723,15 +724,17 @@ class VideoDetailView : ConstraintLayout {
|
||||
val activeDevice = StateCasting.instance.activeDevice;
|
||||
if (activeDevice != null) {
|
||||
handlePlayChanged(it);
|
||||
|
||||
val v = video;
|
||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
||||
Log.i(TAG, "Next video (loop?)")
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
StateCasting.instance.onActiveDeviceMediaItemEnd.subscribe(this) {
|
||||
val activeDevice = StateCasting.instance.activeDevice;
|
||||
if (activeDevice != null) {
|
||||
Log.i(TAG, "Next video (loop?)")
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
|
||||
StateCasting.instance.onActiveDeviceTimeChanged.subscribe(this) {
|
||||
if (_isCasting) {
|
||||
setLastPositionMilliseconds((it * 1000.0).toLong(), true);
|
||||
@@ -1273,6 +1276,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceMediaItemEnd.remove(this)
|
||||
StateApp.instance.preventPictureInPicture.remove(this);
|
||||
StatePlayer.instance.onQueueChanged.remove(this);
|
||||
StatePlayer.instance.onVideoChanging.remove(this);
|
||||
@@ -2420,9 +2424,54 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
val doDedup = Settings.instance.playback.simplifySources;
|
||||
|
||||
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
|
||||
?.distinct()
|
||||
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||
lang -> videoSources
|
||||
.filter { v -> v.language == lang }
|
||||
.map { it.height * it.width }
|
||||
.distinct()
|
||||
.map { res -> Pair(res, lang) }
|
||||
} else listOf();
|
||||
|
||||
|
||||
Log.i(TAG, "Language count: ${allLanguages}");
|
||||
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||
var selectedLanguage: String? = null;
|
||||
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||
SlideUpMenuButtonList(this.context, null, "language_filter", true).apply {
|
||||
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||
|
||||
if(english != null)
|
||||
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||
if(originalLanguage != null)
|
||||
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||
setButtons(languageFilterLabels, selectedLanguage);
|
||||
onClick.subscribe { selected ->
|
||||
setSelected(selected);
|
||||
|
||||
videoSourceItems.forEach {
|
||||
val item = it.itemTag;
|
||||
if(item is IVideoSource) {
|
||||
if(item.language == selected)
|
||||
it.visibility = View.VISIBLE;
|
||||
else
|
||||
it.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else null;
|
||||
|
||||
val bestVideoSources = if(doDedup && videoSources != null) (langResCombinations
|
||||
?.map { comb -> VideoHelper.selectBestVideoSource(videoSources.filter { comb.first == it.height * it.width && comb.second == it.language }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
|
||||
?.plus(videoSources.filter { it is IHLSManifestSource || it is IDashManifestSource }))
|
||||
?.distinct()
|
||||
?.filterNotNull()
|
||||
@@ -2438,7 +2487,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
|
||||
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
|
||||
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
||||
R.string.quality), null, true,
|
||||
R.string.quality), null, false,
|
||||
qualityPlaybackSpeedTitle,
|
||||
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
||||
@@ -2528,11 +2577,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
call = { _player.selectAudioTrack(it.bitrate) });
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
|
||||
if(languageFilters != null) languageFilters else null,
|
||||
if(bestVideoSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||
*bestVideoSources
|
||||
.map {
|
||||
(bestVideoSources.map {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
SlideUpMenuItem(this.context,
|
||||
@@ -2541,8 +2589,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
|
||||
(prefix + it.codec.trim()).trim(),
|
||||
tag = it,
|
||||
call = { handleSelectVideoTrack(it) });
|
||||
}.toList().toTypedArray())
|
||||
call = { handleSelectVideoTrack(it) }).apply {
|
||||
videoSourceItems.add(this);
|
||||
if(selectedLanguage != null) {
|
||||
if(it.language != selectedLanguage)
|
||||
this.visibility = View.GONE;
|
||||
}
|
||||
};
|
||||
}).toList())
|
||||
else null,
|
||||
if(bestAudioSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||
|
||||
+49
@@ -7,6 +7,9 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
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.fragment.mainactivity.main.CreatorsFragment
|
||||
@@ -17,18 +20,54 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
|
||||
import com.futo.platformplayer.models.SearchType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.views.casting.CastButton
|
||||
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GeneralTopBarFragment : TopFragment() {
|
||||
private var _buttonSearch: ImageButton? = null;
|
||||
private var _buttonCast: CastButton? = null;
|
||||
|
||||
private var _buttonNotifs: ConstraintLayout? = null;
|
||||
private var _buttonNotifIcon: ImageView? = null;
|
||||
private var _buttonNotifCount: TextView? = null;
|
||||
|
||||
init {
|
||||
StateAnnouncement.instance.onAnnouncementChanged.subscribe {
|
||||
lifecycleScope?.launch(Dispatchers.Main) {
|
||||
updateNotifCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotifCount() {
|
||||
val currentAnnouncements = StateAnnouncement.instance.getVisibleAnnouncements();
|
||||
if(currentAnnouncements.any())
|
||||
_buttonNotifCount?.let {
|
||||
it.text = currentAnnouncements.size.toString();
|
||||
it.visibility = View.VISIBLE;
|
||||
}
|
||||
else
|
||||
_buttonNotifCount?.let {
|
||||
it.text = currentAnnouncements.size.toString();
|
||||
it.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShown(parameter: Any?) {
|
||||
if(currentMain is CreatorsFragment) {
|
||||
_buttonSearch?.setImageResource(R.drawable.ic_person_search_300w);
|
||||
} else {
|
||||
_buttonSearch?.setImageResource(R.drawable.ic_search_300w);
|
||||
}
|
||||
if(currentMain is NotificationOverlayView.Frag) {
|
||||
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications_filled)
|
||||
}
|
||||
else {
|
||||
_buttonNotifIcon?.setImageResource(R.drawable.ic_notifications)
|
||||
}
|
||||
}
|
||||
override fun onHide() {
|
||||
|
||||
@@ -44,6 +83,16 @@ class GeneralTopBarFragment : TopFragment() {
|
||||
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
|
||||
_buttonCast = view.findViewById(R.id.button_cast);
|
||||
|
||||
_buttonNotifs = view.findViewById(R.id.button_notifs);
|
||||
_buttonNotifIcon = view.findViewById(R.id.button_notifs_icon);
|
||||
_buttonNotifCount = view.findViewById(R.id.button_notifs_count);
|
||||
|
||||
updateNotifCount();
|
||||
|
||||
_buttonNotifs?.setOnClickListener {
|
||||
navigate<NotificationOverlayView.Frag>();
|
||||
}
|
||||
|
||||
buttonSearch.setOnClickListener {
|
||||
if(currentMain is CreatorsFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
|
||||
|
||||
@@ -52,8 +52,8 @@ class VideoHelper {
|
||||
fun isDownloadable(source: IVideoSource) = (source is IVideoUrlSource || source is IHLSManifestSource || source is JSDashManifestRawSource) && source !is IWidevineSource
|
||||
fun isDownloadable(source: IAudioSource) = (source is IAudioUrlSource || source is IHLSManifestAudioSource || source is JSDashManifestRawAudioSource) && source !is IWidevineSource
|
||||
|
||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers);
|
||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>) : IVideoSource? {
|
||||
fun selectBestVideoSource(desc: IVideoSourceDescriptor, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? = selectBestVideoSource(desc.videoSources.toList(), desiredPixelCount, prefContainers, preferredLanguage);
|
||||
fun selectBestVideoSource(sources: Iterable<IVideoSource>, desiredPixelCount : Int, prefContainers : Array<String>, preferredLanguage: String? = null) : IVideoSource? {
|
||||
val targetVideo = if(desiredPixelCount > 0) {
|
||||
sources.toList().minByOrNull { x -> abs(x.height * x.width - desiredPixelCount) };
|
||||
} else {
|
||||
@@ -63,12 +63,34 @@ class VideoHelper {
|
||||
val hasPriority = sources.any { it.priority };
|
||||
|
||||
val targetPixelCount = if(targetVideo != null) targetVideo.width * targetVideo.height else desiredPixelCount;
|
||||
val altSources = if(hasPriority) {
|
||||
|
||||
//Filter priority
|
||||
var altSources = if(hasPriority) {
|
||||
sources.filter { it.priority }.sortedBy { x -> abs(x.height * x.width - targetPixelCount) };
|
||||
} else {
|
||||
sources.filter { it.height == (targetVideo?.height ?: 0) };
|
||||
}
|
||||
|
||||
//Filter Original
|
||||
val hasOriginal = altSources.any { it.original == true };
|
||||
if(hasOriginal && Settings.instance.playback.preferOriginalAudio)
|
||||
altSources = altSources.filter { it.original == true };
|
||||
|
||||
//Filter Language
|
||||
val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) {
|
||||
preferredLanguage
|
||||
} else {
|
||||
if(altSources.any { it.language == Language.ENGLISH })
|
||||
Language.ENGLISH;
|
||||
else
|
||||
Language.UNKNOWN;
|
||||
}
|
||||
if(altSources.any { it.language == languageToFilter }) {
|
||||
altSources.filter { it.language == languageToFilter }.sortedBy { it.bitrate }.toList();
|
||||
} else {
|
||||
altSources.sortedBy { it.bitrate }
|
||||
}
|
||||
|
||||
var bestSource = altSources.firstOrNull();
|
||||
for (prefContainer in prefContainers) {
|
||||
val betterSource = altSources.firstOrNull { it.container == prefContainer };
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,873 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
@@ -1,733 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/*
|
||||
* 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();
|
||||
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,895 +0,0 @@
|
||||
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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,752 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
/*
|
||||
* 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
@@ -1,499 +0,0 @@
|
||||
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
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
public class PoTokenError extends SabrStreamError {
|
||||
public PoTokenError(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
public class SabrStreamConsumedError extends Exception {
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.exceptions;
|
||||
|
||||
public class SabrStreamError extends RuntimeException {
|
||||
public SabrStreamError(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
public class CaptionSelector extends FormatSelector {
|
||||
public CaptionSelector(String displayName, boolean discardMedia) {
|
||||
super(displayName, discardMedia);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMimePrefix() {
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
public class ConsumedRange {
|
||||
public long startSequenceNumber;
|
||||
public long endSequenceNumber;
|
||||
public long startTimeMs;
|
||||
public long durationMs;
|
||||
|
||||
public ConsumedRange(long startTimeMs, long durationMs, long startSequenceNumber, long endSequenceNumber) {
|
||||
this.startTimeMs = startTimeMs;
|
||||
this.durationMs = durationMs;
|
||||
this.startSequenceNumber = startSequenceNumber;
|
||||
this.endSequenceNumber = endSequenceNumber;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class FormatSelector {
|
||||
public final String displayName;
|
||||
public final List<FormatId> formatIds = new ArrayList<>();
|
||||
public final boolean discardMedia;
|
||||
|
||||
public FormatSelector(String displayName, boolean discardMedia) {
|
||||
this.displayName = displayName;
|
||||
this.discardMedia = discardMedia;
|
||||
}
|
||||
|
||||
public String getMimePrefix() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean match(FormatId formatId, String mimeType) {
|
||||
return formatIds.contains(formatId)
|
||||
|| (formatIds.isEmpty() && getMimePrefix() != null && mimeType != null && mimeType.toLowerCase().startsWith(getMimePrefix()));
|
||||
}
|
||||
|
||||
public boolean isDiscardMedia() {
|
||||
return discardMedia;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class InitializedFormat {
|
||||
public final FormatId formatId;
|
||||
public final int durationMs;
|
||||
public final int endTimeMs;
|
||||
public final String mimeType;
|
||||
public final String videoId;
|
||||
public final FormatSelector formatSelector;
|
||||
public int totalSegments;
|
||||
public final boolean discard;
|
||||
public int sequenceLmt = -1;
|
||||
public Segment currentSegment;
|
||||
public Segment initSegment;
|
||||
public final List<ConsumedRange> consumedRanges = new ArrayList<>();
|
||||
|
||||
public InitializedFormat(
|
||||
FormatId formatId,
|
||||
int durationMs,
|
||||
int endTimeMs,
|
||||
String mimeType,
|
||||
String videoId,
|
||||
FormatSelector formatSelector,
|
||||
int totalSegments,
|
||||
boolean discard
|
||||
) {
|
||||
this.formatId = formatId;
|
||||
this.durationMs = durationMs;
|
||||
this.endTimeMs = endTimeMs;
|
||||
this.mimeType = mimeType;
|
||||
this.videoId = videoId;
|
||||
this.formatSelector = formatSelector;
|
||||
this.totalSegments = totalSegments;
|
||||
this.discard = discard;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class Segment {
|
||||
public final FormatId formatId;
|
||||
public final boolean isInitSegment;
|
||||
public final int durationMs;
|
||||
public final int startDataRange;
|
||||
public long sequenceNumber;
|
||||
public final long contentLength;
|
||||
public final boolean contentLengthEstimated;
|
||||
public final int startMs;
|
||||
public final InitializedFormat initializedFormat;
|
||||
public final boolean durationEstimated;
|
||||
public final boolean discard;
|
||||
public final boolean consumed;
|
||||
public final int sequenceLmt;
|
||||
public int receivedDataLength;
|
||||
|
||||
public Segment(FormatId formatId,
|
||||
boolean isInitSegment,
|
||||
int durationMs,
|
||||
int startDataRange,
|
||||
long sequenceNumber,
|
||||
long contentLength,
|
||||
boolean contentLengthEstimated,
|
||||
int startMs,
|
||||
InitializedFormat initializedFormat,
|
||||
boolean durationEstimated,
|
||||
boolean discard,
|
||||
boolean consumed,
|
||||
int sequenceLmt) {
|
||||
this.formatId = formatId;
|
||||
this.isInitSegment = isInitSegment;
|
||||
this.durationMs = durationMs;
|
||||
this.startDataRange = startDataRange;
|
||||
this.sequenceNumber = sequenceNumber;
|
||||
this.contentLength = contentLength;
|
||||
this.contentLengthEstimated = contentLengthEstimated;
|
||||
this.startMs = startMs;
|
||||
this.initializedFormat = initializedFormat;
|
||||
this.durationEstimated = durationEstimated;
|
||||
this.discard = discard;
|
||||
this.consumed = consumed;
|
||||
this.sequenceLmt = sequenceLmt;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.models;
|
||||
|
||||
public class VideoSelector extends FormatSelector {
|
||||
public VideoSelector(String displayName, boolean discardMedia) {
|
||||
super(displayName, discardMedia);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMimePrefix() {
|
||||
return "video";
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class FormatInitializedSabrPart implements SabrPart {
|
||||
public final FormatId formatId;
|
||||
public final FormatSelector formatSelector;
|
||||
|
||||
public FormatInitializedSabrPart(FormatId formatId, FormatSelector formatSelector) {
|
||||
this.formatId = formatId;
|
||||
this.formatSelector = formatSelector;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class MediaSeekSabrPart implements SabrPart {
|
||||
public Reason reason;
|
||||
public FormatId formatId;
|
||||
public FormatSelector formatSelector;
|
||||
|
||||
public MediaSeekSabrPart(Reason reason, FormatId formatId, FormatSelector formatSelector) {
|
||||
this.reason = reason;
|
||||
this.formatId = formatId;
|
||||
this.formatSelector = formatSelector;
|
||||
}
|
||||
|
||||
// Lets the consumer know the media sequence for a format may change
|
||||
public enum Reason {
|
||||
UNKNOWN,
|
||||
SERVER_SEEK, // SABR_SEEK from server
|
||||
CONSUMED_SEEK // Seeking as next fragment is already buffered
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
@UnstableApi
|
||||
public class MediaSegmentDataSabrPart implements SabrPart {
|
||||
public final FormatSelector formatSelector;
|
||||
public final FormatId formatId;
|
||||
public final long sequenceNumber;
|
||||
public final boolean isInitSegment;
|
||||
public final int totalSegments;
|
||||
public final ExtractorInput data;
|
||||
public final int contentLength;
|
||||
public final int segmentStartBytes;
|
||||
|
||||
public MediaSegmentDataSabrPart(
|
||||
FormatSelector formatSelector,
|
||||
FormatId formatId,
|
||||
long sequenceNumber,
|
||||
boolean isInitSegment,
|
||||
int totalSegments,
|
||||
ExtractorInput data,
|
||||
int contentLength,
|
||||
int segmentStartBytes) {
|
||||
this.formatSelector = formatSelector;
|
||||
this.formatId = formatId;
|
||||
this.sequenceNumber = sequenceNumber;
|
||||
this.isInitSegment = isInitSegment;
|
||||
this.totalSegments = totalSegments;
|
||||
this.data = data;
|
||||
this.contentLength = contentLength;
|
||||
this.segmentStartBytes = segmentStartBytes;
|
||||
}
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class MediaSegmentEndSabrPart implements SabrPart {
|
||||
public final FormatSelector formatSelector;
|
||||
public final FormatId formatId;
|
||||
public final long sequenceNumber;
|
||||
public final boolean isInitSegment;
|
||||
public final long totalSegments;
|
||||
|
||||
public MediaSegmentEndSabrPart(
|
||||
FormatSelector formatSelector,
|
||||
FormatId formatId,
|
||||
long sequenceNumber,
|
||||
boolean isInitSegment,
|
||||
long totalSegments) {
|
||||
this.formatSelector = formatSelector;
|
||||
this.formatId = formatId;
|
||||
this.sequenceNumber = sequenceNumber;
|
||||
this.isInitSegment = isInitSegment;
|
||||
this.totalSegments = totalSegments;
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.models.FormatSelector;
|
||||
import com.futo.platformplayer.sabr.protos.videostreaming.FormatId;
|
||||
|
||||
public class MediaSegmentInitSabrPart implements SabrPart {
|
||||
public final FormatSelector formatSelector;
|
||||
public final FormatId formatId;
|
||||
public final long playerTimeMs;
|
||||
public final long sequenceNumber;
|
||||
public final long totalSegments;
|
||||
public final int durationMs;
|
||||
public final boolean durationEstimated;
|
||||
public final int startBytes;
|
||||
public final int startTimeMs;
|
||||
public final boolean isInitSegment;
|
||||
public final long contentLength;
|
||||
public final boolean contentLengthEstimate;
|
||||
|
||||
public MediaSegmentInitSabrPart(
|
||||
FormatSelector formatSelector,
|
||||
FormatId formatId,
|
||||
long playerTimeMs,
|
||||
long sequenceNumber,
|
||||
long totalSegments,
|
||||
int durationMs,
|
||||
boolean durationEstimated,
|
||||
int startBytes,
|
||||
int startTimeMs,
|
||||
boolean isInitSegment,
|
||||
long contentLength,
|
||||
boolean contentLengthEstimate) {
|
||||
this.formatSelector = formatSelector;
|
||||
this.formatId = formatId;
|
||||
this.playerTimeMs = playerTimeMs;
|
||||
this.sequenceNumber = sequenceNumber;
|
||||
this.totalSegments = totalSegments;
|
||||
this.durationMs = durationMs;
|
||||
this.durationEstimated = durationEstimated;
|
||||
this.startBytes = startBytes;
|
||||
this.startTimeMs = startTimeMs;
|
||||
this.isInitSegment = isInitSegment;
|
||||
this.contentLength = contentLength;
|
||||
this.contentLengthEstimate = contentLengthEstimate;
|
||||
}
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
public class PoTokenStatusSabrPart implements SabrPart {
|
||||
public final PoTokenStatus status;
|
||||
|
||||
public PoTokenStatusSabrPart(PoTokenStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public enum PoTokenStatus {
|
||||
OK, // PO Token is provided and valid
|
||||
MISSING, // PO Token is not provided, and is required. A PO Token should be provided ASAP
|
||||
INVALID, // PO Token is provided, but is invalid. A new one should be generated ASAP
|
||||
PENDING, // PO Token is provided, but probably only a cold start token. A full PO Token should be provided ASAP
|
||||
NOT_REQUIRED, // PO Token is not provided, and is not required
|
||||
PENDING_MISSING // PO Token is not provided, but is pending. A full PO Token should be (probably) provided ASAP
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
public class RefreshPlayerResponseSabrPart implements SabrPart {
|
||||
public final Reason reason;
|
||||
public final String reloadPlaybackToken;
|
||||
|
||||
public RefreshPlayerResponseSabrPart(Reason reason, String reloadPlaybackToken) {
|
||||
this.reason = reason;
|
||||
this.reloadPlaybackToken = reloadPlaybackToken;
|
||||
}
|
||||
|
||||
public enum Reason {
|
||||
UNKNOWN,
|
||||
SABR_URL_EXPIRY,
|
||||
SABR_RELOAD_PLAYER_RESPONSE
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.parts;
|
||||
|
||||
public interface SabrPart {
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.FormatInitializedSabrPart;
|
||||
|
||||
public class ProcessFormatInitializationMetadataResult {
|
||||
public FormatInitializedSabrPart sabrPart;
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSeekSabrPart;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ProcessLiveMetadataResult {
|
||||
public final List<MediaSeekSabrPart> seekSabrParts = new ArrayList<>();
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentEndSabrPart;
|
||||
|
||||
public class ProcessMediaEndResult {
|
||||
public MediaSegmentEndSabrPart sabrPart;
|
||||
public boolean isNewSegment; // TODO: better name
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentInitSabrPart;
|
||||
|
||||
public class ProcessMediaHeaderResult {
|
||||
public MediaSegmentInitSabrPart sabrPart;
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSegmentDataSabrPart;
|
||||
|
||||
public class ProcessMediaResult {
|
||||
public MediaSegmentDataSabrPart sabrPart;
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.MediaSeekSabrPart;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ProcessSabrSeekResult {
|
||||
public final List<MediaSeekSabrPart> seekSabrParts = new ArrayList<>();
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package com.futo.platformplayer.sabr.parser.processor;
|
||||
|
||||
import com.futo.platformplayer.sabr.parser.parts.PoTokenStatusSabrPart;
|
||||
|
||||
public class ProcessStreamProtectionStatusResult {
|
||||
public PoTokenStatusSabrPart sabrPart;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user