From 4dd44b935c2f163e34ed05c886675edc7f28662b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:35:41 -0500 Subject: [PATCH] Add optional AV1 software decoding via dav1d for ExoPlayer (#721) ## Description Adds the [`dav1d` decoder module](https://github.com/androidx/media/tree/release/libraries/decoder_av1) for ExoPlayer as an option. It can be enabled in advanced settings. Performance will vary between devices. ### Related issues Closes #103 ### Dev notes As with the ffmpeg module, this one is not required to build the entire app so it's easier for developers to get started. This also introduces a customized `DefaultRenderersFactory` for ExoPlayer, but its largely copied from the super class's implementation. --- .github/actions/native-build/action.yml | 37 ++++---- app/build.gradle.kts | 4 + .../wholphin/preferences/AppPreference.kt | 13 +++ .../wholphin/services/DeviceProfileService.kt | 3 + .../wholphin/services/PlayerFactory.kt | 84 ++++++++++++++++++- .../util/profile/DeviceProfileUtils.kt | 34 ++++---- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 1 + scripts/ffmpeg/build_ffmpeg_decoder.sh | 29 ++++++- 9 files changed, 170 insertions(+), 36 deletions(-) diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml index 1f35c259..309c6b59 100644 --- a/.github/actions/native-build/action.yml +++ b/.github/actions/native-build/action.yml @@ -15,6 +15,25 @@ runs: path: | app/libs key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }} + - name: Load libmpv module cache + id: cache_libmpv_module + if: ${{ inputs.cache }} + uses: actions/cache/restore@v4 + with: + path: | + app/src/main/libs + key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }} + - name: Install dependencies + if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' || steps.cache_libmpv_module.outputs.cache-hit != 'true' + shell: bash + run: | + python -m pip install --upgrade pip + pip install jsonschema jinja2 + sudo apt update + sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson nasm + + +# ffmpeg - name: Build ffmpeg decoder id: ffmpeg-decoder if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' @@ -30,23 +49,7 @@ runs: app/libs key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }} - - name: Load libmpv module cache - id: cache_libmpv_module - if: ${{ inputs.cache }} - uses: actions/cache/restore@v4 - with: - path: | - app/src/main/libs - key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }} - - - name: Install dependencies - if: steps.cache_libmpv_module.outputs.cache-hit != 'true' - shell: bash - run: | - python -m pip install --upgrade pip - pip install jsonschema jinja2 - sudo apt update - sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson +# libmpv - name: Get libmpv dependencies if: steps.cache_libmpv_module.outputs.cache-hit != 'true' shell: bash diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e858044..8b3f74ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,7 @@ plugins { val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false val shouldSign = isCI && System.getenv("KEY_ALIAS") != null val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists() +val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists() val gitTags = providers @@ -291,6 +292,9 @@ dependencies { if (ffmpegModuleExists || isCI) { implementation(files("libs/lib-decoder-ffmpeg-release.aar")) } + if (av1ModuleExists || isCI) { + implementation(files("libs/lib-decoder-av1-release.aar")) + } testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index c15b7bc2..f7f7ba25 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -425,6 +425,18 @@ sealed interface AppPreference { summary = R.string.force_dovi_profile_7_summary, ) + val DecodeAv1 = + AppSwitchPreference( + title = R.string.software_decoding_av1, + defaultValue = true, + getter = { it.playbackPreferences.overrides.decodeAv1 }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { decodeAv1 = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val RememberSelectedTab = AppSwitchPreference( title = R.string.remember_selected_tab, @@ -1004,6 +1016,7 @@ val advancedPreferences = AppPreference.DirectPlayAss, AppPreference.DirectPlayPgs, AppPreference.DirectPlayDoviProfile7, + AppPreference.DecodeAv1, ), ), ConditionalPreferences( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 95cebb3c..976fb2c8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -43,6 +43,7 @@ class DeviceProfileService assDirectPlay = prefs.overrides.directPlayAss, pgsDirectPlay = prefs.overrides.directPlayPgs, dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, + decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), ) @@ -57,6 +58,7 @@ class DeviceProfileService assDirectPlay = newConfig.assDirectPlay, pgsDirectPlay = newConfig.pgsDirectPlay, dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, + decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = newConfig.jellyfinTenEleven, ) } @@ -75,5 +77,6 @@ data class DeviceProfileConfiguration( val assDirectPlay: Boolean, val pgsDirectPlay: Boolean, val dolbyVisionELDirectPlay: Boolean, + val decodeAv1: Boolean, val jellyfinTenEleven: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index c59ba81d..8ee8fa75 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -3,12 +3,19 @@ package com.github.damontecres.wholphin.services import android.content.Context +import android.os.Build +import android.os.Handler import androidx.annotation.OptIn import androidx.datastore.core.DataStore +import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.video.MediaCodecVideoRenderer +import androidx.media3.exoplayer.video.VideoRendererEventListener import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.MediaExtensionStatus @@ -18,6 +25,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking import timber.log.Timber +import java.lang.reflect.Constructor import javax.inject.Inject import javax.inject.Singleton @@ -61,8 +69,8 @@ class PlayerFactory PlayerBackend.EXO_PLAYER, PlayerBackend.UNRECOGNIZED, -> { - val extensions = - runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled + val extensions = prefs?.overrides?.mediaExtensionsEnabled + val decodeAv1 = prefs?.overrides?.decodeAv1 == true Timber.v("extensions=$extensions") val rendererMode = when (extensions) { @@ -74,7 +82,7 @@ class PlayerFactory ExoPlayer .Builder(context) .setRenderersFactory( - DefaultRenderersFactory(context) + WholphinRenderersFactory(context, decodeAv1) .setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode), ).build() @@ -96,3 +104,73 @@ val Player.isReleased: Boolean else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}") } } + +// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 +class WholphinRenderersFactory( + context: Context, + private val av1Enabled: Boolean, +) : DefaultRenderersFactory(context) { + override fun buildVideoRenderers( + context: Context, + extensionRendererMode: Int, + mediaCodecSelector: MediaCodecSelector, + enableDecoderFallback: Boolean, + eventHandler: Handler, + eventListener: VideoRendererEventListener, + allowedVideoJoiningTimeMs: Long, + out: ArrayList, + ) { + var videoRendererBuilder = + MediaCodecVideoRenderer + .Builder(context) + .setCodecAdapterFactory(codecAdapterFactory) + .setMediaCodecSelector(mediaCodecSelector) + .setAllowedJoiningTimeMs(allowedVideoJoiningTimeMs) + .setEnableDecoderFallback(enableDecoderFallback) + .setEventHandler(eventHandler) + .setEventListener(eventListener) + .setMaxDroppedFramesToNotify(MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY) + .experimentalSetParseAv1SampleDependencies(false) + .experimentalSetLateThresholdToDropDecoderInputUs(C.TIME_UNSET) + if (Build.VERSION.SDK_INT >= 34) { + videoRendererBuilder = + videoRendererBuilder.experimentalSetEnableMediaCodecBufferDecodeOnlyFlag( + false, + ) + } + out.add(videoRendererBuilder.build()) + + if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { + return + } + var extensionRendererIndex = out.size + if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) { + extensionRendererIndex-- + } + + if (av1Enabled) { + try { + val clazz = Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer") + val constructor: Constructor<*> = + clazz.getConstructor( + Long::class.javaPrimitiveType, + Handler::class.java, + VideoRendererEventListener::class.java, + Int::class.javaPrimitiveType, + ) + val renderer = + constructor.newInstance( + allowedVideoJoiningTimeMs, + eventHandler, + eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, + ) as Renderer + out.add(extensionRendererIndex++, renderer) + Timber.i("Loaded Libdav1dVideoRenderer.") + } catch (e: Exception) { + // The extension is present, but instantiation failed. + throw java.lang.IllegalStateException("Error instantiating AV1 extension", e) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 0f507fd3..b98ececd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -67,6 +67,7 @@ fun createDeviceProfile( assDirectPlay: Boolean, pgsDirectPlay: Boolean, dolbyVisionELDirectPlay: Boolean, + decodeAv1: Boolean, jellyfinTenEleven: Boolean, ) = buildDeviceProfile { val allowedAudioCodecs = @@ -338,6 +339,7 @@ fun createDeviceProfile( conditions { when { + decodeAv1 -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" !supportsAV1 -> ProfileConditionValue.VIDEO_PROFILE equals "none" !supportsAV1Main10 -> ProfileConditionValue.VIDEO_PROFILE notEquals "main 10" else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" @@ -382,13 +384,15 @@ fun createDeviceProfile( } // AV1 - codecProfile { - type = CodecType.VIDEO - codec = Codec.Video.AV1 + if (!decodeAv1) { + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.AV1 - conditions { - ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width - ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height + conditions { + ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height + } } } @@ -410,16 +414,18 @@ fun createDeviceProfile( buildSet { if (jellyfinTenEleven) add("DOVIInvalid") - if (!supportsAV1DolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") - } + if (!decodeAv1) { + if (!supportsAV1DolbyVision) { + add(VideoRangeType.DOVI.serialName) + if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") + } - if (!supportsAV1HDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) + if (!supportsAV1HDR10Plus) { + add(VideoRangeType.HDR10_PLUS.serialName) - if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + } } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f68ecae8..0710b51d 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -46,6 +46,7 @@ message PlaybackOverrides{ bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; bool direct_play_dolby_vision_e_l = 6; + bool decode_av1 = 7; } message PlaybackPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d0b5441..58fd02b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -460,6 +460,7 @@ Upcoming Movies Upcoming TV Shows Request in 4K + AV1 software decoding Disabled diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh index 59718b52..1741c703 100755 --- a/scripts/ffmpeg/build_ffmpeg_decoder.sh +++ b/scripts/ffmpeg/build_ffmpeg_decoder.sh @@ -15,6 +15,7 @@ PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")" ANDROID_ABI=21 ENABLED_DECODERS=(dca ac3 eac3 mlp truehd flac alac pcm_mulaw pcm_alaw mp3) FFMPEG_BRANCH="release/6.0" +DAV1D_BRANCH="1.5.3" # Path configs DIR_PATH="$(pwd)" @@ -22,6 +23,7 @@ TARGET_PATH="$PROJECT_ROOT/app/libs" MEDIA_PATH="$DIR_PATH/ffmpeg_decoder/media" FFMPEG_MODULE_PATH="$MEDIA_PATH/libraries/decoder_ffmpeg/src/main" FFMPEG_PATH="$DIR_PATH/ffmpeg_decoder/ffmpeg" +AV1_MODULE_PATH="$MEDIA_PATH/libraries/decoder_av1/src/main" HOST="$(uname -s | tr '[:upper:]' '[:lower:]')" HOST_PLATFORM="$HOST-x86_64" @@ -48,16 +50,39 @@ else git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg fi -ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg" +[[ ! -d "${FFMPEG_MODULE_PATH}/jni/ffmpeg" ]] && ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg" pushd "${FFMPEG_MODULE_PATH}/jni" || exit ./build_ffmpeg.sh "${FFMPEG_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ANDROID_ABI}" "${ENABLED_DECODERS[@]}" +# av1 module + +pushd "$AV1_MODULE_PATH/jni" || exit + +if [[ ! -d cpu_features ]]; then + git clone https://github.com/google/cpu_features --depth 1 --single-branch cpu_features +fi + +pushd "$AV1_MODULE_PATH/jni" || exit + +if [[ -d dav1d ]]; then + pushd dav1d || exit + git checkout --force "$DAV1D_BRANCH" +else + git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d +fi + +pushd "$AV1_MODULE_PATH/jni" || exit + +/usr/bin/env bash ./build_dav1d.sh "${AV1_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" + + pushd "$MEDIA_PATH" || exit -./gradlew :lib-decoder-ffmpeg:assemble +./gradlew :lib-decoder-ffmpeg:assemble :lib-decoder-av1:assemble popd || exit popd || exit cp "$MEDIA_PATH/libraries/decoder_ffmpeg/buildout/outputs/aar/lib-decoder-ffmpeg-release.aar" "$TARGET_PATH/" +cp "$MEDIA_PATH/libraries/decoder_av1/buildout/outputs/aar/lib-decoder-av1-release.aar" "$TARGET_PATH/" popd || exit