From 02a032d2871b6175ea5ab2a2a23f5c25efefe2bf Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:54:33 +0200 Subject: [PATCH] fix(android): reconnect Flutter texture after TV standby --- .../plezy/DeferredSurfaceTextureListener.kt | 46 ++++++++ .../kotlin/com/edde746/plezy/MainActivity.kt | 80 ++++++++------ .../DeferredSurfaceTextureListenerTest.kt | 102 ++++++++++++++++++ 3 files changed, 193 insertions(+), 35 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/DeferredSurfaceTextureListener.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/DeferredSurfaceTextureListenerTest.kt diff --git a/android/app/src/main/kotlin/com/edde746/plezy/DeferredSurfaceTextureListener.kt b/android/app/src/main/kotlin/com/edde746/plezy/DeferredSurfaceTextureListener.kt new file mode 100644 index 00000000..45af810b --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/DeferredSurfaceTextureListener.kt @@ -0,0 +1,46 @@ +package com.edde746.plezy + +import android.graphics.SurfaceTexture +import android.os.Handler +import android.os.Looper +import android.view.TextureView + +internal class DeferredSurfaceTextureListener( + private val delegate: TextureView.SurfaceTextureListener, + private val handler: Handler = Handler(Looper.getMainLooper()), + private val onSurfaceAvailable: () -> Unit = {}, + private val isCurrentSurface: (SurfaceTexture) -> Boolean +) : TextureView.SurfaceTextureListener { + + private var pendingResize: Runnable? = null + + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + delegate.onSurfaceTextureAvailable(surface, width, height) + onSurfaceAvailable() + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + pendingResize?.let(handler::removeCallbacks) + pendingResize = Runnable { + pendingResize = null + if (isCurrentSurface(surface)) { + delegate.onSurfaceTextureSizeChanged(surface, width, height) + } + } + handler.postDelayed(pendingResize!!, RESIZE_DELAY_MILLIS) + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) { + delegate.onSurfaceTextureUpdated(surface) + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + pendingResize?.let(handler::removeCallbacks) + pendingResize = null + return delegate.onSurfaceTextureDestroyed(surface) + } + + private companion object { + const val RESIZE_DELAY_MILLIS = 100L + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 53897c5d..4a0f5e8c 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -9,20 +9,16 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration -import android.graphics.SurfaceTexture import android.media.AudioManager import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.os.Process import android.provider.Settings import android.util.Log import android.util.Rational import android.view.InputDevice import android.view.KeyEvent -import android.view.TextureView import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowManager @@ -93,6 +89,9 @@ class MainActivity : FlutterActivity() { private var nativeTextInputFocused = false private var pendingExternalPlayerResult: MethodChannel.Result? = null private var originalWindowBrightness: Float? = null + private var flutterTextureView: FlutterTextureView? = null + private var flutterSurfaceReconnectPending = false + private var activityStarted = false private inline fun logTextInputDiag(message: () -> String) { if (TEXT_INPUT_DIAGNOSTICS_ENABLED) { @@ -383,6 +382,9 @@ class MainActivity : FlutterActivity() { override fun onDestroy() { pendingExternalPlayerResult?.error("ACTIVITY_DESTROYED", "Activity was destroyed while external player was active", null) pendingExternalPlayerResult = null + activityStarted = false + flutterSurfaceReconnectPending = false + flutterTextureView = null super.onDestroy() } @@ -448,41 +450,49 @@ class MainActivity : FlutterActivity() { } override fun onFlutterTextureViewCreated(flutterTextureView: FlutterTextureView) { + this.flutterTextureView = flutterTextureView val original = flutterTextureView.surfaceTextureListener ?: return - val handler = Handler(Looper.getMainLooper()) - var pendingResize: Runnable? = null - var lastWidth = 0 - var lastHeight = 0 + flutterTextureView.surfaceTextureListener = + DeferredSurfaceTextureListener( + delegate = original, + onSurfaceAvailable = ::tryReconnectFlutterSurface + ) { surface -> + flutterTextureView.isAvailable && flutterTextureView.surfaceTexture === surface + } + } - flutterTextureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener { - override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { - original.onSurfaceTextureAvailable(surface, width, height) + override fun onStop() { + activityStarted = false + if (isAndroidTvDevice()) flutterSurfaceReconnectPending = true + super.onStop() + } + + override fun onStart() { + super.onStart() + activityStarted = true + tryReconnectFlutterSurface() + } + + private fun tryReconnectFlutterSurface() { + if (!activityStarted || !flutterSurfaceReconnectPending) return + val textureView = flutterTextureView ?: return + textureView.post { + if (!activityStarted || + !flutterSurfaceReconnectPending || + textureView !== flutterTextureView || + !textureView.isAttachedToWindow || + !textureView.isAvailable + ) { + return@post } - override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { - if (width == lastWidth && height == lastHeight) return - lastWidth = width - lastHeight = height - pendingResize?.let { handler.removeCallbacks(it) } - pendingResize = Runnable { - if (flutterTextureView.isAvailable) { - original.onSurfaceTextureSizeChanged(surface, width, height) - } - } - handler.postDelayed(pendingResize!!, 100) - } - - override fun onSurfaceTextureUpdated(surface: SurfaceTexture) { - original.onSurfaceTextureUpdated(surface) - } - - override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { - pendingResize?.let { handler.removeCallbacks(it) } - pendingResize = null - lastWidth = 0 - lastHeight = 0 - return original.onSurfaceTextureDestroyed(surface) - } + // Some TV firmware retains an available TextureView while invalidating + // its compositor surface during standby. Force Flutter's supported + // surface-swap path so rendering resumes without restarting the engine. + Log.i(TAG, "Reconnecting Flutter texture surface after Android TV standby") + textureView.pause() + textureView.resume() + flutterSurfaceReconnectPending = false } } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/DeferredSurfaceTextureListenerTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/DeferredSurfaceTextureListenerTest.kt new file mode 100644 index 00000000..10f7206a --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/DeferredSurfaceTextureListenerTest.kt @@ -0,0 +1,102 @@ +package com.edde746.plezy + +import android.graphics.SurfaceTexture +import android.os.Looper +import android.view.TextureView +import java.time.Duration +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +class DeferredSurfaceTextureListenerTest { + + private lateinit var surface: SurfaceTexture + private lateinit var delegate: RecordingListener + private lateinit var listener: DeferredSurfaceTextureListener + private var currentSurface: SurfaceTexture? = null + + @Before + fun setUp() { + surface = SurfaceTexture(0) + currentSurface = surface + delegate = RecordingListener() + listener = DeferredSurfaceTextureListener(delegate) { candidate -> candidate === currentSurface } + } + + @After + fun tearDown() { + surface.release() + } + + @Test + fun forwardsRepeatedSameSizeCallbacks() { + listener.onSurfaceTextureSizeChanged(surface, 1920, 1080) + idlePastResizeDelay() + listener.onSurfaceTextureSizeChanged(surface, 1920, 1080) + idlePastResizeDelay() + + assertEquals(listOf(1920 to 1080, 1920 to 1080), delegate.sizes) + } + + @Test + fun coalescesPendingCallbacksToLatestSize() { + listener.onSurfaceTextureSizeChanged(surface, 1280, 720) + listener.onSurfaceTextureSizeChanged(surface, 1920, 1080) + idlePastResizeDelay() + + assertEquals(listOf(1920 to 1080), delegate.sizes) + } + + @Test + fun ignoresCallbackForObsoleteSurface() { + val replacement = SurfaceTexture(0) + try { + listener.onSurfaceTextureSizeChanged(surface, 1920, 1080) + currentSurface = replacement + idlePastResizeDelay() + + assertEquals(emptyList>(), delegate.sizes) + } finally { + replacement.release() + } + } + + @Test + fun destroyingSurfaceCancelsPendingCallback() { + listener.onSurfaceTextureSizeChanged(surface, 1920, 1080) + listener.onSurfaceTextureDestroyed(surface) + idlePastResizeDelay() + + assertEquals(emptyList>(), delegate.sizes) + assertEquals(1, delegate.destroyedCount) + } + + private fun idlePastResizeDelay() { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(101)) + } + + private class RecordingListener : TextureView.SurfaceTextureListener { + val sizes = mutableListOf>() + var destroyedCount = 0 + + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) = Unit + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + sizes += width to height + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + destroyedCount++ + return true + } + } +}