mirror of
https://github.com/libre-tube/LibreTube.git
synced 2024-12-13 13:50:30 +05:30
Implement proper support for dash.
This commit is contained in:
parent
068214df85
commit
da6a614a89
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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<String>,
|
||||
videosUrlArray: Array<String>
|
||||
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<PipedStream>?): 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)
|
||||
}
|
||||
|
176
app/src/main/java/com/github/libretube/util/DashHelper.kt
Normal file
176
app/src/main/java/com/github/libretube/util/DashHelper.kt
Normal file
@ -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<PipedStream> = 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<AdapSetInfo>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@ -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" }
|
||||
|
Loading…
Reference in New Issue
Block a user