Merge Wholphin v1.1.0 while preserving Wolfskin features #27
15 changed files with 1038 additions and 14 deletions
|
|
@ -315,6 +315,7 @@ dependencies {
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
implementation(libs.androidx.media3.ui.compose)
|
implementation(libs.androidx.media3.ui.compose)
|
||||||
implementation(libs.ass.media)
|
implementation(libs.ass.media)
|
||||||
|
implementation(libs.exoplayerhdrutils)
|
||||||
|
|
||||||
implementation(libs.coil.core)
|
implementation(libs.coil.core)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
|
||||||
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
|
|
@ -22,3 +22,6 @@
|
||||||
-keep class androidx.media3.ui.SubtitlePainter {
|
-keep class androidx.media3.ui.SubtitlePainter {
|
||||||
private final float outlineWidth;
|
private final float outlineWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Keep external/native functions
|
||||||
|
-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; }
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,8 @@ class AppPreferencesSerializer
|
||||||
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
|
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
|
||||||
preferDolbyVisionOverHdr10Plus =
|
preferDolbyVisionOverHdr10Plus =
|
||||||
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
|
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
|
||||||
|
convertDolbyVisionProfile7 =
|
||||||
|
ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue
|
||||||
}.build()
|
}.build()
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,17 @@ object ExperimentalPreference {
|
||||||
summary = R.string.prefer_dovi_over_hdr10_plus_summary,
|
summary = R.string.prefer_dovi_over_hdr10_plus_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ConvertDolbyVisionProfile7 =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.convert_dovi_profile_7,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.experimentalPreferences.convertDolbyVisionProfile7 },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateExperimentalPreferences { convertDolbyVisionProfile7 = value }
|
||||||
|
},
|
||||||
|
summary = R.string.convert_dovi_profile_7_summary,
|
||||||
|
)
|
||||||
|
|
||||||
val PreferAc3ForSurround =
|
val PreferAc3ForSurround =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.prefer_ac3_for_surround,
|
title = R.string.prefer_ac3_for_surround,
|
||||||
|
|
@ -93,6 +104,7 @@ val experimentalPreferences =
|
||||||
listOf(
|
listOf(
|
||||||
ExperimentalPreference.VideoTunneling,
|
ExperimentalPreference.VideoTunneling,
|
||||||
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
|
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
|
||||||
|
ExperimentalPreference.ConvertDolbyVisionProfile7,
|
||||||
ExperimentalPreference.PreferAc3ForSurround,
|
ExperimentalPreference.PreferAc3ForSurround,
|
||||||
ExperimentalPreference.DisableAudioOffload,
|
ExperimentalPreference.DisableAudioOffload,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import android.content.Context
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||||
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
|
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.HdrFormat
|
||||||
import com.github.damontecres.wholphin.preferences.HdrOverrideMode
|
import com.github.damontecres.wholphin.preferences.HdrOverrideMode
|
||||||
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
|
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
|
||||||
import com.github.damontecres.wholphin.preferences.enabled
|
import com.github.damontecres.wholphin.preferences.enabled
|
||||||
import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
|
import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
|
||||||
|
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
||||||
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
|
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
|
||||||
|
|
@ -66,7 +68,17 @@ class DeviceProfileService
|
||||||
decodeAv1 = prefs.overrides.decodeAv1,
|
decodeAv1 = prefs.overrides.decodeAv1,
|
||||||
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
|
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
|
||||||
forceEnabledHdr =
|
forceEnabledHdr =
|
||||||
appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven),
|
buildSet {
|
||||||
|
appPrefs
|
||||||
|
.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven)
|
||||||
|
.let(::addAll)
|
||||||
|
// A profile 7 track the player converts to 8.1 is one the server need not transcode
|
||||||
|
if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) {
|
||||||
|
HdrFormat.DOVI_PROFILE_7
|
||||||
|
.rangeTypeNames(newConfig.jellyfinTenEleven)
|
||||||
|
.let(::addAll)
|
||||||
|
}
|
||||||
|
},
|
||||||
forceDisabledHdr =
|
forceDisabledHdr =
|
||||||
appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven),
|
appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven),
|
||||||
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
||||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
|
import androidx.media3.extractor.ExtractorsFactory
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -32,7 +33,11 @@ import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.get
|
import com.github.damontecres.wholphin.preferences.get
|
||||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.util.BitstreamFilter
|
||||||
import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory
|
import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory
|
||||||
|
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
|
||||||
|
import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory
|
||||||
|
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter
|
||||||
import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter
|
import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -57,6 +62,7 @@ class PlayerFactory
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
|
||||||
|
private val deviceProfileService: DeviceProfileService,
|
||||||
) {
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
var currentPlayer: Player? = null
|
var currentPlayer: Player? = null
|
||||||
|
|
@ -94,10 +100,16 @@ class PlayerFactory
|
||||||
val preferDolbyVision =
|
val preferDolbyVision =
|
||||||
appPreferences.experimentalPreferences
|
appPreferences.experimentalPreferences
|
||||||
.get { preferDolbyVisionOverHdr10Plus } ?: false
|
.get { preferDolbyVisionOverHdr10Plus } ?: false
|
||||||
|
val convertDolbyVisionProfile7 =
|
||||||
|
DolbyVisionProfile7Conversion.isWanted(
|
||||||
|
appPreferences.experimentalPreferences,
|
||||||
|
deviceProfileService.mediaCodecCapabilitiesTest,
|
||||||
|
)
|
||||||
Timber.v(
|
Timber.v(
|
||||||
"extensions=%s, assPlaybackMode=%s",
|
"extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s",
|
||||||
extensions,
|
extensions,
|
||||||
prefs.overrides.assPlaybackMode,
|
prefs.overrides.assPlaybackMode,
|
||||||
|
convertDolbyVisionProfile7,
|
||||||
)
|
)
|
||||||
val rendererMode =
|
val rendererMode =
|
||||||
when (extensions) {
|
when (extensions) {
|
||||||
|
|
@ -108,9 +120,18 @@ class PlayerFactory
|
||||||
}
|
}
|
||||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||||
val extractorsFactory = createExtractorsFactory()
|
val extractorsFactory = createExtractorsFactory()
|
||||||
|
|
||||||
|
// Applied outermost so that a profile 7 track is advertised as 8.1 whichever
|
||||||
|
// extractor, media3's own or libass's Matroska replacement, produced it
|
||||||
|
fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory =
|
||||||
|
if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this
|
||||||
var renderersFactory: RenderersFactory =
|
var renderersFactory: RenderersFactory =
|
||||||
WholphinRenderersFactory(context, decodeAv1, preferDolbyVision)
|
WholphinRenderersFactory(
|
||||||
.setEnableDecoderFallback(true)
|
context,
|
||||||
|
decodeAv1,
|
||||||
|
preferDolbyVision,
|
||||||
|
convertDolbyVisionProfile7,
|
||||||
|
).setEnableDecoderFallback(true)
|
||||||
.setExtensionRendererMode(rendererMode)
|
.setExtensionRendererMode(rendererMode)
|
||||||
|
|
||||||
val mediaSourceFactory =
|
val mediaSourceFactory =
|
||||||
|
|
@ -126,15 +147,16 @@ class PlayerFactory
|
||||||
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
|
||||||
DefaultMediaSourceFactory(
|
DefaultMediaSourceFactory(
|
||||||
dataSourceFactory,
|
dataSourceFactory,
|
||||||
extractorsFactory.withAssMkvSupport(
|
extractorsFactory
|
||||||
assSubtitleParserFactory,
|
.withAssMkvSupport(
|
||||||
assHandler,
|
assSubtitleParserFactory,
|
||||||
),
|
assHandler,
|
||||||
|
).forDolbyVision(),
|
||||||
).setSubtitleParserFactory(assSubtitleParserFactory)
|
).setSubtitleParserFactory(assSubtitleParserFactory)
|
||||||
} else {
|
} else {
|
||||||
DefaultMediaSourceFactory(
|
DefaultMediaSourceFactory(
|
||||||
dataSourceFactory,
|
dataSourceFactory,
|
||||||
extractorsFactory,
|
extractorsFactory.forDolbyVision(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val disableAudioOffload =
|
val disableAudioOffload =
|
||||||
|
|
@ -268,6 +290,7 @@ class WholphinRenderersFactory(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val av1Enabled: Boolean,
|
private val av1Enabled: Boolean,
|
||||||
private val preferDolbyVisionOverHdr10Plus: Boolean = false,
|
private val preferDolbyVisionOverHdr10Plus: Boolean = false,
|
||||||
|
private val convertDolbyVisionProfile7: Boolean = false,
|
||||||
) : DefaultRenderersFactory(context) {
|
) : DefaultRenderersFactory(context) {
|
||||||
@OptIn(ExperimentalApi::class)
|
@OptIn(ExperimentalApi::class)
|
||||||
override fun buildVideoRenderers(
|
override fun buildVideoRenderers(
|
||||||
|
|
@ -280,12 +303,16 @@ class WholphinRenderersFactory(
|
||||||
allowedVideoJoiningTimeMs: Long,
|
allowedVideoJoiningTimeMs: Long,
|
||||||
out: ArrayList<Renderer>,
|
out: ArrayList<Renderer>,
|
||||||
) {
|
) {
|
||||||
|
// The conversion goes first: it may shorten the access unit, and the masking then only
|
||||||
|
// has to walk what is left of it
|
||||||
|
val bitstreamFilters =
|
||||||
|
buildList<BitstreamFilter> {
|
||||||
|
if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter())
|
||||||
|
if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter())
|
||||||
|
}
|
||||||
val videoCodecAdapterFactory =
|
val videoCodecAdapterFactory =
|
||||||
if (preferDolbyVisionOverHdr10Plus) {
|
if (bitstreamFilters.isNotEmpty()) {
|
||||||
BitstreamFilteringCodecAdapterFactory(
|
BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters)
|
||||||
codecAdapterFactory,
|
|
||||||
listOf(Hdr10PlusMaskingFilter()),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
codecAdapterFactory
|
codecAdapterFactory
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
@file:OptIn(UnstableApi::class)
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.enabled
|
||||||
|
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place which decides whether Dolby Vision profile 7 is converted to profile 8.1 on this
|
||||||
|
* device, and how a converted track is described.
|
||||||
|
*
|
||||||
|
* Profile 7 is HDR10 compatible HEVC plus two extra NAL unit types: 62 carries the RPU metadata and
|
||||||
|
* 63 the enhancement layer. A decoder which only lists profile 8 takes such a stream but does not
|
||||||
|
* switch the display into Dolby Vision for it, so the picture comes out as the HDR10 base layer.
|
||||||
|
* Rewriting the RPU into its profile 8.1 form and dropping the enhancement layer produces the
|
||||||
|
* single layer stream that decoder does switch for. Nothing is re-encoded.
|
||||||
|
*
|
||||||
|
* The conversion has two halves which have to agree:
|
||||||
|
* - [DolbyVisionProfile7Filter] rewrites the access units on their way into the decoder;
|
||||||
|
* - [DolbyVisionProfile7ExtractorsFactory] rewrites the codec string of the track, because
|
||||||
|
* media3 picks the decoder by `Format.codecs` and a decoder which does not list profile 7 would
|
||||||
|
* otherwise never be chosen for it.
|
||||||
|
*
|
||||||
|
* Both are switched on by [isWanted], as is direct play of profile 7 in the device profile.
|
||||||
|
*/
|
||||||
|
object DolbyVisionProfile7Conversion {
|
||||||
|
/**
|
||||||
|
* True when both the user has enabled the setting and this device is one it helps: its Dolby
|
||||||
|
* Vision decoder lists profile 8 but not profile 7. A device which decodes profile 7 itself
|
||||||
|
* is left alone, since it can use the enhancement layer the conversion would throw away, and
|
||||||
|
* a device without a profile 8 decoder has nothing to handle the converted stream
|
||||||
|
*/
|
||||||
|
fun isWanted(
|
||||||
|
experimental: ExperimentalPreferences,
|
||||||
|
mediaTest: MediaCodecCapabilitiesTest,
|
||||||
|
): Boolean =
|
||||||
|
experimental.enabled { convertDolbyVisionProfile7 } &&
|
||||||
|
!mediaTest.supportsHevcDolbyVisionEL() &&
|
||||||
|
mediaTest.supportsHevcDolbyVisionProfile8()
|
||||||
|
|
||||||
|
/** Whether [format] is a Dolby Vision profile 7 video track, in either sample entry form. */
|
||||||
|
fun isProfile7(format: Format): Boolean {
|
||||||
|
val codecs = format.codecs ?: return false
|
||||||
|
return MimeTypes.VIDEO_DOLBY_VISION == format.sampleMimeType &&
|
||||||
|
(codecs.startsWith(CODECS_PREFIX_DVHE_07) || codecs.startsWith(CODECS_PREFIX_DVH1_07))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The [format] as the converted stream should be advertised: `dvhe.07.06` becomes
|
||||||
|
* `dvhe.08.06`, the level and the sample entry prefix stay. Encrypted tracks are left as they
|
||||||
|
* are: their samples reach the decoder through the secure path the filter does not touch, so
|
||||||
|
* advertising them as converted would be a lie.
|
||||||
|
*/
|
||||||
|
fun advertiseAsProfile8(format: Format): Format =
|
||||||
|
if (isProfile7(format) && format.drmInitData == null) {
|
||||||
|
format.buildUpon().setCodecs(toProfile8(format.codecs!!)).build()
|
||||||
|
} else {
|
||||||
|
format
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `dvhe.07.06` → `dvhe.08.06`. The caller checks [isProfile7] first. */
|
||||||
|
fun toProfile8(codecs: String): String = codecs.substring(0, 5) + "08" + codecs.substring(7)
|
||||||
|
|
||||||
|
private const val CODECS_PREFIX_DVHE_07 = "dvhe.07"
|
||||||
|
private const val CODECS_PREFIX_DVH1_07 = "dvh1.07"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
@file:OptIn(markerClass = [UnstableApi::class])
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.extractor.Extractor
|
||||||
|
import androidx.media3.extractor.ExtractorOutput
|
||||||
|
import androidx.media3.extractor.ExtractorsFactory
|
||||||
|
import androidx.media3.extractor.ForwardingExtractor
|
||||||
|
import androidx.media3.extractor.ForwardingExtractorOutput
|
||||||
|
import androidx.media3.extractor.ForwardingExtractorsFactory
|
||||||
|
import androidx.media3.extractor.ForwardingTrackOutput
|
||||||
|
import androidx.media3.extractor.TrackOutput
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advertises Dolby Vision profile 7 video tracks as profile 8.1, which is what
|
||||||
|
* [DolbyVisionProfile7Filter] turns their samples into before the decoder sees them.
|
||||||
|
*
|
||||||
|
* media3 chooses the decoder by `Format.codecs`, and a decoder which lists profile 8 but not
|
||||||
|
* profile 7 answers "not supported" to `dvhe.07`; with decoder fallback on, playback then lands on
|
||||||
|
* whichever decoder comes first in the list, with a warning that the format exceeds its
|
||||||
|
* capabilities. Rewriting the codec string at the extractor routes the track to the profile 8
|
||||||
|
* decoder, and it also sets `MediaFormat.KEY_PROFILE` right, which media3 derives from the same
|
||||||
|
* string.
|
||||||
|
*
|
||||||
|
* Only the codec string changes. Sample data is not touched here: the two convenience overloads of
|
||||||
|
* [TrackOutput.sampleData] go straight to the delegate, which is exactly what is wanted.
|
||||||
|
*/
|
||||||
|
class DolbyVisionProfile7ExtractorsFactory(
|
||||||
|
delegate: ExtractorsFactory,
|
||||||
|
) : ForwardingExtractorsFactory(delegate) {
|
||||||
|
override fun createExtractors(): Array<Extractor> = wrap(super.createExtractors())
|
||||||
|
|
||||||
|
override fun createExtractors(
|
||||||
|
uri: Uri,
|
||||||
|
responseHeaders: Map<String, List<String>>,
|
||||||
|
): Array<Extractor> = wrap(super.createExtractors(uri, responseHeaders))
|
||||||
|
|
||||||
|
private fun wrap(extractors: Array<Extractor>): Array<Extractor> =
|
||||||
|
Array(extractors.size) { Profile8AdvertisingExtractor(extractors[it]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Profile8AdvertisingExtractor(
|
||||||
|
delegate: Extractor,
|
||||||
|
) : ForwardingExtractor(delegate) {
|
||||||
|
override fun init(output: ExtractorOutput) {
|
||||||
|
super.init(Profile8AdvertisingExtractorOutput(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Profile8AdvertisingExtractorOutput(
|
||||||
|
output: ExtractorOutput,
|
||||||
|
) : ForwardingExtractorOutput(output) {
|
||||||
|
override fun track(
|
||||||
|
id: Int,
|
||||||
|
type: Int,
|
||||||
|
): TrackOutput {
|
||||||
|
val trackOutput = super.track(id, type)
|
||||||
|
return if (type == C.TRACK_TYPE_VIDEO) Profile8AdvertisingTrackOutput(trackOutput) else trackOutput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrites the codec string of every profile 7 format it is handed; everything else passes unchanged. */
|
||||||
|
internal class Profile8AdvertisingTrackOutput(
|
||||||
|
delegate: TrackOutput,
|
||||||
|
) : ForwardingTrackOutput(delegate) {
|
||||||
|
override fun format(format: Format) {
|
||||||
|
val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format)
|
||||||
|
if (advertised !== format) {
|
||||||
|
Timber.i("Advertising Dolby Vision track %s as %s for conversion", format.codecs, advertised.codecs)
|
||||||
|
}
|
||||||
|
super.format(advertised)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,313 @@
|
||||||
|
@file:OptIn(markerClass = [UnstableApi::class])
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.nio.Buffer
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites the Dolby Vision RPU of one access unit, in place. The production implementation is
|
||||||
|
* libdovi, as packaged by ExoplayerHdrUtils; tests substitute their own.
|
||||||
|
*
|
||||||
|
* Both methods take a direct buffer whose access unit starts at index 0 and is [size] bytes long.
|
||||||
|
* That is the shape the native code reads: it takes the buffer's address and capacity, not its
|
||||||
|
* position and limit.
|
||||||
|
*/
|
||||||
|
interface RpuRewriter {
|
||||||
|
/**
|
||||||
|
* The Dolby Vision profile of the RPU (NAL unit type 62) in the access unit, or 0 when there is
|
||||||
|
* none it can parse.
|
||||||
|
*/
|
||||||
|
fun profileOf(
|
||||||
|
frame: ByteBuffer,
|
||||||
|
size: Int,
|
||||||
|
): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites the profile 7 RPU in the access unit into its profile 8.1 form and returns the new
|
||||||
|
* size of the access unit. The bytes after the RPU move to stay contiguous, so the buffer's
|
||||||
|
* capacity must leave room past [size] for an RPU which comes out longer than it went in.
|
||||||
|
*/
|
||||||
|
fun convertToProfile8(
|
||||||
|
frame: ByteBuffer,
|
||||||
|
size: Int,
|
||||||
|
): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [RpuRewriter] on libdovi. Constructing it loads the native library, which throws a [LinkageError] on an ABI it does not ship for. */
|
||||||
|
class LibDoviRpuRewriter : RpuRewriter {
|
||||||
|
private val libDovi = LibDovi()
|
||||||
|
|
||||||
|
override fun profileOf(
|
||||||
|
frame: ByteBuffer,
|
||||||
|
size: Int,
|
||||||
|
): Int = libDovi.getFrameInfo(frame, size)?.doviProfile ?: 0
|
||||||
|
|
||||||
|
override fun convertToProfile8(
|
||||||
|
frame: ByteBuffer,
|
||||||
|
size: Int,
|
||||||
|
): Int = libDovi.processHevcFrame(frame, size, DOVI_TRANSFORM_CONVERT_TO_PROFILE_8, false)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** `doviTransform` value of `LibDovi.processHevcFrame` for the profile 7 → 8.1 rewrite. */
|
||||||
|
const val DOVI_TRANSFORM_CONVERT_TO_PROFILE_8 = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts Dolby Vision profile 7 access units to profile 8.1 on their way into the decoder.
|
||||||
|
*
|
||||||
|
* Profile 7 carries its RPU in NAL unit type 62 and its enhancement layer in type 63, after the
|
||||||
|
* picture's own NAL units. The conversion drops every type 63 unit and has libdovi rewrite the RPU
|
||||||
|
* into the profile 8.1 form, which is what a profile 8 decoder switches the display into Dolby
|
||||||
|
* Vision for. The enhancement layer goes because profile 8.1 is single layer by definition: a
|
||||||
|
* decoder handed a dual layer stream under that name keeps it out of the Dolby Vision pipeline and
|
||||||
|
* shows the HDR10 base layer, which is the very symptom this filter exists to fix.
|
||||||
|
*
|
||||||
|
* Every access unit is judged on its own. One without an RPU, or whose RPU is not profile 7, is
|
||||||
|
* left exactly as it came, and so is one libdovi refuses. The rewrite happens in a scratch buffer
|
||||||
|
* and is copied back only once it has succeeded and fits, so a refused access unit reaches the
|
||||||
|
* decoder untouched rather than half rewritten.
|
||||||
|
*
|
||||||
|
* The codec string of the track has to say profile 8 for the decoder to be chosen at all; that is
|
||||||
|
* [DolbyVisionProfile7ExtractorsFactory]'s job. An instance belongs to one video renderer and is
|
||||||
|
* called from its playback thread only.
|
||||||
|
*/
|
||||||
|
class DolbyVisionProfile7Filter(
|
||||||
|
private val newRewriter: () -> RpuRewriter = { LibDoviRpuRewriter() },
|
||||||
|
) : BitstreamFilter {
|
||||||
|
private var rewriter: RpuRewriter? = null
|
||||||
|
private var rewriterUnavailable = false
|
||||||
|
|
||||||
|
/** The access unit as it arrived, for scanning: a heap array is much faster to walk than absolute gets on a direct buffer. */
|
||||||
|
private var staging = ByteArray(0)
|
||||||
|
|
||||||
|
/** The access unit without its enhancement layer, where libdovi rewrites the RPU. Direct, because the native code needs its address. */
|
||||||
|
private var scratch: ByteBuffer? = null
|
||||||
|
|
||||||
|
private var convertedCount = 0
|
||||||
|
private var passedThroughCount = 0
|
||||||
|
private var refusedCount = 0
|
||||||
|
|
||||||
|
override fun filter(
|
||||||
|
data: ByteBuffer,
|
||||||
|
offset: Int,
|
||||||
|
size: Int,
|
||||||
|
): Int {
|
||||||
|
if (!startsWithStartCode(data, offset, size)) {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
val rewriter = rewriter() ?: return size
|
||||||
|
|
||||||
|
val staging = staging(size)
|
||||||
|
window(data, offset, size).get(staging, 0, size)
|
||||||
|
if (!carriesRpu(staging, size)) {
|
||||||
|
passedThroughCount++
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
val scratch = scratch(size + RPU_GROWTH_HEADROOM_BYTES)
|
||||||
|
val kept = copyWithoutEnhancementLayer(staging, size, scratch)
|
||||||
|
val converted =
|
||||||
|
try {
|
||||||
|
if (rewriter.profileOf(scratch, kept) != DOLBY_VISION_PROFILE_7) {
|
||||||
|
passedThroughCount++
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
rewriter.convertToProfile8(scratch, kept)
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
refuse("the RPU rewrite threw", e)
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
if (converted <= 0 || converted > size) {
|
||||||
|
refuse("the RPU rewrite returned $converted bytes for a $size byte access unit", null)
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
window(data, offset, converted).put(window(scratch, 0, converted))
|
||||||
|
convertedCount++
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rewriter(): RpuRewriter? {
|
||||||
|
if (rewriterUnavailable) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return rewriter ?: try {
|
||||||
|
newRewriter().also { rewriter = it }
|
||||||
|
} catch (e: LinkageError) {
|
||||||
|
// No libdovi for this ABI. Playback goes on exactly as it did without the filter.
|
||||||
|
Timber.w(e, "libdovi is not available on this device, Dolby Vision profile 7 is left as it is")
|
||||||
|
rewriterUnavailable = true
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refuse(
|
||||||
|
reason: String,
|
||||||
|
e: Throwable?,
|
||||||
|
) {
|
||||||
|
if (refusedCount == 0) {
|
||||||
|
Timber.w(e, "Leaving a Dolby Vision profile 7 access unit as it is: %s", reason)
|
||||||
|
}
|
||||||
|
refusedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun staging(size: Int): ByteArray {
|
||||||
|
if (staging.size < size) {
|
||||||
|
staging = ByteArray(maxOf(size, INITIAL_CAPACITY_BYTES))
|
||||||
|
}
|
||||||
|
return staging
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scratch(capacity: Int): ByteBuffer {
|
||||||
|
val current = scratch
|
||||||
|
val buffer =
|
||||||
|
if (current == null || current.capacity() < capacity) {
|
||||||
|
ByteBuffer.allocateDirect(maxOf(capacity, INITIAL_CAPACITY_BYTES)).also { scratch = it }
|
||||||
|
} else {
|
||||||
|
current
|
||||||
|
}
|
||||||
|
(buffer as Buffer).clear()
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String =
|
||||||
|
"Dolby Vision profile 7 → 8.1 ($convertedCount access units converted, " +
|
||||||
|
"$passedThroughCount passed through, $refusedCount refused)"
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DOLBY_VISION_PROFILE_7 = 7
|
||||||
|
|
||||||
|
/** HEVC NAL unit type carrying the Dolby Vision RPU. */
|
||||||
|
const val NAL_UNIT_TYPE_RPU = 62
|
||||||
|
|
||||||
|
/** HEVC NAL unit type carrying the Dolby Vision enhancement layer picture. */
|
||||||
|
const val NAL_UNIT_TYPE_ENHANCEMENT_LAYER = 63
|
||||||
|
|
||||||
|
/** Big enough for a UHD access unit without regrowing on every early frame. */
|
||||||
|
const val INITIAL_CAPACITY_BYTES = 512 * 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room past the access unit for an RPU which comes out of the rewrite longer than it went
|
||||||
|
* in. Dropping the enhancement layer usually frees far more, but a profile 7 stream whose
|
||||||
|
* enhancement layer the extractor never delivered has none to free.
|
||||||
|
*/
|
||||||
|
const val RPU_GROWTH_HEADROOM_BYTES = 4 * 1024
|
||||||
|
|
||||||
|
private fun startsWithStartCode(
|
||||||
|
data: ByteBuffer,
|
||||||
|
offset: Int,
|
||||||
|
size: Int,
|
||||||
|
): Boolean {
|
||||||
|
if (size < 4) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return data.get(offset) == 0.toByte() &&
|
||||||
|
data.get(offset + 1) == 0.toByte() &&
|
||||||
|
(
|
||||||
|
data.get(offset + 2) == 1.toByte() ||
|
||||||
|
(data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the Annex B access unit in `[0, length)` of [source] carries an RPU. */
|
||||||
|
private fun carriesRpu(
|
||||||
|
source: ByteArray,
|
||||||
|
length: Int,
|
||||||
|
): Boolean {
|
||||||
|
var unit = 0
|
||||||
|
while (unit < length) {
|
||||||
|
val payload = unit + startCodeLength(source, unit)
|
||||||
|
if (payload >= length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (nalUnitType(source[payload]) == NAL_UNIT_TYPE_RPU) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
unit = nextStartCode(source, payload, length)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the Annex B access unit in `[0, length)` of [source] into [target] from index 0,
|
||||||
|
* leaving out every enhancement layer NAL unit, and returns the number of bytes copied.
|
||||||
|
*/
|
||||||
|
fun copyWithoutEnhancementLayer(
|
||||||
|
source: ByteArray,
|
||||||
|
length: Int,
|
||||||
|
target: ByteBuffer,
|
||||||
|
): Int {
|
||||||
|
var unit = 0
|
||||||
|
var written = 0
|
||||||
|
while (unit < length) {
|
||||||
|
val payload = unit + startCodeLength(source, unit)
|
||||||
|
if (payload >= length) {
|
||||||
|
// A start code with nothing behind it. Not something to interpret; kept as it came.
|
||||||
|
target.put(source, unit, length - unit)
|
||||||
|
written += length - unit
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val next = nextStartCode(source, payload, length)
|
||||||
|
if (nalUnitType(source[payload]) != NAL_UNIT_TYPE_ENHANCEMENT_LAYER) {
|
||||||
|
target.put(source, unit, next - unit)
|
||||||
|
written += next - unit
|
||||||
|
}
|
||||||
|
unit = next
|
||||||
|
}
|
||||||
|
return written
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A view of `[offset, offset + length)` of [data] with a position and limit of its own, so
|
||||||
|
* that bulk copies leave the buffer's untouched. The position and limit calls go through
|
||||||
|
* [Buffer]: the [ByteBuffer] overrides which return a ByteBuffer are API 34, and `minSdk` is 23.
|
||||||
|
*/
|
||||||
|
private fun window(
|
||||||
|
data: ByteBuffer,
|
||||||
|
offset: Int,
|
||||||
|
length: Int,
|
||||||
|
): ByteBuffer {
|
||||||
|
val view = data.duplicate()
|
||||||
|
val buffer: Buffer = view
|
||||||
|
buffer.clear()
|
||||||
|
buffer.position(offset)
|
||||||
|
buffer.limit(offset + length)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/** nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2. */
|
||||||
|
private fun nalUnitType(header: Byte): Int = (header.toInt() shr 1) and 0x3F
|
||||||
|
|
||||||
|
/** Length of the start code at [at], which the caller knows is one: 3 or 4 bytes. */
|
||||||
|
private fun startCodeLength(
|
||||||
|
data: ByteArray,
|
||||||
|
at: Int,
|
||||||
|
): Int = if (data[at + 2] == 1.toByte()) 3 else 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the next Annex B start code at or after [from], or [limit] if there is none. A
|
||||||
|
* fourth leading zero byte counts as part of the start code, which is the form media3's
|
||||||
|
* extractors emit; three byte codes are walked all the same.
|
||||||
|
*/
|
||||||
|
private fun nextStartCode(
|
||||||
|
data: ByteArray,
|
||||||
|
from: Int,
|
||||||
|
limit: Int,
|
||||||
|
): Int {
|
||||||
|
var i = from
|
||||||
|
while (i + 2 < limit) {
|
||||||
|
if (data[i] == 0.toByte() && data[i + 1] == 0.toByte() && data[i + 2] == 1.toByte()) {
|
||||||
|
return if (i > from && data[i - 1] == 0.toByte()) i - 1 else i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -241,6 +241,7 @@ message ExperimentalPreferences {
|
||||||
bool prefer_ac3_surround = 3;
|
bool prefer_ac3_surround = 3;
|
||||||
bool disable_audio_offload = 4;
|
bool disable_audio_offload = 4;
|
||||||
bool prefer_dolby_vision_over_hdr10_plus = 5;
|
bool prefer_dolby_vision_over_hdr10_plus = 5;
|
||||||
|
bool convert_dolby_vision_profile_7 = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AppPreferences {
|
message AppPreferences {
|
||||||
|
|
|
||||||
|
|
@ -503,6 +503,8 @@
|
||||||
<string name="force_dovi_profile_7_summary">Ignores device compatibility checks</string>
|
<string name="force_dovi_profile_7_summary">Ignores device compatibility checks</string>
|
||||||
<string name="prefer_dovi_over_hdr10_plus">Prefer Dolby Vision over HDR10+</string>
|
<string name="prefer_dovi_over_hdr10_plus">Prefer Dolby Vision over HDR10+</string>
|
||||||
<string name="prefer_dovi_over_hdr10_plus_summary">Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+</string>
|
<string name="prefer_dovi_over_hdr10_plus_summary">Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+</string>
|
||||||
|
<string name="convert_dovi_profile_7">Convert Dolby Vision Profile 7 to 8.1</string>
|
||||||
|
<string name="convert_dovi_profile_7_summary">Rewrites dual-layer Profile 7 into single-layer Profile 8.1 before the decoder and direct plays it, for devices whose decoder lists Profile 8 but not Profile 7</string>
|
||||||
|
|
||||||
<string name="discover">Discover</string>
|
<string name="discover">Discover</string>
|
||||||
<string name="request">Request</string>
|
<string name="request">Request</string>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import androidx.media3.common.DrmInitData
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DolbyVisionProfile7ConversionTest {
|
||||||
|
@Test
|
||||||
|
fun recognisesProfile7InBothSampleEntryForms() {
|
||||||
|
assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.07.06")))
|
||||||
|
assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvh1.07.06")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotMistakeOtherProfilesOrCodecsForProfile7() {
|
||||||
|
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.08.06")))
|
||||||
|
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.05.06")))
|
||||||
|
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision(null)))
|
||||||
|
assertFalse(
|
||||||
|
DolbyVisionProfile7Conversion.isProfile7(
|
||||||
|
Format
|
||||||
|
.Builder()
|
||||||
|
.setSampleMimeType(MimeTypes.VIDEO_H265)
|
||||||
|
.setCodecs("dvhe.07.06")
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rewritesOnlyTheProfileOfTheCodecString() {
|
||||||
|
assertEquals("dvhe.08.06", DolbyVisionProfile7Conversion.toProfile8("dvhe.07.06"))
|
||||||
|
assertEquals("dvh1.08.09", DolbyVisionProfile7Conversion.toProfile8("dvh1.07.09"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun advertisesAProfile7TrackAsProfile8AndKeepsTheRestOfTheFormat() {
|
||||||
|
val format = dolbyVision("dvhe.07.06")
|
||||||
|
|
||||||
|
val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format)
|
||||||
|
|
||||||
|
assertEquals("dvhe.08.06", advertised.codecs)
|
||||||
|
assertEquals(MimeTypes.VIDEO_DOLBY_VISION, advertised.sampleMimeType)
|
||||||
|
assertEquals(format.width, advertised.width)
|
||||||
|
assertEquals(format.height, advertised.height)
|
||||||
|
assertEquals(format.initializationData, advertised.initializationData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesTracksWhichAreNotProfile7AsTheSameInstance() {
|
||||||
|
val profile8 = dolbyVision("dvhe.08.06")
|
||||||
|
val hevc = Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H265).build()
|
||||||
|
|
||||||
|
assertSame(profile8, DolbyVisionProfile7Conversion.advertiseAsProfile8(profile8))
|
||||||
|
assertSame(hevc, DolbyVisionProfile7Conversion.advertiseAsProfile8(hevc))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesAnEncryptedProfile7TrackAlone() {
|
||||||
|
// Encrypted samples take the secure path, which the bitstream filter does not touch
|
||||||
|
val encrypted =
|
||||||
|
dolbyVision("dvhe.07.06")
|
||||||
|
.buildUpon()
|
||||||
|
.setDrmInitData(DrmInitData(DrmInitData.SchemeData(java.util.UUID.randomUUID(), MimeTypes.VIDEO_MP4, byteArrayOf(1))))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertSame(encrypted, DolbyVisionProfile7Conversion.advertiseAsProfile8(encrypted))
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun dolbyVision(codecs: String?): Format =
|
||||||
|
Format
|
||||||
|
.Builder()
|
||||||
|
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
|
||||||
|
.setCodecs(codecs)
|
||||||
|
.setWidth(3840)
|
||||||
|
.setHeight(2160)
|
||||||
|
.setInitializationData(listOf(byteArrayOf(0x01, 0x02)))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
@file:OptIn(markerClass = [UnstableApi::class])
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.util.ParsableByteArray
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.extractor.Extractor
|
||||||
|
import androidx.media3.extractor.ExtractorOutput
|
||||||
|
import androidx.media3.extractor.ExtractorsFactory
|
||||||
|
import androidx.media3.extractor.TrackOutput
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The factory has to reach the video track's format through three layers of media3 forwarding,
|
||||||
|
* rewrite it there and nowhere else, and let sample data through untouched.
|
||||||
|
*/
|
||||||
|
class DolbyVisionProfile7ExtractorsFactoryTest {
|
||||||
|
private val videoTrack = mockk<TrackOutput>(relaxed = true)
|
||||||
|
private val audioTrack = mockk<TrackOutput>(relaxed = true)
|
||||||
|
private val output =
|
||||||
|
mockk<ExtractorOutput>(relaxed = true) {
|
||||||
|
every { track(VIDEO_ID, C.TRACK_TYPE_VIDEO) } returns videoTrack
|
||||||
|
every { track(AUDIO_ID, C.TRACK_TYPE_AUDIO) } returns audioTrack
|
||||||
|
}
|
||||||
|
private val extractor = mockk<Extractor>(relaxed = true)
|
||||||
|
private val delegate =
|
||||||
|
mockk<ExtractorsFactory> {
|
||||||
|
every { createExtractors() } returns arrayOf(extractor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun advertisesTheVideoTrackAsProfile8() {
|
||||||
|
val video = initialisedVideoTrack()
|
||||||
|
|
||||||
|
video.format(dolbyVision("dvhe.07.06"))
|
||||||
|
|
||||||
|
val published = slot<Format>()
|
||||||
|
verify { videoTrack.format(capture(published)) }
|
||||||
|
assertEquals("dvhe.08.06", published.captured.codecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesOtherVideoFormatsAsTheyAre() {
|
||||||
|
val video = initialisedVideoTrack()
|
||||||
|
val hevc =
|
||||||
|
Format
|
||||||
|
.Builder()
|
||||||
|
.setSampleMimeType(MimeTypes.VIDEO_H265)
|
||||||
|
.setCodecs("hvc1.2.4.L153")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
video.format(hevc)
|
||||||
|
|
||||||
|
verify { videoTrack.format(hevc) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesTheAudioTrackUnwrapped() {
|
||||||
|
val wrapped = wrappedOutput()
|
||||||
|
|
||||||
|
assertSame(audioTrack, wrapped.track(AUDIO_ID, C.TRACK_TYPE_AUDIO))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun passesSampleDataStraightThrough() {
|
||||||
|
val video = initialisedVideoTrack()
|
||||||
|
val data = ParsableByteArray(byteArrayOf(1, 2, 3))
|
||||||
|
|
||||||
|
video.sampleData(data, 3)
|
||||||
|
video.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null)
|
||||||
|
|
||||||
|
verify { videoTrack.sampleData(data, 3) }
|
||||||
|
verify { videoTrack.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrapsEveryExtractorOfTheDelegate() {
|
||||||
|
val another = mockk<Extractor>(relaxed = true)
|
||||||
|
every { delegate.createExtractors() } returns arrayOf(extractor, another)
|
||||||
|
|
||||||
|
val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors()
|
||||||
|
|
||||||
|
assertEquals(2, extractors.size)
|
||||||
|
extractors[1].init(output)
|
||||||
|
verify { another.init(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wrappedOutput(): ExtractorOutput {
|
||||||
|
val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors()
|
||||||
|
extractors.single().init(output)
|
||||||
|
val wrapped = slot<ExtractorOutput>()
|
||||||
|
verify { extractor.init(capture(wrapped)) }
|
||||||
|
return wrapped.captured
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initialisedVideoTrack(): TrackOutput = wrappedOutput().track(VIDEO_ID, C.TRACK_TYPE_VIDEO)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val VIDEO_ID = 1
|
||||||
|
const val AUDIO_ID = 2
|
||||||
|
|
||||||
|
fun dolbyVision(codecs: String): Format =
|
||||||
|
Format
|
||||||
|
.Builder()
|
||||||
|
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
|
||||||
|
.setCodecs(codecs)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Test
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The RPU rewrite itself is libdovi's and runs on a device; here it is a fake, and what is under
|
||||||
|
* test is everything around it: which access units are touched at all, that the enhancement layer
|
||||||
|
* goes and the rest stays contiguous, that a refused rewrite leaves the decoder's buffer exactly as
|
||||||
|
* it was, and that the filter keeps to the [BitstreamFilter] contract on a direct buffer.
|
||||||
|
*/
|
||||||
|
class DolbyVisionProfile7FilterTest {
|
||||||
|
@Test
|
||||||
|
fun dropsTheEnhancementLayerAndRewritesTheRpu() {
|
||||||
|
val filter = filterWith(FakeRewriter(profile = 7))
|
||||||
|
val target = directBuffer(annexB(VPS, VCL, EL, EL, RPU))
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(annexB(VPS, VCL, CONVERTED_RPU).toList(), target.bytes(0, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun convertsAnRpuWhichArrivedWithoutAnEnhancementLayer() {
|
||||||
|
// A dual layer remux whose enhancement layer lives in Matroska block additions, which the
|
||||||
|
// extractor discards: the RPU still needs its rewrite for the profile 8 decoder
|
||||||
|
val filter = filterWith(FakeRewriter(profile = 7))
|
||||||
|
val target = directBuffer(annexB(VCL, RPU))
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(0, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun worksFromAnOffsetAndLeavesTheBytesAroundTheAccessUnitAlone() {
|
||||||
|
val filter = filterWith(FakeRewriter(profile = 7))
|
||||||
|
val accessUnit = annexB(VCL, EL, RPU)
|
||||||
|
val prefix = byteArrayOf(0x7A, 0x7B)
|
||||||
|
val suffix = byteArrayOf(0x7C)
|
||||||
|
val target = directBuffer(prefix + accessUnit + suffix)
|
||||||
|
|
||||||
|
val size = filter.filter(target, prefix.size, accessUnit.size)
|
||||||
|
|
||||||
|
assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(prefix.size, size))
|
||||||
|
assertEquals(prefix.toList(), target.bytes(0, prefix.size))
|
||||||
|
assertEquals(suffix.toList(), target.bytes(prefix.size + accessUnit.size, suffix.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotMoveThePositionOrLimitOfTheBuffer() {
|
||||||
|
val filter = filterWith(FakeRewriter(profile = 7))
|
||||||
|
val target = directBuffer(annexB(VCL, EL, RPU))
|
||||||
|
target.position(3)
|
||||||
|
target.limit(target.capacity() - 1)
|
||||||
|
|
||||||
|
filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(3, target.position())
|
||||||
|
assertEquals(target.capacity() - 1, target.limit())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesAnAccessUnitWithoutAnRpuAlone() {
|
||||||
|
val rewriter = FakeRewriter(profile = 7)
|
||||||
|
val filter = filterWith(rewriter)
|
||||||
|
val accessUnit = annexB(VPS, VCL, EL)
|
||||||
|
val target = directBuffer(accessUnit)
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(accessUnit.size, size)
|
||||||
|
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||||
|
assertEquals(0, rewriter.conversions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesAnRpuOfAnotherProfileAlone() {
|
||||||
|
val rewriter = FakeRewriter(profile = 8)
|
||||||
|
val filter = filterWith(rewriter)
|
||||||
|
val accessUnit = annexB(VCL, RPU)
|
||||||
|
val target = directBuffer(accessUnit)
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(accessUnit.size, size)
|
||||||
|
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||||
|
assertEquals(0, rewriter.conversions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesAnAccessUnitAloneWhenTheRewriteThrows() {
|
||||||
|
val filter = filterWith(FakeRewriter(profile = 7, throws = true))
|
||||||
|
val accessUnit = annexB(VCL, EL, RPU)
|
||||||
|
val target = directBuffer(accessUnit)
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(accessUnit.size, size)
|
||||||
|
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesAnAccessUnitAloneWhenTheRewriteWouldNotFit() {
|
||||||
|
// No enhancement layer to make room, and an RPU which comes out longer than it went in
|
||||||
|
val filter = filterWith(FakeRewriter(profile = 7, converted = RPU + ByteArray(16) { 0x55 }))
|
||||||
|
val accessUnit = annexB(VCL, RPU)
|
||||||
|
val target = directBuffer(accessUnit)
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(accessUnit.size, size)
|
||||||
|
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun takesALongerRpuWhenTheEnhancementLayerMadeRoomForIt() {
|
||||||
|
val longerRpu = RPU + byteArrayOf(0x55, 0x56)
|
||||||
|
val filter = filterWith(FakeRewriter(profile = 7, converted = longerRpu))
|
||||||
|
val target = directBuffer(annexB(VCL, EL, RPU))
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(annexB(VCL, longerRpu).toList(), target.bytes(0, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesAnAccessUnitAloneWhenTheRewriteReportsNonsense() {
|
||||||
|
val filter = filterWith(FakeRewriter(profile = 7, reportedSize = -1))
|
||||||
|
val accessUnit = annexB(VCL, EL, RPU)
|
||||||
|
val target = directBuffer(accessUnit)
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(accessUnit.size, size)
|
||||||
|
assertEquals(accessUnit.toList(), target.bytes(0, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesDataWhichIsNotAnnexBAlone() {
|
||||||
|
val rewriter = FakeRewriter(profile = 7)
|
||||||
|
val filter = filterWith(rewriter)
|
||||||
|
val data = byteArrayOf(0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x01, 0x7C)
|
||||||
|
val target = directBuffer(data)
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(data.size, size)
|
||||||
|
assertEquals(data.toList(), target.bytes(0, size))
|
||||||
|
assertEquals(0, rewriter.conversions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun walksThreeByteStartCodesToo() {
|
||||||
|
val filter = filterWith(FakeRewriter(profile = 7))
|
||||||
|
val accessUnit = SHORT_START_CODE + VCL + SHORT_START_CODE + EL + SHORT_START_CODE + RPU
|
||||||
|
val target = directBuffer(accessUnit)
|
||||||
|
|
||||||
|
val size = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals((SHORT_START_CODE + VCL + SHORT_START_CODE + CONVERTED_RPU).toList(), target.bytes(0, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givesUpForGoodWhenTheNativeLibraryIsMissing() {
|
||||||
|
var attempts = 0
|
||||||
|
val filter =
|
||||||
|
DolbyVisionProfile7Filter {
|
||||||
|
attempts++
|
||||||
|
throw UnsatisfiedLinkError("no libdovi_android for this ABI")
|
||||||
|
}
|
||||||
|
val accessUnit = annexB(VCL, EL, RPU)
|
||||||
|
val target = directBuffer(accessUnit)
|
||||||
|
|
||||||
|
val first = filter.filter(target, 0, target.capacity())
|
||||||
|
val second = filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(accessUnit.size, first)
|
||||||
|
assertEquals(accessUnit.size, second)
|
||||||
|
assertEquals(accessUnit.toList(), target.bytes(0, accessUnit.size))
|
||||||
|
assertEquals("the library should be tried once, not on every access unit", 1, attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handsTheRewriterTheAccessUnitWithoutTheEnhancementLayer() {
|
||||||
|
val rewriter = FakeRewriter(profile = 7)
|
||||||
|
val filter = filterWith(rewriter)
|
||||||
|
val target = directBuffer(annexB(VPS, VCL, EL, RPU, EL))
|
||||||
|
|
||||||
|
filter.filter(target, 0, target.capacity())
|
||||||
|
|
||||||
|
assertEquals(annexB(VPS, VCL, RPU).toList(), rewriter.lastFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stands in for libdovi. It finds the RPU NAL unit the way the native code does, by walking the
|
||||||
|
* Annex B start codes, and swaps its payload for [converted], moving whatever follows.
|
||||||
|
*/
|
||||||
|
private class FakeRewriter(
|
||||||
|
private val profile: Int,
|
||||||
|
private val converted: ByteArray = CONVERTED_RPU,
|
||||||
|
private val throws: Boolean = false,
|
||||||
|
private val reportedSize: Int? = null,
|
||||||
|
) : RpuRewriter {
|
||||||
|
var conversions = 0
|
||||||
|
var lastFrame: List<Byte> = emptyList()
|
||||||
|
|
||||||
|
override fun profileOf(
|
||||||
|
frame: ByteBuffer,
|
||||||
|
size: Int,
|
||||||
|
): Int {
|
||||||
|
assertFalse("libdovi reads the buffer's address, so the frame has to be direct", frame.hasArray())
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToProfile8(
|
||||||
|
frame: ByteBuffer,
|
||||||
|
size: Int,
|
||||||
|
): Int {
|
||||||
|
conversions++
|
||||||
|
lastFrame = frame.bytes(0, size)
|
||||||
|
if (throws) {
|
||||||
|
throw IllegalArgumentException("Unable to parse Dolby Vision RPU")
|
||||||
|
}
|
||||||
|
val bytes = frame.bytes(0, size).toByteArray()
|
||||||
|
val rpuStart = indexOfNal(bytes, RPU)
|
||||||
|
val rpuEnd = nextStartCode(bytes, rpuStart + 1)
|
||||||
|
val rewritten = bytes.copyOfRange(0, rpuStart) + converted + bytes.copyOfRange(rpuEnd, bytes.size)
|
||||||
|
check(rewritten.size <= frame.capacity()) { "the fake outgrew the scratch buffer" }
|
||||||
|
rewritten.forEachIndexed { index, byte -> frame.put(index, byte) }
|
||||||
|
return reportedSize ?: rewritten.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun indexOfNal(
|
||||||
|
bytes: ByteArray,
|
||||||
|
nal: ByteArray,
|
||||||
|
): Int {
|
||||||
|
for (i in 0..bytes.size - nal.size) {
|
||||||
|
if (bytes.copyOfRange(i, i + nal.size).contentEquals(nal)) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw AssertionError("RPU not found in the frame handed to the rewriter")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextStartCode(
|
||||||
|
bytes: ByteArray,
|
||||||
|
from: Int,
|
||||||
|
): Int {
|
||||||
|
for (i in from..bytes.size - 3) {
|
||||||
|
if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 1.toByte()) {
|
||||||
|
return if (i > from && bytes[i - 1] == 0.toByte()) i - 1 else i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val START_CODE = byteArrayOf(0, 0, 0, 1)
|
||||||
|
private val SHORT_START_CODE = byteArrayOf(0, 0, 1)
|
||||||
|
|
||||||
|
/** NAL unit header for [type]: forbidden_zero_bit 0, nuh_layer_id 0, nuh_temporal_id_plus1 1. */
|
||||||
|
private fun nal(
|
||||||
|
type: Int,
|
||||||
|
vararg payload: Byte,
|
||||||
|
): ByteArray = byteArrayOf((type shl 1).toByte(), 0x01) + payload
|
||||||
|
|
||||||
|
private val VPS = nal(32, 0x0C, 0x01, 0x7F)
|
||||||
|
private val VCL = nal(1, 0x11, 0x22, 0x33, 0x44)
|
||||||
|
private val EL = nal(63, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E)
|
||||||
|
private val RPU = nal(62, 0x19, 0x07, 0x07, 0x07)
|
||||||
|
private val CONVERTED_RPU = nal(62, 0x19, 0x08, 0x08)
|
||||||
|
|
||||||
|
private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal }
|
||||||
|
|
||||||
|
private fun filterWith(rewriter: RpuRewriter) = DolbyVisionProfile7Filter { rewriter }
|
||||||
|
|
||||||
|
/** Like the decoder input buffer on a device: direct, with no backing array. */
|
||||||
|
private fun directBuffer(data: ByteArray): ByteBuffer =
|
||||||
|
ByteBuffer.allocateDirect(data.size).also { buffer ->
|
||||||
|
data.forEachIndexed { index, byte -> buffer.put(index, byte) }
|
||||||
|
assertFalse(buffer.hasArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteBuffer.bytes(
|
||||||
|
from: Int,
|
||||||
|
count: Int,
|
||||||
|
): List<Byte> = (from until from + count).map { get(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,8 @@ tvMaterial = "1.1.0-alpha01"
|
||||||
lifecycleRuntimeKtx = "2.11.0"
|
lifecycleRuntimeKtx = "2.11.0"
|
||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
androidx-media3 = "1.11.1"
|
androidx-media3 = "1.11.1"
|
||||||
|
# Dolby Vision profile 7 -> 8.1 RPU rewrite (libdovi behind JNI), MIT
|
||||||
|
exoplayerhdrutils = "0.4.1"
|
||||||
coil = "3.6.2"
|
coil = "3.6.2"
|
||||||
jellyfin-sdk = "1.7.1"
|
jellyfin-sdk = "1.7.1"
|
||||||
nav3Core = "1.1.7"
|
nav3Core = "1.1.7"
|
||||||
|
|
@ -118,6 +120,7 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da
|
||||||
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||||
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
||||||
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
|
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
|
||||||
|
exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" }
|
||||||
|
|
||||||
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue