Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ dependencies {
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:$media3_version")
implementation("io.github.peerless2012:ass-media:0.3.0")

testImplementation("junit:junit:4.13.2")

//UI
implementation("io.github.rabehx:iconsax-compose:0.0.5")
implementation("io.coil-kt.coil3:coil-compose:3.3.0")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ enum class SegmentSkip(val raw: Int) {
/** Generated class from Pigeon that represents data sent in messages. */
data class PlayerSettings (
val enableTunneling: Boolean,
val ignoreHdr10Plus: Boolean,
val skipTypes: Map<SegmentType, SegmentSkip>,
val themeColor: Long? = null,
val skipForward: Long,
Expand All @@ -164,21 +165,23 @@ data class PlayerSettings (
companion object {
fun fromList(pigeonVar_list: List<Any?>): PlayerSettings {
val enableTunneling = pigeonVar_list[0] as Boolean
val skipTypes = pigeonVar_list[1] as Map<SegmentType, SegmentSkip>
val themeColor = pigeonVar_list[2] as Long?
val skipForward = pigeonVar_list[3] as Long
val skipBackward = pigeonVar_list[4] as Long
val autoNextType = pigeonVar_list[5] as AutoNextType
val acceptedOrientations = pigeonVar_list[6] as List<PlayerOrientations>
val fillScreen = pigeonVar_list[7] as Boolean
val videoFit = pigeonVar_list[8] as VideoPlayerFit
val screensaver = pigeonVar_list[9] as Screensaver
return PlayerSettings(enableTunneling, skipTypes, themeColor, skipForward, skipBackward, autoNextType, acceptedOrientations, fillScreen, videoFit, screensaver)
val ignoreHdr10Plus = pigeonVar_list[1] as Boolean
val skipTypes = pigeonVar_list[2] as Map<SegmentType, SegmentSkip>
val themeColor = pigeonVar_list[3] as Long?
val skipForward = pigeonVar_list[4] as Long
val skipBackward = pigeonVar_list[5] as Long
val autoNextType = pigeonVar_list[6] as AutoNextType
val acceptedOrientations = pigeonVar_list[7] as List<PlayerOrientations>
val fillScreen = pigeonVar_list[8] as Boolean
val videoFit = pigeonVar_list[9] as VideoPlayerFit
val screensaver = pigeonVar_list[10] as Screensaver
return PlayerSettings(enableTunneling, ignoreHdr10Plus, skipTypes, themeColor, skipForward, skipBackward, autoNextType, acceptedOrientations, fillScreen, videoFit, screensaver)
}
}
fun toList(): List<Any?> {
return listOf(
enableTunneling,
ignoreHdr10Plus,
skipTypes,
themeColor,
skipForward,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ internal fun ExoPlayer(
) {
val videoHost = VideoPlayerObject
val context = LocalContext.current
val playerSettings by PlayerSettingsObject.settings.collectAsState()
val settings = playerSettings ?: return
val ignoreHdr10Plus = settings.ignoreHdr10Plus
val enableTunneling = settings.enableTunneling

val extractorsFactory = DefaultExtractorsFactory().apply {
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
Expand All @@ -90,7 +94,11 @@ internal fun ExoPlayer(
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()

val renderersFactory = DefaultRenderersFactory(context)
val renderersFactory = (if (ignoreHdr10Plus) {
StripHDR10PlusRenderersFactory(context)
} else {
DefaultRenderersFactory(context)
})
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
.setEnableDecoderFallback(true)

Expand All @@ -101,12 +109,12 @@ internal fun ExoPlayer(
setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
}.build()
)
setTunnelingEnabled(PlayerSettingsObject.settings.value?.enableTunneling ?: false)
setTunnelingEnabled(enableTunneling)
setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
})
}

val exoPlayer = remember {
val exoPlayer = remember(ignoreHdr10Plus, enableTunneling) {
ExoPlayer.Builder(context, renderersFactory)
.setTrackSelector(trackSelector)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package nl.jknaapen.fladder.player

import java.nio.ByteBuffer

/**
* In-place sanitizer for HEVC Annex B buffers carrying both Dolby Vision and HDR10+
* dynamic metadata. Some Android TV chipsets fail when a native DV decoder also
* receives in-band HDR10+ SEI, so keep only the dynamic metadata for the selected
* decode path.
*/
object StripHDR10PlusBitstreamSanitizer {
private const val NAL_TYPE_PREFIX_SEI = 39
private const val NAL_TYPE_SUFFIX_SEI = 40
private const val NAL_TYPE_UNSPEC62 = 62
private const val NAL_TYPE_UNSPEC63 = 63

private const val SEI_PAYLOAD_TYPE_ITU_T_T35 = 4

fun sanitize(data: ByteBuffer, stripHdr10PlusSei: Boolean, stripDvRpu: Boolean) {
val startPos = data.position()
val limit = data.limit()
var writePos = startPos
var nalStartIndex = -1
var startCodeLen = 0

var i = startPos
while (i <= limit) {
val atEnd = i == limit
var foundStartCode = false
var nextStartCodeLen = 0
if (!atEnd && i + 2 < limit && data.get(i).toInt() == 0 && data.get(i + 1).toInt() == 0) {
if (data.get(i + 2).toInt() == 1) {
foundStartCode = true
nextStartCodeLen = 3
} else if (data.get(i + 2).toInt() == 0 && i + 3 < limit && data.get(i + 3).toInt() == 1) {
foundStartCode = true
nextStartCodeLen = 4
}
}

if (foundStartCode || atEnd) {
if (nalStartIndex >= 0) {
val nalDataStart = nalStartIndex + startCodeLen
val nalEnd = i
var strip = false

if (nalEnd - nalDataStart >= 2) {
val nalUnitType = (data.get(nalDataStart).toInt() and 0x7E) shr 1
strip = when (nalUnitType) {
NAL_TYPE_UNSPEC62, NAL_TYPE_UNSPEC63 -> stripDvRpu
NAL_TYPE_PREFIX_SEI, NAL_TYPE_SUFFIX_SEI ->
stripHdr10PlusSei && isHdr10PlusSeiNalUnit(data, nalDataStart + 2, nalEnd)
else -> false
}
}

if (!strip) {
if (writePos != nalStartIndex) {
for (j in nalStartIndex until nalEnd) {
data.put(writePos++, data.get(j))
}
} else {
writePos = nalEnd
}
}
}
nalStartIndex = i
startCodeLen = nextStartCodeLen
i += if (nextStartCodeLen > 0) nextStartCodeLen else 1
} else {
i++
}
}

data.limit(writePos)
data.position(startPos)
}

private fun isHdr10PlusSeiNalUnit(data: ByteBuffer, rbspStart: Int, nalEnd: Int): Boolean {
var pos = rbspStart
if (pos >= nalEnd) return false

var payloadType = 0
while (pos < nalEnd) {
val value = data.get(pos++).toInt() and 0xFF
payloadType += value
if (value != 0xFF) break
}

var payloadSize = 0
while (pos < nalEnd) {
val value = data.get(pos++).toInt() and 0xFF
payloadSize += value
if (value != 0xFF) break
}

if (payloadType != SEI_PAYLOAD_TYPE_ITU_T_T35 || payloadSize < 7 || pos + 7 > nalEnd) {
return false
}

val countryCode = data.get(pos).toInt() and 0xFF
val providerCode = ((data.get(pos + 1).toInt() and 0xFF) shl 8) or (data.get(pos + 2).toInt() and 0xFF)
val orientedCode = ((data.get(pos + 3).toInt() and 0xFF) shl 8) or (data.get(pos + 4).toInt() and 0xFF)
val appIdentifier = data.get(pos + 5).toInt() and 0xFF
val appVersion = data.get(pos + 6).toInt() and 0xFF

return countryCode == 0xB5 &&
providerCode == 0x003C &&
orientedCode == 0x0001 &&
appIdentifier == 4 &&
(appVersion == 0 || appVersion == 1)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package nl.jknaapen.fladder.player

import android.content.Context
import android.os.Handler
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.decoder.DecoderInputBuffer
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener

private const val TAG = "FladderPlayer"

@OptIn(UnstableApi::class)
class StripHDR10PlusRenderersFactory(context: Context) : DefaultRenderersFactory(context) {
override fun buildVideoRenderers(
context: Context,
extensionRendererMode: Int,
mediaCodecSelector: MediaCodecSelector,
enableDecoderFallback: Boolean,
eventHandler: Handler,
eventListener: VideoRendererEventListener,
allowedVideoJoiningTimeMs: Long,
out: ArrayList<Renderer>
) {
super.buildVideoRenderers(
context,
extensionRendererMode,
mediaCodecSelector,
enableDecoderFallback,
eventHandler,
eventListener,
allowedVideoJoiningTimeMs,
out
)

val rendererIndex = out.indexOfFirst { it is MediaCodecVideoRenderer }
if (rendererIndex < 0) {
Log.w(TAG, "HDR10+ stripping backend enabled, but no MediaCodec video renderer was found")
return
}

out[rendererIndex] = StripHDR10PlusVideoRenderer(
MediaCodecVideoRenderer.Builder(context)
.setCodecAdapterFactory(codecAdapterFactory)
.setMediaCodecSelector(mediaCodecSelector)
.setAllowedJoiningTimeMs(allowedVideoJoiningTimeMs)
.setEnableDecoderFallback(enableDecoderFallback)
.setEventHandler(eventHandler)
.setEventListener(eventListener)
.setMaxDroppedFramesToNotify(MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)
)
}
}

@OptIn(UnstableApi::class)
private class StripHDR10PlusVideoRenderer(builder: MediaCodecVideoRenderer.Builder) : MediaCodecVideoRenderer(builder) {
private var stripHdr10PlusSei = false
private var stripDvRpu = false

override fun onCodecInitialized(
name: String,
configuration: MediaCodecAdapter.Configuration,
initializedTimestampMs: Long,
initializationDurationMs: Long
) {
super.onCodecInitialized(name, configuration, initializedTimestampMs, initializationDurationMs)

val codecs = configuration.format.codecs?.lowercase() ?: ""
val dvHevcFormat = configuration.format.sampleMimeType == MimeTypes.VIDEO_DOLBY_VISION &&
(codecs.startsWith("dvhe.") || codecs.startsWith("dvh1."))
val codecMimeType = configuration.codecInfo.codecMimeType
val newStripHdr10PlusSei = dvHevcFormat && codecMimeType == MimeTypes.VIDEO_DOLBY_VISION
val newStripDvRpu = dvHevcFormat &&
codecMimeType == MimeTypes.VIDEO_H265 &&
isBlCompatibleDvProfile(codecs)

if (newStripHdr10PlusSei != stripHdr10PlusSei || newStripDvRpu != stripDvRpu) {
Log.i(
TAG,
"DV bitstream sanitizing: stripHdr10PlusSei=$newStripHdr10PlusSei, " +
"stripDvRpu=$newStripDvRpu (codec=$name, codecs=${configuration.format.codecs})"
)
}

stripHdr10PlusSei = newStripHdr10PlusSei
stripDvRpu = newStripDvRpu
}

override fun onQueueInputBuffer(buffer: DecoderInputBuffer) {
if (stripHdr10PlusSei || stripDvRpu) {
val data = buffer.data
if (data != null && data.hasRemaining() && !buffer.isEncrypted) {
StripHDR10PlusBitstreamSanitizer.sanitize(data, stripHdr10PlusSei, stripDvRpu)
}
}
super.onQueueInputBuffer(buffer)
}

private fun isBlCompatibleDvProfile(codecs: String): Boolean =
codecs.startsWith("dvhe.07") ||
codecs.startsWith("dvh1.07") ||
codecs.startsWith("dvhe.08") ||
codecs.startsWith("dvh1.08")
}
Loading
Loading