From da6a614a89dcb88ddc6e4c7c0c3e0e5c351f983b Mon Sep 17 00:00:00 2001 From: Kavin <20838718+FireMasterK@users.noreply.github.com> Date: Wed, 16 Nov 2022 14:17:27 +0000 Subject: [PATCH] Implement proper support for dash. --- app/build.gradle | 1 + .../github/libretube/api/obj/PipedStream.kt | 2 +- .../libretube/ui/fragments/PlayerFragment.kt | 84 ++------- .../com/github/libretube/util/DashHelper.kt | 176 ++++++++++++++++++ gradle/libs.versions.toml | 1 + 5 files changed, 199 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/com/github/libretube/util/DashHelper.kt diff --git a/app/build.gradle b/app/build.gradle index abad6cd8e..f65a4f934 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -103,6 +103,7 @@ dependencies { implementation libs.exoplayer implementation(libs.exoplayer.extension.cronet) { exclude group: 'com.google.android.gms' } implementation libs.exoplayer.extension.mediasession + implementation libs.exoplayer.dash /* Retrofit and Jackson */ implementation libs.square.retrofit diff --git a/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt b/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt index 58de468e8..daa1b6f78 100644 --- a/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt +++ b/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt @@ -19,5 +19,5 @@ data class PipedStream( var height: Int? = null, var fps: Int? = null, val audioTrackName: String? = null, - val audioTrackId: String? = null + val audioTrackId: String? = null, ) diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt index f9d6700d5..b28e7d646 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt @@ -18,6 +18,7 @@ import android.os.Looper import android.os.PowerManager import android.text.Html import android.text.format.DateUtils +import android.util.Base64 import android.util.Log import android.view.LayoutInflater import android.view.MotionEvent @@ -75,6 +76,7 @@ import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.ui.sheets.PlayingQueueSheet import com.github.libretube.util.BackgroundHelper +import com.github.libretube.util.DashHelper import com.github.libretube.util.ImageHelper import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.PlayerHelper @@ -102,6 +104,7 @@ import com.google.android.exoplayer2.ui.StyledPlayerView import com.google.android.exoplayer2.upstream.DataSource import com.google.android.exoplayer2.upstream.DefaultDataSource import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.util.MimeTypes import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -771,7 +774,7 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { binding.apply { playerViewsInfo.text = context?.getString(R.string.views, response.views.formatShort()) + - if (!isLive) TextUtils.SEPARATOR + response.uploadDate else "" + if (!isLive) TextUtils.SEPARATOR + response.uploadDate else "" textLike.text = response.likes.formatShort() textDislike.text = response.dislikes.formatShort() @@ -941,7 +944,10 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { } // update the subscribed state - binding.playerSubscribe.setupSubscriptionButton(streams.uploaderUrl?.toID(), streams.uploader) + binding.playerSubscribe.setupSubscriptionButton( + streams.uploaderUrl?.toID(), + streams.uploader + ) if (token != "") { binding.relPlayerSave.setOnClickListener { @@ -1072,41 +1078,10 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { return chapterIndex } - private fun setMediaSource( - videoUrl: String, - audioUrl: String - ) { - val checkIntervalSize = when (PlayerHelper.progressiveLoadingIntervalSize) { - "default" -> ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES - else -> PlayerHelper.progressiveLoadingIntervalSize.toInt() * 1024 - } - - val dataSourceFactory: DataSource.Factory = - DefaultHttpDataSource.Factory() - - val videoItem: MediaItem = MediaItem.Builder() - .setUri(videoUrl.toUri()) - .setSubtitleConfigurations(subtitles) - .build() - - val videoSource: MediaSource = - ProgressiveMediaSource.Factory(dataSourceFactory) - .setContinueLoadingCheckIntervalBytes(checkIntervalSize) - .createMediaSource(videoItem) - - val audioSource: MediaSource = - ProgressiveMediaSource.Factory(dataSourceFactory) - .setContinueLoadingCheckIntervalBytes(checkIntervalSize) - .createMediaSource(fromUri(audioUrl)) - - val mergeSource: MediaSource = - MergingMediaSource(videoSource, audioSource) - exoPlayer.setMediaSource(mergeSource) - } - - private fun setHLSMediaSource(uri: Uri) { + private fun setMediaSource(uri: Uri, mimeType: String) { val mediaItem: MediaItem = MediaItem.Builder() .setUri(uri) + .setMimeType(mimeType) .setSubtitleConfigurations(subtitles) .build() exoPlayer.setMediaItem(mediaItem) @@ -1187,41 +1162,24 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { // set media source and resolution in the beginning setStreamSource( streams, - videosNameArray, - videosUrlArray ) } private fun setStreamSource( - streams: Streams, - videosNameArray: Array, - videosUrlArray: Array + streams: Streams ) { val defaultResolution = PlayerHelper.getDefaultResolution(requireContext()) if (defaultResolution != "") { - videosNameArray.forEachIndexed { index, pipedStream -> - // search for quality preference in the available stream sources - if (pipedStream.contains(defaultResolution)) { - selectedVideoSourceUrl = videosUrlArray[index] - selectedAudioSourceUrl = selectedAudioSourceUrl ?: getAudioSource(streams.audioStreams) - setMediaSource(selectedAudioSourceUrl!!, selectedVideoSourceUrl!!) - return - } - } + // TODO: Fix this, we need to set it from the player! } - // if default resolution isn't set or available, use hls if available - if (streams.hls != null) { - setHLSMediaSource(Uri.parse(streams.hls)) - return - } + val manifest = DashHelper.createManifest(streams) - // if nothing found, use the first list entry - if (videosUrlArray.isNotEmpty()) { - val videoUri = videosUrlArray[0] - val audioUrl = PlayerHelper.getAudioSource(requireContext(), streams.audioStreams!!) - setMediaSource(videoUri, audioUrl) - } + // encode to base64 + val encoded = Base64.encodeToString(manifest.toByteArray(), Base64.DEFAULT) + val mediaItem = "data:application/dash+xml;charset=utf-8;base64,${encoded}" + + this.setMediaSource(mediaItem.toUri(), MimeTypes.APPLICATION_MPD) } private fun getAudioSource(audioStreams: List?): String { @@ -1409,11 +1367,9 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { videosNameArray[which] == "LBRY HLS" ) { // set the progressive media source - setHLSMediaSource(videosUrlArray[which].toUri()) + setMediaSource(videosUrlArray[which], MimeTypes.APPLICATION_M3U8) } else { - selectedVideoSourceUrl = videosUrlArray[which] - selectedAudioSourceUrl = selectedAudioSourceUrl ?: getAudioSource(streams.audioStreams) - setMediaSource(selectedVideoSourceUrl!!, selectedAudioSourceUrl!!) + // TODO: Fix this } exoPlayer.seekTo(lastPosition) } diff --git a/app/src/main/java/com/github/libretube/util/DashHelper.kt b/app/src/main/java/com/github/libretube/util/DashHelper.kt new file mode 100644 index 000000000..6f2a439b1 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/DashHelper.kt @@ -0,0 +1,176 @@ +package com.github.libretube.util + +import com.github.libretube.api.obj.PipedStream +import com.github.libretube.api.obj.Streams +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.StringWriter +import javax.xml.parsers.DocumentBuilder +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +// Based off of https://github.com/TeamPiped/Piped/blob/master/src/utils/DashUtils.js + +class DashHelper { + companion object { + + private val builderFactory: DocumentBuilderFactory = DocumentBuilderFactory.newInstance() + private val transformerFactory: TransformerFactory = TransformerFactory.newInstance() + + private data class AdapSetInfo( + val mimeType: String, + val audioTrackId: String? = null, + val formats: MutableList = mutableListOf() + ) + + fun createManifest(streams: Streams): String { + val builder: DocumentBuilder = builderFactory.newDocumentBuilder() + + val doc = builder.newDocument() + val mpd = doc.createElement("MPD") + mpd.setAttribute("xmlns", "urn:mpeg:dash:schema:mpd:2011") + mpd.setAttribute("profiles", "urn:mpeg:dash:profile:full:2011") + mpd.setAttribute("minBufferTime", "PT1.5S") + mpd.setAttribute("type", "static") + mpd.setAttribute("mediaPresentationDuration", "PT${streams.duration}S") + + val period = doc.createElement("Period") + + val adapSetInfos = ArrayList() + + for (stream in streams.videoStreams!!) { + + // ignore dual format streams + if (!stream.videoOnly!!) + continue + + val adapSetInfo = adapSetInfos.find { it.mimeType == stream.mimeType } + if (adapSetInfo != null) { + adapSetInfo.formats.add(stream) + continue + } + adapSetInfos.add( + AdapSetInfo( + stream.mimeType!!, + null, + mutableListOf(stream) + ) + ) + } + + for (stream in streams.audioStreams!!) { + val adapSetInfo = + adapSetInfos.find { it.mimeType == stream.mimeType && it.audioTrackId == stream.audioTrackId } + if (adapSetInfo != null) { + adapSetInfo.formats.add(stream) + continue + } + adapSetInfos.add( + AdapSetInfo( + stream.mimeType!!, + null, + mutableListOf(stream) + ) + ) + } + + for (adapSet in adapSetInfos) { + val adapSetElement = doc.createElement("AdaptationSet") + adapSetElement.setAttribute("mimeType", adapSet.mimeType) + adapSetElement.setAttribute("startWithSAP", "1") + adapSetElement.setAttribute("subsegmentAlignment", "true") + if (adapSet.audioTrackId != null) { + adapSetElement.setAttribute("lang", adapSet.audioTrackId.substring(0, 2)) + } + + val isVideo = adapSet.mimeType.contains("video") + + if (isVideo) { + adapSetElement.setAttribute("scanType", "progressive") + } + + for (stream in adapSet.formats) { + val rep = let { + if (isVideo) { + createVideoRepresentation(doc, stream) + } else { + createAudioRepresentation(doc, stream) + } + } + adapSetElement.appendChild(rep) + } + + period.appendChild(adapSetElement) + } + + mpd.appendChild(period) + + doc.appendChild(mpd) + + val domSource = DOMSource(doc) + val writer = StringWriter() + + val transformer = transformerFactory.newTransformer() + transformer.transform(domSource, StreamResult(writer)) + + return writer.toString() + } + + private fun createAudioRepresentation(doc: Document, stream: PipedStream): Element { + val representation = doc.createElement("Representation") + representation.setAttribute("bandwidth", stream.bitrate.toString()) + representation.setAttribute("codecs", stream.codec!!) + representation.setAttribute("mimeType", stream.mimeType!!) + + val audioChannelConfiguration = doc.createElement("AudioChannelConfiguration") + audioChannelConfiguration.setAttribute( + "schemeIdUri", + "urn:mpeg:dash:23003:3:audio_channel_configuration:2011" + ) + audioChannelConfiguration.setAttribute("value", "2") + + val baseUrl = doc.createElement("BaseURL") + baseUrl.appendChild(doc.createTextNode(stream.url!!)) + + val segmentBase = doc.createElement("SegmentBase") + segmentBase.setAttribute("indexRange", "${stream.indexStart}-${stream.indexEnd}") + + val initialization = doc.createElement("Initialization") + initialization.setAttribute("range", "${stream.initStart}-${stream.initEnd}") + segmentBase.appendChild(initialization) + + representation.appendChild(audioChannelConfiguration) + representation.appendChild(baseUrl) + representation.appendChild(segmentBase) + + return representation + } + + private fun createVideoRepresentation(doc: Document, stream: PipedStream): Element { + val representation = doc.createElement("Representation") + representation.setAttribute("codecs", stream.codec!!) + representation.setAttribute("bandwidth", stream.bitrate.toString()) + representation.setAttribute("width", stream.width.toString()) + representation.setAttribute("height", stream.height.toString()) + representation.setAttribute("maxPlayoutRate", "1") + representation.setAttribute("frameRate", stream.fps.toString()) + + val baseUrl = doc.createElement("BaseURL") + baseUrl.appendChild(doc.createTextNode(stream.url!!)) + + val segmentBase = doc.createElement("SegmentBase") + segmentBase.setAttribute("indexRange", "${stream.indexStart}-${stream.indexEnd}") + + val initialization = doc.createElement("Initialization") + initialization.setAttribute("range", "${stream.initStart}-${stream.initEnd}") + segmentBase.appendChild(initialization) + + representation.appendChild(baseUrl) + representation.appendChild(segmentBase) + + return representation + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6fd6db684..c527e6aea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ square-retrofit-converterJackson = { group = "com.squareup.retrofit2", name = "c jacksonAnnotations = { group = "com.fasterxml.jackson.core", name = "jackson-annotations", version.ref = "jacksonAnnotations" } desugaring = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugaring" } exoplayer-extension-cronet = { group = "com.google.android.exoplayer", name = "extension-cronet", version.ref = "exoplayer" } +exoplayer-dash = { group = "com.google.android.exoplayer", name = "exoplayer-dash", version.ref = "exoplayer" } cronet-embedded = { group = "org.chromium.net", name = "cronet-embedded", version.ref = "cronetEmbedded" } cronet-okhttp = { group = "com.google.net.cronet", name = "cronet-okhttp", version.ref = "cronetOkHttp" } coil = { group = "io.coil-kt", name = "coil", version.ref="coil" }