From dd09e421bfd97d5f4307bdfd863acebab9361859 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:25:56 +0200 Subject: [PATCH] fix(android): force codec re-init on TV surface changes to recover video after screensaver close #1481 --- .../kotlin/com/edde746/plezy/TvDetection.kt | 24 +++++++++++++++ .../plezy/exoplayer/PlezyRenderersFactory.kt | 30 +++++++++++++++++++ lib/screens/video_player/parts/lifecycle.dart | 15 ++++++++++ 3 files changed, 69 insertions(+) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/TvDetection.kt diff --git a/android/app/src/main/kotlin/com/edde746/plezy/TvDetection.kt b/android/app/src/main/kotlin/com/edde746/plezy/TvDetection.kt new file mode 100644 index 00000000..d1305947 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/TvDetection.kt @@ -0,0 +1,24 @@ +package com.edde746.plezy + +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration + +/** + * Native mirror of MainActivity.getAndroidTvDetection(): any TV signal counts. + * Kept in sync with the Dart-facing detection so native gating matches + * PlatformDetector.isTV(). + */ +object TvDetection { + fun isTv(context: Context): Boolean { + val pm = context.packageManager + val uiModeType = context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK + + @Suppress("DEPRECATION") + return uiModeType == Configuration.UI_MODE_TYPE_TELEVISION || + pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) || + pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) || + pm.hasSystemFeature("amazon.hardware.fire_tv") || + !pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt index 14b24917..1db8914e 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -25,6 +25,7 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener +import com.edde746.plezy.TvDetection import java.nio.ByteBuffer import java.util.concurrent.atomic.AtomicLong import kotlin.math.abs @@ -32,6 +33,13 @@ import kotlin.math.abs @OptIn(UnstableApi::class) class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) { + /** + * Android TV: MediaCodec.setOutputSurface is broken on many TV SoCs (Sony Bravia + * MediaTek etc., ExoPlayer #5119/#8329) — force full codec re-init on surface + * changes instead (#1481). TV-only: phones keep the fast path for PiP churn. + */ + private val forceSetOutputSurfaceWorkaround: Boolean = TvDetection.isTv(context) + /** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */ val audioDelayUs = AtomicLong(0L) @@ -79,6 +87,7 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) .setEventHandler(eventHandler) .setEventListener(eventListener) .setMaxDroppedFramesToNotify(MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY), + forceSetOutputSurfaceWorkaround, videoDiagnosticsLogger ) } @@ -341,6 +350,7 @@ internal class SubtitleDelayRenderer( @OptIn(UnstableApi::class) internal class DvSanitizingVideoRenderer( builder: Builder, + private val forceSetOutputSurfaceWorkaround: Boolean, private val log: ((String, String, String) -> Unit)? ) : MediaCodecVideoRenderer(builder) { @@ -348,6 +358,26 @@ internal class DvSanitizingVideoRenderer( private var stripHdr10PlusSei = false private var stripDvRpu = false + private var loggedSurfaceWorkaround = false + + // On TV SoCs MediaCodec.setOutputSurface() silently yields a black picture after + // the screensaver/background destroys and recreates the surface (#1481). Returning + // true makes media3 release + re-init the codec instead. media3's built-in device + // list doesn't cover 2019+ Bravias; consulted once per codec init. + override fun codecNeedsSetOutputSurfaceWorkaround(name: String): Boolean { + if (forceSetOutputSurfaceWorkaround) { + if (!loggedSurfaceWorkaround) { + loggedSurfaceWorkaround = true + log?.invoke( + "info", + "video", + "TV setOutputSurface workaround active: codec will fully re-init on surface changes (codec=$name)" + ) + } + return true + } + return super.codecNeedsSetOutputSurfaceWorkaround(name) + } // Sanitize diagnostics — playback-thread only, cumulative for the renderer's lifetime. private var sanitizedSampleCount = 0L diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index b039f366..ff2dd2c5 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -143,6 +143,21 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { _recordLifecycleState('resumed', action: 'render_restored'); } + // TV never hides the render layer on background (_handleAppHidden returns + // early without setting _hiddenForBackground), but the screensaver can + // still destroy the surface. Kick the video output so a missed surface + // callback can't leave the picture black: mpv re-attaches via + // refreshVideoOutput, ExoPlayer just reapplies sizing/z-order. + if (!_hiddenForBackground && + Platform.isAndroid && + PlatformDetector.isTV() && + currentPlayer != null && + _isPlayerInitialized) { + await currentPlayer.updateFrame(); + if (!mounted || currentPlayer != player) return; + _recordLifecycleState('resumed', action: 'tv_video_output_kick'); + } + // Restore media controls and wakelock when app is resumed. if (_isPlayerInitialized && mounted) { _resumeMediaControlsAfterTvBackground('app_resumed');