diff --git a/.github/workflows/build-android-apks.yml b/.github/workflows/build-android-apks.yml new file mode 100644 index 000000000..29540bb58 --- /dev/null +++ b/.github/workflows/build-android-apks.yml @@ -0,0 +1,108 @@ +name: Build Android APKs + +on: + push: + branches: + - develop + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + build-android-apks: + name: Build Android APKs + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.1 + + - name: Fetch Flutter version + run: | + FLUTTER_VERSION=$(jq -r '.flutter' .fvmrc) + echo "FLUTTER_VERSION=${FLUTTER_VERSION}" >> "$GITHUB_ENV" + shell: bash + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: "17" + cache: gradle + check-latest: true + + - name: Prepare release signing + env: + ENCODED_STRING: ${{ secrets.KEYSTORE_BASE_64 }} + RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + RELEASE_KEYSTORE_ALIAS: ${{ secrets.RELEASE_KEYSTORE_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + run: | + if [[ -z "$ENCODED_STRING" || -z "$RELEASE_KEYSTORE_PASSWORD" || -z "$RELEASE_KEYSTORE_ALIAS" || -z "$RELEASE_KEY_PASSWORD" ]]; then + echo "Missing one or more signing secrets." + echo "Required secrets: KEYSTORE_BASE_64, RELEASE_KEYSTORE_PASSWORD, RELEASE_KEYSTORE_ALIAS, RELEASE_KEY_PASSWORD" + exit 1 + fi + + echo "$ENCODED_STRING" | base64 -d > android/app/keystore.jks + echo "SIGNING_SOURCE=repository signing secrets" >> "$GITHUB_ENV" + + cat > android/app/key.properties <, val themeColor: Long? = null, val skipForward: Long, @@ -164,21 +165,23 @@ data class PlayerSettings ( companion object { fun fromList(pigeonVar_list: List): PlayerSettings { val enableTunneling = pigeonVar_list[0] as Boolean - val skipTypes = pigeonVar_list[1] as Map - 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 - 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 + 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 + 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 { return listOf( enableTunneling, + ignoreHdr10Plus, skipTypes, themeColor, skipForward, diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt index 44bb2a8d5..22f85355a 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt @@ -90,7 +90,11 @@ internal fun ExoPlayer( .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .build() - val renderersFactory = DefaultRenderersFactory(context) + val renderersFactory = (if (PlayerSettingsObject.settings.value?.ignoreHdr10Plus == true) { + StripHDR10PlusRenderersFactory(context) + } else { + DefaultRenderersFactory(context) + }) .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) .setEnableDecoderFallback(true) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/StripHDR10PlusBitstreamSanitizer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/StripHDR10PlusBitstreamSanitizer.kt new file mode 100644 index 000000000..66c650881 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/StripHDR10PlusBitstreamSanitizer.kt @@ -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) + } +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/StripHDR10PlusRenderersFactory.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/StripHDR10PlusRenderersFactory.kt new file mode 100644 index 000000000..4445a124c --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/StripHDR10PlusRenderersFactory.kt @@ -0,0 +1,107 @@ +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 + ) { + super.buildVideoRenderers( + context, + extensionRendererMode, + mediaCodecSelector, + enableDecoderFallback, + eventHandler, + eventListener, + allowedVideoJoiningTimeMs, + out + ) + + val rendererIndex = out.indexOfFirst { it.javaClass == MediaCodecVideoRenderer::class.java } + if (rendererIndex < 0) 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") +} diff --git a/android/app/src/test/kotlin/nl/jknaapen/fladder/player/StripHDR10PlusBitstreamSanitizerTest.kt b/android/app/src/test/kotlin/nl/jknaapen/fladder/player/StripHDR10PlusBitstreamSanitizerTest.kt new file mode 100644 index 000000000..363472727 --- /dev/null +++ b/android/app/src/test/kotlin/nl/jknaapen/fladder/player/StripHDR10PlusBitstreamSanitizerTest.kt @@ -0,0 +1,131 @@ +package nl.jknaapen.fladder.player + +import java.nio.ByteBuffer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StripHDR10PlusBitstreamSanitizerTest { + @Test + fun stripsHdr10PlusPrefixSeiBetweenVclNals() { + val vcl1 = annexBNal(1, byteArrayOf(0x01, 0x02)) + val vcl2 = annexBNal(1, byteArrayOf(0x03, 0x04)) + val buffer = bufferOf(vcl1, hdr10PlusSei(), vcl2) + val originalLimit = buffer.limit() + + StripHDR10PlusBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer)) + assertTrue(buffer.limit() < originalLimit) + assertEquals(0, buffer.position()) + } + + @Test + fun stripsSuffixSei() { + val vcl = annexBNal(1, byteArrayOf(0x01)) + val suffixSei = annexBNal(40, hdr10PlusSeiPayload()) + val buffer = bufferOf(vcl, suffixSei) + + StripHDR10PlusBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(vcl, remainingBytes(buffer)) + } + + @Test + fun handles3ByteStartCodes() { + val vcl1 = annexBNal(1, byteArrayOf(0x01, 0x02), startCodeLen = 3) + val sei = annexBNal(39, hdr10PlusSeiPayload(), startCodeLen = 3) + val vcl2 = annexBNal(1, byteArrayOf(0x03), startCodeLen = 3) + val buffer = bufferOf(vcl1, sei, vcl2) + + StripHDR10PlusBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer)) + } + + @Test + fun preservesNonHdr10PlusT35Sei() { + val sei = annexBNal( + 39, + byteArrayOf(0x04, 0x07, 0x00, 0x00, 0x3C, 0x00, 0x01, 0x04, 0x00) + ) + val buffer = bufferOf(annexBNal(1, byteArrayOf(0x01)), sei, annexBNal(1, byteArrayOf(0x02))) + val original = remainingBytes(buffer) + + StripHDR10PlusBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(original, remainingBytes(buffer)) + } + + @Test + fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() { + val vcl = annexBNal(1, byteArrayOf(0x01)) + val rpu = annexBNal(62, byteArrayOf(0x19, 0x08)) + val el = annexBNal(63, byteArrayOf(0x42)) + val sei = hdr10PlusSei() + val buffer = bufferOf(vcl, rpu, sei, el) + + StripHDR10PlusBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = false, stripDvRpu = true) + + assertArrayEquals(concat(vcl, sei), remainingBytes(buffer)) + } + + @Test + fun respectsPositionAndRestoresIt() { + val prefix = byteArrayOf(0xAA.toByte(), 0xBB.toByte()) + val vcl = annexBNal(1, byteArrayOf(0x01)) + val content = concat(prefix, vcl, hdr10PlusSei()) + val buffer = ByteBuffer.wrap(content.copyOf()) + buffer.position(prefix.size) + + StripHDR10PlusBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertEquals(prefix.size, buffer.position()) + assertArrayEquals(vcl, remainingBytes(buffer)) + assertEquals(0xAA.toByte(), buffer.get(0)) + assertEquals(0xBB.toByte(), buffer.get(1)) + } + + @Test + fun worksOnDirectBuffers() { + val vcl = annexBNal(1, byteArrayOf(0x01, 0x02)) + val content = concat(vcl, hdr10PlusSei()) + val buffer = ByteBuffer.allocateDirect(content.size) + buffer.put(content) + buffer.flip() + + StripHDR10PlusBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(vcl, remainingBytes(buffer)) + } + + private fun annexBNal(nalUnitType: Int, payload: ByteArray, startCodeLen: Int = 4): ByteArray { + val startCode = if (startCodeLen == 3) byteArrayOf(0, 0, 1) else byteArrayOf(0, 0, 0, 1) + val header = byteArrayOf(((nalUnitType shl 1) and 0x7E).toByte(), 0x01) + return concat(startCode, header, payload) + } + + private fun hdr10PlusSeiPayload(): ByteArray = + byteArrayOf(0x04, 0x07, 0xB5.toByte(), 0x00, 0x3C, 0x00, 0x01, 0x04, 0x00, 0x80.toByte()) + + private fun hdr10PlusSei(): ByteArray = annexBNal(39, hdr10PlusSeiPayload()) + + private fun concat(vararg parts: ByteArray): ByteArray { + val result = ByteArray(parts.sumOf { it.size }) + var offset = 0 + for (part in parts) { + part.copyInto(result, offset) + offset += part.size + } + return result + } + + private fun bufferOf(vararg parts: ByteArray): ByteBuffer = ByteBuffer.wrap(concat(*parts)) + + private fun remainingBytes(buffer: ByteBuffer): ByteArray { + val copy = ByteArray(buffer.remaining()) + buffer.duplicate().get(copy) + return copy + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 65fc489e4..84acaa717 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1487,6 +1487,8 @@ "qualityOptionsAuto": "Auto", "advancedVideoOptionsTitle": "Advanced video options", "advancedVideoOptionsDesc": "Enable HDR, tone mapping, and advanced color handling (unstable for some devices)", + "ignoreHdr10PlusTitle": "Ignore HDR10+", + "ignoreHdr10PlusDesc": "Strips HDR10+ metadata from Dolby Vision streams to fix broken playback on some devices (Fire TVs)", "version": "Version", "mediaSegmentActions": "Media segment actions", "segmentActionNone": "None", @@ -2768,4 +2770,4 @@ "loud": "Loud", "share": "Share", "setAs": "Set as" -} \ No newline at end of file +} diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index 4afc33f09..5d093ae37 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -102,6 +102,7 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { @Default(2.0) double speedBoostRate, @Default(true) bool enableDoubleTapSeek, @Default(false) bool enableAdvancedVideoOptions, + @Default(false) bool ignoreHdr10Plus, @Default(true) bool enableEdgeGestures, @Default(false) bool reverseEdgeGestures, @Default(true) bool enablePictureInPicture, @@ -129,6 +130,7 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { bool playerSame(VideoPlayerSettingsModel other) { return other.hardwareAccel == hardwareAccel && other.enableTunneling == enableTunneling && + other.ignoreHdr10Plus == ignoreHdr10Plus && other.useLibass == useLibass && other.bufferSize == bufferSize && other.wantedPlayer == wantedPlayer; @@ -145,6 +147,7 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { other.hardwareAccel == hardwareAccel && other.useLibass == useLibass && other.enableTunneling == enableTunneling && + other.ignoreHdr10Plus == ignoreHdr10Plus && other.bufferSize == bufferSize && other.internalVolume == internalVolume && other.playerOptions == playerOptions && @@ -159,6 +162,7 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { hardwareAccel.hashCode ^ useLibass.hashCode ^ enableTunneling.hashCode ^ + ignoreHdr10Plus.hashCode ^ bufferSize.hashCode ^ internalVolume.hashCode ^ audioDevice.hashCode; diff --git a/lib/models/settings/video_player_settings.freezed.dart b/lib/models/settings/video_player_settings.freezed.dart index 0046a082f..972ce3536 100644 --- a/lib/models/settings/video_player_settings.freezed.dart +++ b/lib/models/settings/video_player_settings.freezed.dart @@ -35,6 +35,7 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { double get speedBoostRate; bool get enableDoubleTapSeek; bool get enableAdvancedVideoOptions; + bool get ignoreHdr10Plus; bool get enableEdgeGestures; bool get reverseEdgeGestures; bool get enablePictureInPicture; @@ -81,6 +82,7 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { ..add(DiagnosticsProperty('enableDoubleTapSeek', enableDoubleTapSeek)) ..add(DiagnosticsProperty( 'enableAdvancedVideoOptions', enableAdvancedVideoOptions)) + ..add(DiagnosticsProperty('ignoreHdr10Plus', ignoreHdr10Plus)) ..add(DiagnosticsProperty('enableEdgeGestures', enableEdgeGestures)) ..add(DiagnosticsProperty('reverseEdgeGestures', reverseEdgeGestures)) ..add( @@ -94,7 +96,7 @@ mixin _$VideoPlayerSettingsModel implements DiagnosticableTreeMixin { @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, ignoreHdr10Plus: $ignoreHdr10Plus, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs)'; } } @@ -126,6 +128,7 @@ abstract mixin class $VideoPlayerSettingsModelCopyWith<$Res> { double speedBoostRate, bool enableDoubleTapSeek, bool enableAdvancedVideoOptions, + bool ignoreHdr10Plus, bool enableEdgeGestures, bool reverseEdgeGestures, bool enablePictureInPicture, @@ -170,6 +173,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> Object? speedBoostRate = null, Object? enableDoubleTapSeek = null, Object? enableAdvancedVideoOptions = null, + Object? ignoreHdr10Plus = null, Object? enableEdgeGestures = null, Object? reverseEdgeGestures = null, Object? enablePictureInPicture = null, @@ -264,6 +268,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res> ? _self.enableAdvancedVideoOptions : enableAdvancedVideoOptions // ignore: cast_nullable_to_non_nullable as bool, + ignoreHdr10Plus: null == ignoreHdr10Plus + ? _self.ignoreHdr10Plus + : ignoreHdr10Plus // ignore: cast_nullable_to_non_nullable + as bool, enableEdgeGestures: null == enableEdgeGestures ? _self.enableEdgeGestures : enableEdgeGestures // ignore: cast_nullable_to_non_nullable @@ -415,6 +423,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { double speedBoostRate, bool enableDoubleTapSeek, bool enableAdvancedVideoOptions, + bool ignoreHdr10Plus, bool enableEdgeGestures, bool reverseEdgeGestures, bool enablePictureInPicture, @@ -451,6 +460,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.speedBoostRate, _that.enableDoubleTapSeek, _that.enableAdvancedVideoOptions, + _that.ignoreHdr10Plus, _that.enableEdgeGestures, _that.reverseEdgeGestures, _that.enablePictureInPicture, @@ -501,6 +511,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { double speedBoostRate, bool enableDoubleTapSeek, bool enableAdvancedVideoOptions, + bool ignoreHdr10Plus, bool enableEdgeGestures, bool reverseEdgeGestures, bool enablePictureInPicture, @@ -536,6 +547,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.speedBoostRate, _that.enableDoubleTapSeek, _that.enableAdvancedVideoOptions, + _that.ignoreHdr10Plus, _that.enableEdgeGestures, _that.reverseEdgeGestures, _that.enablePictureInPicture, @@ -585,6 +597,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { double speedBoostRate, bool enableDoubleTapSeek, bool enableAdvancedVideoOptions, + bool ignoreHdr10Plus, bool enableEdgeGestures, bool reverseEdgeGestures, bool enablePictureInPicture, @@ -620,6 +633,7 @@ extension VideoPlayerSettingsModelPatterns on VideoPlayerSettingsModel { _that.speedBoostRate, _that.enableDoubleTapSeek, _that.enableAdvancedVideoOptions, + _that.ignoreHdr10Plus, _that.enableEdgeGestures, _that.reverseEdgeGestures, _that.enablePictureInPicture, @@ -661,6 +675,7 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel this.speedBoostRate = 2.0, this.enableDoubleTapSeek = true, this.enableAdvancedVideoOptions = false, + this.ignoreHdr10Plus = false, this.enableEdgeGestures = true, this.reverseEdgeGestures = false, this.enablePictureInPicture = true, @@ -759,6 +774,9 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel final bool enableAdvancedVideoOptions; @override @JsonKey() + final bool ignoreHdr10Plus; + @override + @JsonKey() final bool enableEdgeGestures; @override @JsonKey() @@ -824,6 +842,7 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel ..add(DiagnosticsProperty('enableDoubleTapSeek', enableDoubleTapSeek)) ..add(DiagnosticsProperty( 'enableAdvancedVideoOptions', enableAdvancedVideoOptions)) + ..add(DiagnosticsProperty('ignoreHdr10Plus', ignoreHdr10Plus)) ..add(DiagnosticsProperty('enableEdgeGestures', enableEdgeGestures)) ..add(DiagnosticsProperty('reverseEdgeGestures', reverseEdgeGestures)) ..add( @@ -837,7 +856,7 @@ class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, enableTunneling: $enableTunneling, bufferSize: $bufferSize, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings, hotKeys: $hotKeys, screensaver: $screensaver, enableSpeedBoost: $enableSpeedBoost, speedBoostRate: $speedBoostRate, enableDoubleTapSeek: $enableDoubleTapSeek, enableAdvancedVideoOptions: $enableAdvancedVideoOptions, ignoreHdr10Plus: $ignoreHdr10Plus, enableEdgeGestures: $enableEdgeGestures, reverseEdgeGestures: $reverseEdgeGestures, enablePictureInPicture: $enablePictureInPicture, enableReplayGain: $enableReplayGain, replayGainVolumeLevel: $replayGainVolumeLevel, enablePlayPauseFade: $enablePlayPauseFade, enableCrossfade: $enableCrossfade, crossfadeDurationMs: $crossfadeDurationMs)'; } } @@ -871,6 +890,7 @@ abstract mixin class _$VideoPlayerSettingsModelCopyWith<$Res> double speedBoostRate, bool enableDoubleTapSeek, bool enableAdvancedVideoOptions, + bool ignoreHdr10Plus, bool enableEdgeGestures, bool reverseEdgeGestures, bool enablePictureInPicture, @@ -915,6 +935,7 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> Object? speedBoostRate = null, Object? enableDoubleTapSeek = null, Object? enableAdvancedVideoOptions = null, + Object? ignoreHdr10Plus = null, Object? enableEdgeGestures = null, Object? reverseEdgeGestures = null, Object? enablePictureInPicture = null, @@ -1009,6 +1030,10 @@ class __$VideoPlayerSettingsModelCopyWithImpl<$Res> ? _self.enableAdvancedVideoOptions : enableAdvancedVideoOptions // ignore: cast_nullable_to_non_nullable as bool, + ignoreHdr10Plus: null == ignoreHdr10Plus + ? _self.ignoreHdr10Plus + : ignoreHdr10Plus // ignore: cast_nullable_to_non_nullable + as bool, enableEdgeGestures: null == enableEdgeGestures ? _self.enableEdgeGestures : enableEdgeGestures // ignore: cast_nullable_to_non_nullable diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index c6e599658..73276f4c3 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -52,6 +52,7 @@ _VideoPlayerSettingsModel _$VideoPlayerSettingsModelFromJson( enableDoubleTapSeek: json['enableDoubleTapSeek'] as bool? ?? true, enableAdvancedVideoOptions: json['enableAdvancedVideoOptions'] as bool? ?? false, + ignoreHdr10Plus: json['ignoreHdr10Plus'] as bool? ?? false, enableEdgeGestures: json['enableEdgeGestures'] as bool? ?? true, reverseEdgeGestures: json['reverseEdgeGestures'] as bool? ?? false, enablePictureInPicture: json['enablePictureInPicture'] as bool? ?? true, @@ -93,6 +94,7 @@ Map _$VideoPlayerSettingsModelToJson( 'speedBoostRate': instance.speedBoostRate, 'enableDoubleTapSeek': instance.enableDoubleTapSeek, 'enableAdvancedVideoOptions': instance.enableAdvancedVideoOptions, + 'ignoreHdr10Plus': instance.ignoreHdr10Plus, 'enableEdgeGestures': instance.enableEdgeGestures, 'reverseEdgeGestures': instance.reverseEdgeGestures, 'enablePictureInPicture': instance.enablePictureInPicture, @@ -189,6 +191,7 @@ const _$VideoHotKeysEnumMap = { VideoHotKeys.skipMediaSegment: 'skipMediaSegment', VideoHotKeys.takeScreenshot: 'takeScreenshot', VideoHotKeys.takeScreenshotClean: 'takeScreenshotClean', + VideoHotKeys.toggleSubtitles: 'toggleSubtitles', VideoHotKeys.exit: 'exit', }; diff --git a/lib/providers/settings/pigeon_player_settings_provider.dart b/lib/providers/settings/pigeon_player_settings_provider.dart index 0a896acd0..7ce444e87 100644 --- a/lib/providers/settings/pigeon_player_settings_provider.dart +++ b/lib/providers/settings/pigeon_player_settings_provider.dart @@ -28,6 +28,7 @@ final pigeonPlayerSettingsSyncProvider = Provider((ref) { pigeon.PlayerSettingsPigeon().sendPlayerSettings( pigeon.PlayerSettings( enableTunneling: value.enableTunneling, + ignoreHdr10Plus: value.ignoreHdr10Plus, screensaver: switch (value.screensaver) { Screensaver.disabled => pigeon.Screensaver.disabled, Screensaver.dvd => pigeon.Screensaver.dvd, diff --git a/lib/providers/settings/video_player_settings_provider.dart b/lib/providers/settings/video_player_settings_provider.dart index ce883c021..8abac576e 100644 --- a/lib/providers/settings/video_player_settings_provider.dart +++ b/lib/providers/settings/video_player_settings_provider.dart @@ -89,6 +89,7 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier state = state.copyWith(hardwareAccel: value ?? true); void setUseLibass(bool? value) => state = state.copyWith(useLibass: value ?? false); void setMediaTunneling(bool? value) => state = state.copyWith(enableTunneling: value ?? false); + void setIgnoreHdr10Plus(bool value) => state = state.copyWith(ignoreHdr10Plus: value); void setBufferSize(int? value) => state = state.copyWith(bufferSize: value ?? 32); void setFitType(BoxFit? value) => state = state.copyWith(videoFit: value ?? BoxFit.contain); void setScreensaver(Screensaver? value) => state = state.copyWith(screensaver: value ?? Screensaver.black); diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index a710a9fbb..4a5ac6bdb 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -401,6 +401,16 @@ class _PlayerSettingsPageState extends ConsumerState { onChanged: (value) => provider.setMediaTunneling(value), ), ), + if (currentPlayer == PlayerOptions.nativePlayer) + SettingsListTile( + label: Text(context.localized.ignoreHdr10PlusTitle), + subLabel: Text(context.localized.ignoreHdr10PlusDesc), + onTap: () => provider.setIgnoreHdr10Plus(!videoSettings.ignoreHdr10Plus), + trailing: Switch( + value: videoSettings.ignoreHdr10Plus, + onChanged: (value) => provider.setIgnoreHdr10Plus(value), + ), + ), if (ref.read(argumentsStateProvider).leanBackMode) SettingsListTileEnum( label: Text(context.localized.playerSettingsScreensaverTitle), diff --git a/lib/src/player_settings_helper.g.dart b/lib/src/player_settings_helper.g.dart index 2b44241d0..7d71cc87f 100644 --- a/lib/src/player_settings_helper.g.dart +++ b/lib/src/player_settings_helper.g.dart @@ -1,6 +1,8 @@ // Autogenerated from Pigeon (v26.1.0), do not edit directly. // See also: https://pub.dev/packages/pigeon -// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import +// ignore_for_file: unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types +// ignore_for_file: unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers import 'dart:async'; import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; @@ -14,21 +16,19 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } + bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { - return a.length == b.length && - a.indexed - .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && a.entries.every((MapEntry entry) => - (b as Map).containsKey(entry.key) && - _deepEquals(entry.value, b[entry.key])); + return a.length == b.length && + a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key])); } return a == b; } - enum Screensaver { disabled, dvd, @@ -78,6 +78,7 @@ enum SegmentSkip { class PlayerSettings { PlayerSettings({ required this.enableTunneling, + required this.ignoreHdr10Plus, required this.skipTypes, this.themeColor, required this.skipForward, @@ -91,6 +92,8 @@ class PlayerSettings { bool enableTunneling; + bool ignoreHdr10Plus; + Map skipTypes; int? themeColor; @@ -112,6 +115,7 @@ class PlayerSettings { List _toList() { return [ enableTunneling, + ignoreHdr10Plus, skipTypes, themeColor, skipForward, @@ -125,21 +129,23 @@ class PlayerSettings { } Object encode() { - return _toList(); } + return _toList(); + } static PlayerSettings decode(Object result) { result as List; return PlayerSettings( enableTunneling: result[0]! as bool, - skipTypes: (result[1] as Map?)!.cast(), - themeColor: result[2] as int?, - skipForward: result[3]! as int, - skipBackward: result[4]! as int, - autoNextType: result[5]! as AutoNextType, - acceptedOrientations: (result[6] as List?)!.cast(), - fillScreen: result[7]! as bool, - videoFit: result[8]! as VideoPlayerFit, - screensaver: result[9]! as Screensaver, + ignoreHdr10Plus: result[1]! as bool, + skipTypes: (result[2] as Map?)!.cast(), + themeColor: result[3] as int?, + skipForward: result[4]! as int, + skipBackward: result[5]! as int, + autoNextType: result[6]! as AutoNextType, + acceptedOrientations: (result[7] as List?)!.cast(), + fillScreen: result[8]! as bool, + videoFit: result[9]! as VideoPlayerFit, + screensaver: result[10]! as Screensaver, ); } @@ -157,11 +163,9 @@ class PlayerSettings { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } - class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -169,25 +173,25 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is Screensaver) { + } else if (value is Screensaver) { buffer.putUint8(129); writeValue(buffer, value.index); - } else if (value is VideoPlayerFit) { + } else if (value is VideoPlayerFit) { buffer.putUint8(130); writeValue(buffer, value.index); - } else if (value is PlayerOrientations) { + } else if (value is PlayerOrientations) { buffer.putUint8(131); writeValue(buffer, value.index); - } else if (value is AutoNextType) { + } else if (value is AutoNextType) { buffer.putUint8(132); writeValue(buffer, value.index); - } else if (value is SegmentType) { + } else if (value is SegmentType) { buffer.putUint8(133); writeValue(buffer, value.index); - } else if (value is SegmentSkip) { + } else if (value is SegmentSkip) { buffer.putUint8(134); writeValue(buffer, value.index); - } else if (value is PlayerSettings) { + } else if (value is PlayerSettings) { buffer.putUint8(135); writeValue(buffer, value.encode()); } else { @@ -198,25 +202,25 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: final int? value = readValue(buffer) as int?; return value == null ? null : Screensaver.values[value]; - case 130: + case 130: final int? value = readValue(buffer) as int?; return value == null ? null : VideoPlayerFit.values[value]; - case 131: + case 131: final int? value = readValue(buffer) as int?; return value == null ? null : PlayerOrientations.values[value]; - case 132: + case 132: final int? value = readValue(buffer) as int?; return value == null ? null : AutoNextType.values[value]; - case 133: + case 133: final int? value = readValue(buffer) as int?; return value == null ? null : SegmentType.values[value]; - case 134: + case 134: final int? value = readValue(buffer) as int?; return value == null ? null : SegmentSkip.values[value]; - case 135: + case 135: return PlayerSettings.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -238,15 +242,15 @@ class PlayerSettingsPigeon { final String pigeonVar_messageChannelSuffix; Future sendPlayerSettings(PlayerSettings playerSettings) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.PlayerSettingsPigeon.sendPlayerSettings$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.nl_jknaapen_fladder.settings.' + 'PlayerSettingsPigeon.sendPlayerSettings$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([playerSettings]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/pigeons/player_settings_pigeon.dart b/pigeons/player_settings_pigeon.dart index 1953a0d6c..ce9afef01 100644 --- a/pigeons/player_settings_pigeon.dart +++ b/pigeons/player_settings_pigeon.dart @@ -13,6 +13,7 @@ import 'package:pigeon/pigeon.dart'; ) class PlayerSettings { final bool enableTunneling; + final bool ignoreHdr10Plus; final Map skipTypes; //Color in ARGB32 format final int? themeColor; @@ -26,6 +27,7 @@ class PlayerSettings { const PlayerSettings({ required this.enableTunneling, + required this.ignoreHdr10Plus, required this.skipTypes, required this.themeColor, required this.skipForward,