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/constants/PreferenceKeys.kt b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt index 9f0f69781..ac20cc559 100644 --- a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt @@ -78,9 +78,9 @@ object PreferenceKeys { const val PLAYER_RESIZE_MODE = "player_resize_mode" const val SB_SKIP_MANUALLY = "sb_skip_manually_key" const val SB_SHOW_MARKERS = "sb_show_markers" - const val LIMIT_HLS = "limit_hls" const val PROGRESSIVE_LOADING_INTERVAL_SIZE = "progressive_loading_interval" const val ALTERNATIVE_PLAYER_LAYOUT = "alternative_player_layout" + const val USE_HLS_OVER_DASH = "use_hls" /** * Background mode diff --git a/app/src/main/java/com/github/libretube/obj/VideoResolution.kt b/app/src/main/java/com/github/libretube/obj/VideoResolution.kt new file mode 100644 index 000000000..b234cfda2 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/VideoResolution.kt @@ -0,0 +1,7 @@ +package com.github.libretube.obj + +data class VideoResolution( + val name: String, + val resolution: Int? = null, + val adaptiveSourceUrl: 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..ef913c0dc 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 @@ -59,6 +60,7 @@ import com.github.libretube.extensions.query import com.github.libretube.extensions.toID import com.github.libretube.extensions.toStreamItem import com.github.libretube.obj.ShareData +import com.github.libretube.obj.VideoResolution import com.github.libretube.services.BackgroundMode import com.github.libretube.services.DownloadService import com.github.libretube.ui.activities.MainActivity @@ -75,6 +77,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 @@ -86,22 +89,17 @@ import com.google.android.exoplayer2.DefaultLoadControl import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem.SubtitleConfiguration -import com.google.android.exoplayer2.MediaItem.fromUri import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.ext.cronet.CronetDataSource import com.google.android.exoplayer2.source.DefaultMediaSourceFactory -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.MergingMediaSource -import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.text.Cue.TEXT_SIZE_TYPE_ABSOLUTE import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import com.google.android.exoplayer2.ui.CaptionStyleCompat 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 @@ -169,9 +167,6 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { private lateinit var shareData: ShareData - private var selectedAudioSourceUrl: String? = null - private var selectedVideoSourceUrl: String? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { @@ -941,7 +936,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,67 +1070,24 @@ 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) } - private fun getAvailableResolutions(): Pair, Array> { - if (!this::streams.isInitialized) return Pair(arrayOf(), arrayOf()) + private fun getAvailableResolutions(): List { + if (!this::streams.isInitialized) return listOf() - var videosNameArray: Array = arrayOf() - var videosUrlArray: Array = arrayOf() - - // append hls to list if available - if (streams.hls != null) { - videosNameArray += getString(R.string.hls) - videosUrlArray += streams.hls!! - } + val resolutions = mutableListOf() val videoStreams = try { // attempt to sort the qualities, catch if there was an error ih parsing streams.videoStreams?.sortedBy { - it.quality - .toString() - .split("p") - .first() - .replace("p", "") - .toLong() + it.quality?.toLong() ?: 0L }?.reversed() .orEmpty() } catch (_: Exception) { @@ -1142,21 +1097,44 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { for (vid in videoStreams) { // append quality to list if it has the preferred format (e.g. MPEG) val preferredMimeType = "video/${PlayerHelper.videoFormatPreference}" - if (vid.url != null && vid.mimeType == preferredMimeType) { // preferred format - videosNameArray += vid.quality.toString() - videosUrlArray += vid.url!! - } else if (vid.quality.equals("LBRY") && vid.format.equals("MP4")) { // LBRY MP4 format - videosNameArray += "LBRY MP4" - videosUrlArray += vid.url!! + if (vid.url != null && vid.mimeType == preferredMimeType) { + // avoid duplicated resolutions + if (resolutions.any { + it.resolution == vid.quality.toString().split("p").first().toInt() + } + ) { + continue + } + + resolutions.add( + VideoResolution( + name = vid.quality!!, + resolution = vid.quality.toString().split("p").first().toInt() + ) + ) + } else if (vid.quality.equals("LBRY") && vid.format.equals("MP4")) { + resolutions.add( + VideoResolution( + name = "LBRY MP4", + adaptiveSourceUrl = vid.url, + resolution = Int.MAX_VALUE + ) + ) } } - return Pair(videosNameArray, videosUrlArray) + + if (resolutions.isEmpty()) { + return listOf( + VideoResolution(getString(R.string.hls), adaptiveSourceUrl = streams.hls) + ) + } + + resolutions.add(0, VideoResolution(getString(R.string.auto_quality), Int.MAX_VALUE)) + + return resolutions } private fun setResolutionAndSubtitles() { - // get the available resolutions - val (videosNameArray, videosUrlArray) = getAvailableResolutions() - // create a list of subtitles subtitles = mutableListOf() val subtitlesNamesList = mutableListOf(context?.getString(R.string.none)!!) @@ -1186,51 +1164,34 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { // set media source and resolution in the beginning setStreamSource( - streams, - videosNameArray, - videosUrlArray + streams ) } - private fun setStreamSource( - streams: Streams, - videosNameArray: Array, - videosUrlArray: Array - ) { - val defaultResolution = PlayerHelper.getDefaultResolution(requireContext()) + private fun setStreamSource(streams: Streams) { + val defaultResolution = PlayerHelper.getDefaultResolution(requireContext()).replace("p", "") 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 - } - } + val params = trackSelector.buildUponParameters() + .setMaxVideoSize(Int.MAX_VALUE, defaultResolution.toInt()) + .setMinVideoSize(Int.MAX_VALUE, defaultResolution.toInt()) + trackSelector.setParameters(params) } - // if default resolution isn't set or available, use hls if available - if (streams.hls != null) { - setHLSMediaSource(Uri.parse(streams.hls)) - return - } + if (!PreferenceHelper.getBoolean(PreferenceKeys.USE_HLS_OVER_DASH, false) && + streams.videoStreams.orEmpty().isNotEmpty() + ) { + 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" - private fun getAudioSource(audioStreams: List?): String { - val appLanguage = Locale.getDefault().language.lowercase().substring(0, 2) - val filteredStreams = audioStreams.orEmpty().filter { it.audioTrackId?.contains(appLanguage) ?: false } - return PlayerHelper.getAudioSource( - requireContext(), - filteredStreams.ifEmpty { audioStreams!! } - ) + this.setMediaSource(mediaItem.toUri(), MimeTypes.APPLICATION_MPD) + } else if (streams.hls != null) { + setMediaSource(streams.hls.toUri(), MimeTypes.APPLICATION_M3U8) + } else { + Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() + } } private fun createExoPlayer() { @@ -1264,17 +1225,10 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { // control for the track sources like subtitles and audio source trackSelector = DefaultTrackSelector(requireContext()) - // limit hls to full hd - if ( - PreferenceHelper.getBoolean( - PreferenceKeys.LIMIT_HLS, - false - ) - ) { - val newParams = trackSelector.buildUponParameters() - .setMaxVideoSize(1920, 1080) - trackSelector.setParameters(newParams) - } + val params = trackSelector.buildUponParameters().setPreferredAudioLanguage( + Locale.getDefault().language.lowercase().substring(0, 2) + ) + trackSelector.setParameters(params) exoPlayer = ExoPlayer.Builder(requireContext()) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) @@ -1396,26 +1350,19 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { override fun onQualityClicked() { // get the available resolutions - val (videosNameArray, videosUrlArray) = getAvailableResolutions() + val resolutions = getAvailableResolutions() // Dialog for quality selection - val lastPosition = exoPlayer.currentPosition BaseBottomSheet() .setSimpleItems( - videosNameArray.toList() + resolutions.map { it.name } ) { which -> - if ( - videosNameArray[which] == getString(R.string.hls) || - videosNameArray[which] == "LBRY HLS" - ) { - // set the progressive media source - setHLSMediaSource(videosUrlArray[which].toUri()) - } else { - selectedVideoSourceUrl = videosUrlArray[which] - selectedAudioSourceUrl = selectedAudioSourceUrl ?: getAudioSource(streams.audioStreams) - setMediaSource(selectedVideoSourceUrl!!, selectedAudioSourceUrl!!) - } - exoPlayer.seekTo(lastPosition) + val resolution = resolutions[which].resolution!! + + val params = trackSelector.buildUponParameters() + .setMaxVideoSize(Int.MAX_VALUE, resolution) + .setMinVideoSize(Int.MAX_VALUE, resolution) + trackSelector.setParameters(params) } .show(childFragmentManager) } @@ -1432,9 +1379,10 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions { BaseBottomSheet() .setSimpleItems(audioLanguages) { index -> val audioStreams = audioGroups.values.elementAt(index) - selectedAudioSourceUrl = PlayerHelper.getAudioSource(requireContext(), audioStreams) - selectedVideoSourceUrl = selectedVideoSourceUrl ?: streams.videoStreams!!.first().url!! - setMediaSource(selectedAudioSourceUrl!!, selectedVideoSourceUrl!!) + val lang = audioStreams.first().audioTrackId!!.substring(0, 2) + val newParams = trackSelector.buildUponParameters() + .setPreferredAudioLanguage(lang) + trackSelector.setParameters(newParams) } .show(childFragmentManager) } 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..8acadf5a4 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/DashHelper.kt @@ -0,0 +1,174 @@ +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 + +object DashHelper { + + 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!!, + stream.audioTrackId, + 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/app/src/main/res/values/array.xml b/app/src/main/res/values/array.xml index f48a87cb5..d48ec1b73 100644 --- a/app/src/main/res/values/array.xml +++ b/app/src/main/res/values/array.xml @@ -188,7 +188,7 @@ - @string/hls + @string/auto_quality 2160p 1440p 1080p diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81231ac6b..43190dc2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -208,7 +208,7 @@ Authentication instance Use a different instance for authenticated calls. Choose an auth instance - Auto + HLS GitHub Audio and video Fullscreen orientation @@ -373,6 +373,9 @@ Audio track Default Unsupported file format! + Use HLS + Use HLS instead of DASH (will be slower, not recommended) + Auto Download Service diff --git a/app/src/main/res/xml/audio_video_settings.xml b/app/src/main/res/xml/audio_video_settings.xml index 9d140d99b..ee98f808f 100644 --- a/app/src/main/res/xml/audio_video_settings.xml +++ b/app/src/main/res/xml/audio_video_settings.xml @@ -46,6 +46,7 @@ + @@ -62,15 +64,17 @@ app:defaultValue="all" app:entries="@array/playerAudioFormat" app:entryValues="@array/playerAudioFormatValues" + app:isPreferenceVisible="false" app:key="player_audio_format" app:title="@string/playerAudioFormat" app:useSimpleSummaryProvider="true" /> + android:icon="@drawable/ic_list" + android:summary="@string/hls_instead_of_dash_summary" + android:title="@string/hls_instead_of_dash" + app:key="use_hls" />