diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index c98410da..f98f0686 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -111,8 +111,16 @@ class _BrowserViewState extends ConsumerState static const _pointerThrottleInterval = Duration(milliseconds: 32); DateTime _lastPointerEvent = DateTime(0); Offset _accumulatedDelta = Offset.zero; + bool _screenshotCaptureInFlight = false; Future _timerTick(Timer timer) async { + // Timer.periodic does not await async callbacks. A slow Gecko capture used + // to overlap the next tick, multiplying GPU readbacks, bitmap encoders and + // thumbnail events exactly while the device was already under load. + if (_screenshotCaptureInFlight) { + return; + } + // Skip the (expensive) Gecko render-to-bitmap while a full-cover route // (settings, tab tray, search, …) occludes the browser. The screenshot // would force an off-screen render the user can't see and competes for the @@ -133,15 +141,17 @@ class _BrowserViewState extends ConsumerState return; } - await ref - .read(selectedTabSessionProvider) - .requestScreenshot(requireImageResult: false) - .onError((error, stackTrace) { - logger.e(error, stackTrace: stackTrace); - timer.cancel(); - - return null; - }); + _screenshotCaptureInFlight = true; + try { + await ref + .read(selectedTabSessionProvider) + .requestScreenshot(requireImageResult: false); + } catch (error, stackTrace) { + logger.e(error, stackTrace: stackTrace); + timer.cancel(); + } finally { + _screenshotCaptureInFlight = false; + } } @override diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Bitmap.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Bitmap.kt index 45dd4a0c..f2d2069c 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Bitmap.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/Bitmap.kt @@ -9,32 +9,42 @@ package eu.weblibre.flutter_mozilla_components.ext import android.graphics.Bitmap import android.os.Build import java.io.ByteArrayOutputStream +import kotlin.math.roundToInt fun Bitmap.resize(maxWidth: Int, maxHeight: Int): Bitmap { - var width = this.width - var height = this.height - - val aspectRatio: Float = width.toFloat() / height.toFloat() - - if (width > height) { - width = maxWidth - height = (width / aspectRatio).toInt() - } else { - height = maxHeight - width = (height * aspectRatio).toInt() + require(maxWidth > 0 && maxHeight > 0) { + "Bitmap bounds must be positive" } - return Bitmap.createScaledBitmap(this, width, height, true) + if (width <= maxWidth && height <= maxHeight) { + return this + } + + val scale = minOf( + maxWidth.toFloat() / width.toFloat(), + maxHeight.toFloat() / height.toFloat(), + ) + val targetWidth = (width * scale).roundToInt().coerceAtLeast(1) + val targetHeight = (height * scale).roundToInt().coerceAtLeast(1) + + return Bitmap.createScaledBitmap(this, targetWidth, targetHeight, true) } -fun Bitmap.toWebPBytes(): ByteArray { +fun Bitmap.toWebPBytes( + lossless: Boolean = true, + quality: Int = 100, +): ByteArray { val stream = ByteArrayOutputStream() val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Bitmap.CompressFormat.WEBP_LOSSLESS + if (lossless) { + Bitmap.CompressFormat.WEBP_LOSSLESS + } else { + Bitmap.CompressFormat.WEBP_LOSSY + } } else { @Suppress("DEPRECATION") Bitmap.CompressFormat.WEBP } - compress(compressFormat, 100, stream) + compress(compressFormat, quality.coerceIn(0, 100), stream) return stream.toByteArray() -} \ No newline at end of file +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt index 7af59d9f..8a47d42b 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt @@ -19,6 +19,15 @@ import eu.weblibre.flutter_mozilla_components.pigeons.ImageSrcHitResult import eu.weblibre.flutter_mozilla_components.pigeons.PhoneHitResult import eu.weblibre.flutter_mozilla_components.pigeons.UnknownHitResult import eu.weblibre.flutter_mozilla_components.pigeons.VideoHitResult +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import mozilla.components.browser.state.action.BrowserAction import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.action.LastAccessAction @@ -44,6 +53,53 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd private val components by lazy { requireNotNull(GlobalComponents.components) { "Components not initialized" } } + + /** + * Thumbnail scaling and WebP encoding are CPU-heavy and this middleware is + * normally invoked on the browser/UI dispatch path. Keep that work off the + * frame-critical thread and serialize it so periodic captures cannot build + * up a queue of competing bitmap encoders. + */ + private val thumbnailEncodingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val thumbnailEncodingMutex = Mutex() + private var thumbnailEncodingJob: Job? = null + + private fun forwardThumbnail(action: ContentAction.UpdateThumbnailAction) { + // Only the newest selected-tab preview is useful. Cancellation does not + // interrupt Bitmap.compress itself, so the mutex also prevents a newer + // request from starting a second encoder before the old one unwinds. + thumbnailEncodingJob?.cancel() + thumbnailEncodingJob = thumbnailEncodingScope.launch { + thumbnailEncodingMutex.withLock { + if (!isActive) return@withLock + + val resized = action.thumbnail.resize(maxWidth = 720, maxHeight = 720) + try { + // Thumbnails are displayed at a few hundred logical pixels; + // lossless 1280x800 WebP added CPU and channel traffic with + // no visible benefit. + val bytes = resized.toWebPBytes(lossless = false, quality = 82) + if (!isActive) return@withLock + + runOnUiThread { + flutterEvents.onThumbnailChange( + EventSequence.next(), + action.sessionId, + bytes, + ) { _ -> } + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.e("FlutterEventMiddleware", "Failed to encode thumbnail", error) + } finally { + if (resized !== action.thumbnail && !resized.isRecycled) { + resized.recycle() + } + } + } + } + } @Suppress("ComplexMethod") override fun invoke( @@ -53,12 +109,7 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd ) { when (action) { is ContentAction.UpdateThumbnailAction -> { - val resized = action.thumbnail.resize(maxWidth = 1280, maxHeight = 800); - val bytes = resized.toWebPBytes() - - runOnUiThread { - flutterEvents.onThumbnailChange(EventSequence.next(), action.sessionId, bytes) { _ -> } - } + forwardThumbnail(action) } //UpdateReaderConnectRequiredAction seems to be the only event that is called predictable //after a hot reload