Compare commits

..

26 Commits

Author SHA1 Message Date
Kelvin 8536861e09 Update dialogs 2026-01-05 23:56:53 +01:00
Kelvin 71262da3c2 New notification ui 2026-01-02 20:38:43 +01:00
Koen 3ca6a1fd70 Merge branch 'marcus/remove-legacy-casting' into 'master'
casting: remove legacy backend

See merge request videostreaming/grayjay!162
2025-12-26 08:52:13 +00:00
Marcus Hanestad 0d8c8de450 casting: remove legacy backend 2025-12-25 23:04:10 +01:00
Koen J 8ba2fe9972 getOrNull should be used for original everywhere. 2025-12-23 15:52:15 +01:00
koen-futo 7a7ef533cc Merge pull request #2336 from realchrisolin/master
update configChanges so bluetooth keyboards don't recreate activity
2025-12-22 14:28:18 +01:00
Koen 5385549a43 Merge branch 'b23tv-intent-filter' into 'master'
Add b23.tv (BiliBili) to intent filters in AndroidManifest.xml

See merge request videostreaming/grayjay!161
2025-12-20 14:06:36 +00:00
Stefan 04deffc66e Add b23.tv (BiliBili) to intent filters in AndroidManifest.xml
related with https://github.com/futo-org/grayjay-android/issues/2537
2025-12-20 12:08:52 +00:00
Koen J 852f563c9a Renamed subtitles-1 2025-12-18 15:23:16 +01:00
Koen J c84cea9ea1 Remove animation for quality selector. 2025-12-18 14:37:44 +01:00
Koen J 5c162083d5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-18 08:23:26 +01:00
Koen J 3230e7c0b4 Draft fix for cast subtitles UMP. 2025-12-18 08:23:13 +01:00
Kelvin 8437825dd1 apply language filters to downloads 2025-12-17 20:29:45 +01:00
Kelvin 0fbe0bb438 Add filters for video languages to resolve excessive sources 2025-12-17 19:43:56 +01:00
Kelvin 34d2e62314 sub mods 2025-12-17 16:27:12 +01:00
Kelvin 1075ded170 Language for video support, original for video support, deduplication fix for languages on videos, submods 2025-12-17 15:32:37 +01:00
Koen J 80bb15f3fb Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-15 10:03:34 +01:00
Koen J 27a86a67f0 Updated submodules and fixed casting for combined request executor. 2025-12-15 10:03:18 +01:00
Koen 284b2a24f8 Merge branch 'marcus/casting-sdk-updates' into 'master'
casting: subscribe to and handle MediaItemEnd events

See merge request videostreaming/grayjay!158
2025-12-15 09:01:31 +00:00
Kelvin K 854d1506a6 Compile fix 2025-12-11 17:17:42 -06:00
Kelvin K 811fd4e73e Improved dl 2025-12-11 17:16:31 -06:00
Kelvin K 335988aa67 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-11 14:16:07 -06:00
Kelvin K 29a54fbed4 Download support combined 2025-12-11 14:15:55 -06:00
Koen J 3a11d0d9d1 Fixed HLS downloading for Twitch, DialyMotion, Nebula. 2025-12-05 15:31:31 +01:00
Marcus Hanestad 894e400819 casting: subscribe to and handle MediaItemEnd events 2025-11-27 16:56:43 +01:00
Chris Olin 09bc180d4f update configChanges so bluetooth keyboards don't recreate activity 2025-06-10 13:25:45 -04:00
169 changed files with 1941 additions and 14100 deletions
+1 -1
View File
@@ -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'
}
+1 -1
View File
@@ -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"
+7
View File
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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
}
@@ -9,4 +9,6 @@ interface IVideoSource {
val bitrate : Int?;
val duration: Long;
val priority: Boolean;
val language: String?;
val original: Boolean?;
}
@@ -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;
@@ -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;
}
@@ -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");
}
@@ -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);
@@ -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 {
@@ -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? {
@@ -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;
}
@@ -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);
}
}
@@ -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 =
@@ -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) {
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
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 doesnt 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>) {
@@ -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",
@@ -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; well 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&amp;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 "&amp;" 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>&amp;</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 '&amp;' 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( '&', '&amp;' ))
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);
}
}
}
@@ -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);
}
}
@@ -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";
}
}
@@ -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
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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
}
}
@@ -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 {
}
@@ -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;
}
@@ -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<>();
}
@@ -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
}
@@ -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;
}
@@ -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;
}
@@ -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<>();
}
@@ -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