diff --git a/apps/weblibre/lib/domain/entities/equatable_image.dart b/apps/weblibre/lib/domain/entities/equatable_image.dart index c025d675..6e7bd97f 100644 --- a/apps/weblibre/lib/domain/entities/equatable_image.dart +++ b/apps/weblibre/lib/domain/entities/equatable_image.dart @@ -19,26 +19,42 @@ */ import 'dart:ui'; -/// A wrapper around [Image] that compares by a precomputed byte-hash. +/// What makes one decoded image the *same* image as another. /// -/// Instances are byte-hash deduped via the decoder's LRU, so the same -/// `EquatableImage` may be shared across many holders (multiple tabs of -/// the same site, the icon cache, widget closures). To make that safe, -/// the underlying `ui.Image` is disposed via a [Finalizer] when this -/// wrapper becomes unreachable — never on cache eviction or state -/// transitions, where another holder might still be using it. +/// Deliberately a record rather than a single combined `int`: [digest] is a +/// 64-bit content hash, and folding it together with the decode options via +/// `Object.hash` would truncate the whole thing to 30 bits (Dart caps hash +/// codes at `0x3fffffff`) and leave the result standing in as the identity +/// with no exact comparison behind it. As a record, hash codes only pick the +/// bucket and structural `==` decides equality, so the full digest and the +/// exact options both count. +typedef ImageIdentity = ({ + int digest, + int? targetWidth, + int? targetHeight, + bool allowUpscaling, +}); + +/// A wrapper around [Image] that compares by a precomputed [ImageIdentity]. +/// +/// Instances are deduped via the decoder's LRU, so the same `EquatableImage` +/// may be shared across many holders (multiple tabs of the same site, the icon +/// cache, widget closures). To make that safe, the underlying `ui.Image` is +/// disposed via a [Finalizer] when this wrapper becomes unreachable — never on +/// cache eviction or state transitions, where another holder might still be +/// using it. class EquatableImage { static final Finalizer _finalizer = Finalizer( (image) => image.dispose(), ); Image? _value; - final int _imageHash; + final ImageIdentity _identity; bool _isDisposed = false; - EquatableImage(Image value, {required int hash}) + EquatableImage(Image value, {required ImageIdentity identity}) : _value = value, - _imageHash = hash { + _identity = identity { _finalizer.attach(this, value, detach: this); } @@ -63,10 +79,10 @@ class EquatableImage { } @override - int get hashCode => _imageHash.hashCode; + int get hashCode => _identity.hashCode; @override bool operator ==(Object other) { - return other is EquatableImage && other._imageHash == _imageHash; + return other is EquatableImage && other._identity == _identity; } } diff --git a/apps/weblibre/lib/features/geckoview/domain/entities/states/tab.dart b/apps/weblibre/lib/features/geckoview/domain/entities/states/tab.dart index a227a35c..73d6660f 100644 --- a/apps/weblibre/lib/features/geckoview/domain/entities/states/tab.dart +++ b/apps/weblibre/lib/features/geckoview/domain/entities/states/tab.dart @@ -23,15 +23,20 @@ import 'package:nullability/nullability.dart'; import 'package:weblibre/data/models/web_page_info.dart'; import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart'; -import 'package:weblibre/features/geckoview/domain/entities/states/find_result.dart'; -import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/security.dart'; -import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; part 'tab.g.dart'; +/// Per-tab state the browser chrome and tab ordering depend on. +/// +/// Deliberately narrow: the whole `Map` is watched by the +/// providers feeding the always-visible quick tab switcher and the grouped tab +/// list, so any field added here rebuilds every visible chip whenever it +/// changes on *any* tab. High-churn fields (load progress, thumbnails, session +/// history, find-in-page results, translation) live in per-tab notifiers in +/// `providers/tab_detail_state.dart` instead. @CopyWith(constructor: '_') class TabState extends WebPageInfo { static final defaultUrl = Uri.parse('about:blank'); @@ -59,10 +64,6 @@ class TabState extends WebPageInfo { ), ); - final EquatableImage? thumbnail; - - final int progress; - final TabMode tabMode; String? get isolationContextId => tabMode.isolationContextId; @@ -70,13 +71,8 @@ class TabState extends WebPageInfo { final bool isLoading; final bool showToolbarAsExpanded; - bool get isFinishedLoading => !isLoading && progress == 100; - final SecurityState securityInfoState; - final HistoryState historyState; final ReaderableState readerableState; - final FindResultState findResultState; - final TranslationState translationState; TabState({ required this.id, @@ -85,17 +81,12 @@ class TabState extends WebPageInfo { required super.url, required String title, required this.icon, - required this.thumbnail, - required this.progress, this.tabMode = TabMode.regular, required this.isFullScreen, required this.isLoading, required this.showToolbarAsExpanded, required this.securityInfoState, - required this.historyState, required this.readerableState, - required this.findResultState, - required this.translationState, }) : super(title: title.trim()); TabState._({ @@ -105,17 +96,12 @@ class TabState extends WebPageInfo { required super.url, required super.title, required this.icon, - required this.thumbnail, - required this.progress, required this.tabMode, required this.isFullScreen, required this.isLoading, required this.showToolbarAsExpanded, required this.securityInfoState, - required this.historyState, required this.readerableState, - required this.findResultState, - required this.translationState, }); factory TabState.$default(String tabId) => TabState( @@ -125,16 +111,11 @@ class TabState extends WebPageInfo { url: defaultUrl, title: "", icon: null, - thumbnail: null, - progress: 0, isFullScreen: false, isLoading: false, showToolbarAsExpanded: false, securityInfoState: SecurityState.$default(), - historyState: HistoryState.$default(), readerableState: ReaderableState.$default(), - findResultState: FindResultState.$default(), - translationState: TranslationState.$default(), ); @override @@ -144,16 +125,11 @@ class TabState extends WebPageInfo { parentId, contextId, icon, - thumbnail, - progress, tabMode, isFullScreen, isLoading, showToolbarAsExpanded, securityInfoState, - historyState, readerableState, - findResultState, - translationState, ]; } diff --git a/apps/weblibre/lib/features/geckoview/domain/entities/states/tab.g.dart b/apps/weblibre/lib/features/geckoview/domain/entities/states/tab.g.dart index 200e20a9..cc3e807e 100644 --- a/apps/weblibre/lib/features/geckoview/domain/entities/states/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/entities/states/tab.g.dart @@ -17,10 +17,6 @@ abstract class _$TabStateCWProxy { TabState icon(EquatableImage? icon); - TabState thumbnail(EquatableImage? thumbnail); - - TabState progress(int progress); - TabState tabMode(TabMode tabMode); TabState isFullScreen(bool isFullScreen); @@ -31,14 +27,8 @@ abstract class _$TabStateCWProxy { TabState securityInfoState(SecurityState securityInfoState); - TabState historyState(HistoryState historyState); - TabState readerableState(ReaderableState readerableState); - TabState findResultState(FindResultState findResultState); - - TabState translationState(TranslationState translationState); - /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabState(...).copyWith.fieldName(value)`. /// @@ -52,17 +42,12 @@ abstract class _$TabStateCWProxy { Uri url, String? title, EquatableImage? icon, - EquatableImage? thumbnail, - int progress, TabMode tabMode, bool isFullScreen, bool isLoading, bool showToolbarAsExpanded, SecurityState securityInfoState, - HistoryState historyState, ReaderableState readerableState, - FindResultState findResultState, - TranslationState translationState, }); } @@ -88,12 +73,6 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy { @override TabState icon(EquatableImage? icon) => call(icon: icon); - @override - TabState thumbnail(EquatableImage? thumbnail) => call(thumbnail: thumbnail); - - @override - TabState progress(int progress) => call(progress: progress); - @override TabState tabMode(TabMode tabMode) => call(tabMode: tabMode); @@ -111,22 +90,10 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy { TabState securityInfoState(SecurityState securityInfoState) => call(securityInfoState: securityInfoState); - @override - TabState historyState(HistoryState historyState) => - call(historyState: historyState); - @override TabState readerableState(ReaderableState readerableState) => call(readerableState: readerableState); - @override - TabState findResultState(FindResultState findResultState) => - call(findResultState: findResultState); - - @override - TabState translationState(TranslationState translationState) => - call(translationState: translationState); - @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabState(...).copyWith.fieldName(value)`. @@ -141,17 +108,12 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy { Object? url = const $CopyWithPlaceholder(), Object? title = const $CopyWithPlaceholder(), Object? icon = const $CopyWithPlaceholder(), - Object? thumbnail = const $CopyWithPlaceholder(), - Object? progress = const $CopyWithPlaceholder(), Object? tabMode = const $CopyWithPlaceholder(), Object? isFullScreen = const $CopyWithPlaceholder(), Object? isLoading = const $CopyWithPlaceholder(), Object? showToolbarAsExpanded = const $CopyWithPlaceholder(), Object? securityInfoState = const $CopyWithPlaceholder(), - Object? historyState = const $CopyWithPlaceholder(), Object? readerableState = const $CopyWithPlaceholder(), - Object? findResultState = const $CopyWithPlaceholder(), - Object? translationState = const $CopyWithPlaceholder(), }) { return TabState._( id: _value.id, @@ -175,14 +137,6 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy { ? _value.icon // ignore: cast_nullable_to_non_nullable : icon as EquatableImage?, - thumbnail: thumbnail == const $CopyWithPlaceholder() - ? _value.thumbnail - // ignore: cast_nullable_to_non_nullable - : thumbnail as EquatableImage?, - progress: progress == const $CopyWithPlaceholder() || progress == null - ? _value.progress - // ignore: cast_nullable_to_non_nullable - : progress as int, tabMode: tabMode == const $CopyWithPlaceholder() || tabMode == null ? _value.tabMode // ignore: cast_nullable_to_non_nullable @@ -208,29 +162,12 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy { ? _value.securityInfoState // ignore: cast_nullable_to_non_nullable : securityInfoState as SecurityState, - historyState: - historyState == const $CopyWithPlaceholder() || historyState == null - ? _value.historyState - // ignore: cast_nullable_to_non_nullable - : historyState as HistoryState, readerableState: readerableState == const $CopyWithPlaceholder() || readerableState == null ? _value.readerableState // ignore: cast_nullable_to_non_nullable : readerableState as ReaderableState, - findResultState: - findResultState == const $CopyWithPlaceholder() || - findResultState == null - ? _value.findResultState - // ignore: cast_nullable_to_non_nullable - : findResultState as FindResultState, - translationState: - translationState == const $CopyWithPlaceholder() || - translationState == null - ? _value.translationState - // ignore: cast_nullable_to_non_nullable - : translationState as TranslationState, ); } } diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.dart new file mode 100644 index 00000000..a559a733 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.dart @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/// High-churn per-tab state, deliberately kept *out* of [TabState]. +/// +/// [TabState] is fanned out to the always-visible browser chrome — the quick +/// tab switcher chips, the grouped tab list, the contextual toolbar — through +/// providers that watch the whole `Map`. Any field that ticks +/// during a page load or on a background timer therefore rebuilt every chip on +/// screen, even though no chip renders that field. +/// +/// The fields here are each consumed by exactly one or two widgets, so they +/// live in their own keyed notifiers and are read through per-tab selectors. +/// A progress tick or a background tab's screenshot now invalidates only the +/// widget that actually shows it. +library; + +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/find_result.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart'; + +part 'tab_detail_state.g.dart'; + +/// Load progress (0-100) per tab. Ticks continuously while a page loads. +@Riverpod(keepAlive: true) +class TabProgressStates extends _$TabProgressStates { + @override + Map build() => const {}; + + void update(String tabId, int progress) { + if (state[tabId] == progress) { + return; + } + + state = {...state}..[tabId] = progress; + } +} + +@Riverpod() +int tabProgress(Ref ref, String? tabId) { + if (tabId == null) { + return 0; + } + + return ref.watch(tabProgressStatesProvider.select((s) => s[tabId] ?? 0)); +} + +/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and +/// consumed only by the tab tray previews. +@Riverpod(keepAlive: true) +class TabThumbnails extends _$TabThumbnails { + @override + Map build() => const {}; + + void update(String tabId, EquatableImage? thumbnail) { + if (thumbnail == null) { + if (!state.containsKey(tabId)) { + return; + } + + state = {...state}..remove(tabId); + return; + } + + if (state[tabId] == thumbnail) { + return; + } + + state = {...state}..[tabId] = thumbnail; + } +} + +@Riverpod() +EquatableImage? tabThumbnail(Ref ref, String? tabId) { + if (tabId == null) { + return null; + } + + return ref.watch(tabThumbnailsProvider.select((s) => s[tabId])); +} + +/// Session history (back/forward stack) per tab. +@Riverpod(keepAlive: true) +class TabHistoryStates extends _$TabHistoryStates { + @override + Map build() => const {}; + + void update(String tabId, HistoryState history) { + if (state[tabId] == history) { + return; + } + + state = {...state}..[tabId] = history; + } +} + +@Riverpod() +HistoryState tabHistoryState(Ref ref, String? tabId) { + if (tabId == null) { + return HistoryState.$default(); + } + + return ref.watch( + tabHistoryStatesProvider.select((s) => s[tabId] ?? HistoryState.$default()), + ); +} + +/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while +/// a search is running. +@Riverpod(keepAlive: true) +class TabFindResultStates extends _$TabFindResultStates { + @override + Map build() => const {}; + + void update(String tabId, FindResultState result) { + if (state[tabId] == result) { + return; + } + + state = {...state}..[tabId] = result; + } + + FindResultState resultFor(String tabId) => + state[tabId] ?? FindResultState.$default(); +} + +@Riverpod() +FindResultState tabFindResultState(Ref ref, String? tabId) { + if (tabId == null) { + return FindResultState.$default(); + } + + return ref.watch( + tabFindResultStatesProvider.select( + (s) => s[tabId] ?? FindResultState.$default(), + ), + ); +} + +/// Translation progress/result per tab. +@Riverpod(keepAlive: true) +class TabTranslationStates extends _$TabTranslationStates { + @override + Map build() => const {}; + + void update(String tabId, TranslationState translation) { + if (state[tabId] == translation) { + return; + } + + state = {...state}..[tabId] = translation; + } +} + +@Riverpod() +TranslationState tabTranslationState(Ref ref, String? tabId) { + if (tabId == null) { + return TranslationState.$default(); + } + + return ref.watch( + tabTranslationStatesProvider.select( + (s) => s[tabId] ?? TranslationState.$default(), + ), + ); +} diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.g.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.g.dart new file mode 100644 index 00000000..ad8437a5 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.g.dart @@ -0,0 +1,727 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab_detail_state.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Load progress (0-100) per tab. Ticks continuously while a page loads. + +@ProviderFor(TabProgressStates) +final tabProgressStatesProvider = TabProgressStatesProvider._(); + +/// Load progress (0-100) per tab. Ticks continuously while a page loads. +final class TabProgressStatesProvider + extends $NotifierProvider> { + /// Load progress (0-100) per tab. Ticks continuously while a page loads. + TabProgressStatesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabProgressStatesProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabProgressStatesHash(); + + @$internal + @override + TabProgressStates create() => TabProgressStates(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$tabProgressStatesHash() => r'60bb03e33e4322eb2869b90b5cc5802d3cdc225d'; + +/// Load progress (0-100) per tab. Ticks continuously while a page loads. + +abstract class _$TabProgressStates extends $Notifier> { + Map build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref, Map>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, Map>, + Map, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} + +@ProviderFor(tabProgress) +final tabProgressProvider = TabProgressFamily._(); + +final class TabProgressProvider extends $FunctionalProvider + with $Provider { + TabProgressProvider._({ + required TabProgressFamily super.from, + required String? super.argument, + }) : super( + retry: null, + name: r'tabProgressProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabProgressHash(); + + @override + String toString() { + return r'tabProgressProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + int create(Ref ref) { + final argument = this.argument as String?; + return tabProgress(ref, argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(int value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is TabProgressProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$tabProgressHash() => r'83c4990d96f2160e919ee99882905c09872e8bb7'; + +final class TabProgressFamily extends $Family + with $FunctionalFamilyOverride { + TabProgressFamily._() + : super( + retry: null, + name: r'tabProgressProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + TabProgressProvider call(String? tabId) => + TabProgressProvider._(argument: tabId, from: this); + + @override + String toString() => r'tabProgressProvider'; +} + +/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and +/// consumed only by the tab tray previews. + +@ProviderFor(TabThumbnails) +final tabThumbnailsProvider = TabThumbnailsProvider._(); + +/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and +/// consumed only by the tab tray previews. +final class TabThumbnailsProvider + extends $NotifierProvider> { + /// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and + /// consumed only by the tab tray previews. + TabThumbnailsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabThumbnailsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabThumbnailsHash(); + + @$internal + @override + TabThumbnails create() => TabThumbnails(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$tabThumbnailsHash() => r'16c4bb6edff2e6413100d9ce02c1ae7948740cfe'; + +/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and +/// consumed only by the tab tray previews. + +abstract class _$TabThumbnails extends $Notifier> { + Map build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref + as $Ref, Map>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + Map, + Map + >, + Map, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} + +@ProviderFor(tabThumbnail) +final tabThumbnailProvider = TabThumbnailFamily._(); + +final class TabThumbnailProvider + extends + $FunctionalProvider + with $Provider { + TabThumbnailProvider._({ + required TabThumbnailFamily super.from, + required String? super.argument, + }) : super( + retry: null, + name: r'tabThumbnailProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabThumbnailHash(); + + @override + String toString() { + return r'tabThumbnailProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + EquatableImage? create(Ref ref) { + final argument = this.argument as String?; + return tabThumbnail(ref, argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EquatableImage? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is TabThumbnailProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$tabThumbnailHash() => r'7c8b591abdb5e8cc914ab8e44247a4bca6bddd15'; + +final class TabThumbnailFamily extends $Family + with $FunctionalFamilyOverride { + TabThumbnailFamily._() + : super( + retry: null, + name: r'tabThumbnailProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + TabThumbnailProvider call(String? tabId) => + TabThumbnailProvider._(argument: tabId, from: this); + + @override + String toString() => r'tabThumbnailProvider'; +} + +/// Session history (back/forward stack) per tab. + +@ProviderFor(TabHistoryStates) +final tabHistoryStatesProvider = TabHistoryStatesProvider._(); + +/// Session history (back/forward stack) per tab. +final class TabHistoryStatesProvider + extends $NotifierProvider> { + /// Session history (back/forward stack) per tab. + TabHistoryStatesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabHistoryStatesProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabHistoryStatesHash(); + + @$internal + @override + TabHistoryStates create() => TabHistoryStates(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$tabHistoryStatesHash() => r'3920410334c4354ca66da605d9bfe85b4853f3bf'; + +/// Session history (back/forward stack) per tab. + +abstract class _$TabHistoryStates extends $Notifier> { + Map build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref as $Ref, Map>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, Map>, + Map, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} + +@ProviderFor(tabHistoryState) +final tabHistoryStateProvider = TabHistoryStateFamily._(); + +final class TabHistoryStateProvider + extends $FunctionalProvider + with $Provider { + TabHistoryStateProvider._({ + required TabHistoryStateFamily super.from, + required String? super.argument, + }) : super( + retry: null, + name: r'tabHistoryStateProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabHistoryStateHash(); + + @override + String toString() { + return r'tabHistoryStateProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + HistoryState create(Ref ref) { + final argument = this.argument as String?; + return tabHistoryState(ref, argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(HistoryState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is TabHistoryStateProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$tabHistoryStateHash() => r'5bca69fddb6fc2f3db6caf5c84bd1a7f5728e1d9'; + +final class TabHistoryStateFamily extends $Family + with $FunctionalFamilyOverride { + TabHistoryStateFamily._() + : super( + retry: null, + name: r'tabHistoryStateProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + TabHistoryStateProvider call(String? tabId) => + TabHistoryStateProvider._(argument: tabId, from: this); + + @override + String toString() => r'tabHistoryStateProvider'; +} + +/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while +/// a search is running. + +@ProviderFor(TabFindResultStates) +final tabFindResultStatesProvider = TabFindResultStatesProvider._(); + +/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while +/// a search is running. +final class TabFindResultStatesProvider + extends + $NotifierProvider> { + /// Find-in-page match counters per tab. Emitted at a high rate by Gecko while + /// a search is running. + TabFindResultStatesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabFindResultStatesProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabFindResultStatesHash(); + + @$internal + @override + TabFindResultStates create() => TabFindResultStates(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$tabFindResultStatesHash() => + r'f57dee1658eae789002ac3cff15808cbc96b7883'; + +/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while +/// a search is running. + +abstract class _$TabFindResultStates + extends $Notifier> { + Map build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref + as $Ref, Map>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + Map, + Map + >, + Map, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} + +@ProviderFor(tabFindResultState) +final tabFindResultStateProvider = TabFindResultStateFamily._(); + +final class TabFindResultStateProvider + extends + $FunctionalProvider + with $Provider { + TabFindResultStateProvider._({ + required TabFindResultStateFamily super.from, + required String? super.argument, + }) : super( + retry: null, + name: r'tabFindResultStateProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabFindResultStateHash(); + + @override + String toString() { + return r'tabFindResultStateProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + FindResultState create(Ref ref) { + final argument = this.argument as String?; + return tabFindResultState(ref, argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(FindResultState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is TabFindResultStateProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$tabFindResultStateHash() => + r'0f58040111bb43d830e14ae44569265b9ae804d9'; + +final class TabFindResultStateFamily extends $Family + with $FunctionalFamilyOverride { + TabFindResultStateFamily._() + : super( + retry: null, + name: r'tabFindResultStateProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + TabFindResultStateProvider call(String? tabId) => + TabFindResultStateProvider._(argument: tabId, from: this); + + @override + String toString() => r'tabFindResultStateProvider'; +} + +/// Translation progress/result per tab. + +@ProviderFor(TabTranslationStates) +final tabTranslationStatesProvider = TabTranslationStatesProvider._(); + +/// Translation progress/result per tab. +final class TabTranslationStatesProvider + extends + $NotifierProvider> { + /// Translation progress/result per tab. + TabTranslationStatesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabTranslationStatesProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabTranslationStatesHash(); + + @$internal + @override + TabTranslationStates create() => TabTranslationStates(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>( + value, + ), + ); + } +} + +String _$tabTranslationStatesHash() => + r'f04004026dc5fd638319528a55e2f10e0f64c9b3'; + +/// Translation progress/result per tab. + +abstract class _$TabTranslationStates + extends $Notifier> { + Map build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref + as $Ref< + Map, + Map + >; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + Map, + Map + >, + Map, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} + +@ProviderFor(tabTranslationState) +final tabTranslationStateProvider = TabTranslationStateFamily._(); + +final class TabTranslationStateProvider + extends + $FunctionalProvider< + TranslationState, + TranslationState, + TranslationState + > + with $Provider { + TabTranslationStateProvider._({ + required TabTranslationStateFamily super.from, + required String? super.argument, + }) : super( + retry: null, + name: r'tabTranslationStateProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabTranslationStateHash(); + + @override + String toString() { + return r'tabTranslationStateProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + TranslationState create(Ref ref) { + final argument = this.argument as String?; + return tabTranslationState(ref, argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TranslationState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is TabTranslationStateProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$tabTranslationStateHash() => + r'886365e36167542b2e4dbdbeb97c942a414a752e'; + +final class TabTranslationStateFamily extends $Family + with $FunctionalFamilyOverride { + TabTranslationStateFamily._() + : super( + retry: null, + name: r'tabTranslationStateProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + TabTranslationStateProvider call(String? tabId) => + TabTranslationStateProvider._(argument: tabId, from: this); + + @override + String toString() => r'tabTranslationStateProvider'; +} diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart index 6d961969..456e83ed 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart @@ -19,6 +19,7 @@ */ import 'dart:async'; +import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod/riverpod.dart'; @@ -34,6 +35,7 @@ import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; @@ -46,8 +48,33 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting part 'tab_state.g.dart'; +/// Decode width for tab thumbnails. The screenshots arrive at full device +/// resolution but are only ever shown in tab-tray previews a few hundred +/// logical pixels wide, so decoding them at native size burns main-isolate +/// time and GPU memory for detail nobody sees. Only the width is constrained +/// so the decoder keeps the aspect ratio; upscaling is disabled so smaller +/// sources are left alone. +const thumbnailDecodeWidth = 720; + @Riverpod(keepAlive: true) class TabStates extends _$TabStates { + /// Replaces the entry for [tabId] — but only when [next] actually differs. + /// + /// Every write here allocates a new map, and `Map` compares by identity, so + /// an unconditional assignment notifies *all* whole-map watchers (the quick + /// tab switcher, the grouped tab list, the FIFO list) even when nothing + /// changed. Gecko re-emits the full content state on every progress tick, so + /// without this guard a single page load pushed dozens of no-op rebuilds + /// through the entire browser chrome. [TabState] is `FastEquatable`, so the + /// comparison is a cached-hash check. + void _put(String tabId, TabState next) { + if (state[tabId] == next) { + return; + } + + state = {...state}..[tabId] = next; + } + Future _onTabContentStateChange(TabContentState contentState) async { final current = await patchedState(contentState.id); @@ -82,14 +109,19 @@ class TabStates extends _$TabStates { contextId: contentState.contextId, url: url, title: resolvedTitle, - progress: contentState.progress, tabMode: inferredTabMode, isFullScreen: contentState.isFullScreen, isLoading: contentState.isLoading, showToolbarAsExpanded: contentState.showToolbarAsExpanded, ); - state = {...state}..[contentState.id] = newState; + _put(contentState.id, newState); + + // Progress lives outside [TabState] so a load tick doesn't invalidate the + // whole map (and with it every chip in the quick tab switcher). + ref + .read(tabProgressStatesProvider.notifier) + .update(contentState.id, contentState.progress); // Only reconcile DB hierarchy when the engine parent link actually changes. // Content-state events also fire on every progress/title tick, and seeding @@ -106,7 +138,7 @@ class TabStates extends _$TabStates { ); } - if (newState.isFinishedLoading) { + if (!contentState.isLoading && contentState.progress == 100) { ref .read(geckoInferenceRepositoryProvider.notifier) .markInitialLoadComplete(); @@ -138,97 +170,111 @@ class TabStates extends _$TabStates { final IconChangeEvent(:tabId, :bytes) = event; final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); - final current = state[tabId] ?? TabState.$default(tabId); - state = {...state}..[tabId] = current.copyWith.icon(image); + if (!ref.mounted) { + return; + } + + final current = state[tabId] ?? TabState.$default(tabId); + _put(tabId, current.copyWith.icon(image)); } Future _onThumbnailChange(ThumbnailEvent event) async { final ThumbnailEvent(:tabId, :bytes) = event; - final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); - final current = state[tabId] ?? TabState.$default(tabId); + final image = await bytes.mapNotNull( + (bytes) => tryDecodeImage( + bytes, + targetWidth: thumbnailDecodeWidth, + allowUpscaling: false, + ), + ); - state = {...state}..[tabId] = current.copyWith.thumbnail(image); + if (!ref.mounted) { + return; + } + + ref.read(tabThumbnailsProvider.notifier).update(tabId, image); } void _onSecurityInfoStateChange(SecurityInfoEvent event) { final SecurityInfoEvent(:tabId, :securityInfo) = event; final current = state[tabId] ?? TabState.$default(tabId); - state = {...state} - ..[tabId] = current.copyWith.securityInfoState( + _put( + tabId, + current.copyWith.securityInfoState( SecurityState( secure: securityInfo.secure, host: securityInfo.host, issuer: securityInfo.issuer, ), - ); + ), + ); } void _onHistoryStateChange(HistoryEvent event) { final HistoryEvent(:tabId, :history) = event; - final current = state[tabId] ?? TabState.$default(tabId); - state = {...state} - ..[tabId] = current.copyWith.historyState( - HistoryState( - items: history.items.nonNulls - .map( - (item) => - HistoryItem(url: Uri.parse(item.url), title: item.title), - ) - .toList(), - currentIndex: history.currentIndex, - canGoBack: history.canGoBack, - canGoForward: history.canGoForward, - ), - ); + ref + .read(tabHistoryStatesProvider.notifier) + .update( + tabId, + HistoryState( + items: history.items.nonNulls + .map( + (item) => + HistoryItem(url: Uri.parse(item.url), title: item.title), + ) + .toList(), + currentIndex: history.currentIndex, + canGoBack: history.canGoBack, + canGoForward: history.canGoForward, + ), + ); } void _onReaderableStateChange(ReaderableEvent event) { final ReaderableEvent(:tabId, :readerable) = event; final current = state[tabId] ?? TabState.$default(tabId); - state = {...state} - ..[tabId] = current.copyWith.readerableState( + _put( + tabId, + current.copyWith.readerableState( ReaderableState( readerable: readerable.readerable, active: readerable.active, ), - ); + ), + ); } void _onTabTranslationStateChange(TabTranslationEvent event) { final TabTranslationEvent(:tabId, :state) = event; - final current = this.state[tabId] ?? TabState.$default(tabId); - this.state = {...this.state} - ..[tabId] = current.copyWith.translationState( - TranslationState.fromData(state), - ); + ref + .read(tabTranslationStatesProvider.notifier) + .update(tabId, TranslationState.fromData(state)); } void _onFindResultsChange(FindResultsEvent event) { final FindResultsEvent(:tabId, :results) = event; - final current = state[tabId] ?? TabState.$default(tabId); + final findResults = ref.read(tabFindResultStatesProvider.notifier); if (results.isNotEmpty) { final result = results.last; - state = {...state} - ..[tabId] = current.copyWith.findResultState( - FindResultState( - lastSearchText: ref.read(findInPageRepositoryProvider(tabId)), - activeMatchOrdinal: result.activeMatchOrdinal, - numberOfMatches: result.numberOfMatches, - isDoneCounting: result.isDoneCounting, - ), - ); - } else if (current.findResultState.hasMatches) { - state = { - ...state, - }..[tabId] = current.copyWith.findResultState(FindResultState.$default()); + findResults.update( + tabId, + FindResultState( + lastSearchText: ref.read(findInPageRepositoryProvider(tabId)), + activeMatchOrdinal: result.activeMatchOrdinal, + numberOfMatches: result.numberOfMatches, + isDoneCounting: result.isDoneCounting, + ), + ); + } else if (findResults.resultFor(tabId).hasMatches) { + findResults.update(tabId, FindResultState.$default()); } } @@ -309,8 +355,15 @@ class TabStates extends _$TabStates { ); }, ), + // Gecko emits match counters at ~40/s while a find is running. 25ms let + // essentially every one of those through; 100ms is still well under the + // threshold where the counter feels laggy. Debounced *per tab* so a + // background tab still counting can't starve the foreground one. eventService.findResultsEvent - .debounceTime(const Duration(milliseconds: 25)) + .groupBy((event) => event.tabId) + .flatMap( + (group) => group.debounceTime(const Duration(milliseconds: 100)), + ) .listen( (event) { _onFindResultsChange(event); @@ -381,6 +434,42 @@ TabState? tabState(Ref ref, String? tabId) { return ref.watch(tabStatesProvider.select((tabs) => tabs[tabId])); } +/// The only fields of [TabState] that tab filtering/sorting depends on. +class TabSortKeys with FastEquatable { + final TabMode tabMode; + final String titleOrAuthority; + final String url; + + TabSortKeys({ + required this.tabMode, + required this.titleOrAuthority, + required this.url, + }); + + @override + List get hashParameters => [tabMode, titleOrAuthority, url]; +} + +/// Projection of [tabStatesProvider] for consumers that order or filter tabs +/// but render nothing from the state itself. Watching this instead of the full +/// map keeps the (expensive) grouping/sorting passes off the path of every +/// icon, security-info and readerable event. +@Riverpod(keepAlive: true) +EquatableValue> tabSortKeys(Ref ref) { + return ref.watch( + tabStatesProvider.select( + (states) => EquatableValue({ + for (final MapEntry(:key, :value) in states.entries) + key: TabSortKeys( + tabMode: value.tabMode, + titleOrAuthority: value.titleOrAuthority, + url: value.url.toString(), + ), + }), + ), + ); +} + @Riverpod() Future tabStateWithFallback(Ref ref, String tabId) async { final state = ref.watch(tabStateProvider(tabId)); diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart index e409643b..1daecb8a 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -41,7 +41,7 @@ final class TabStatesProvider } } -String _$tabStatesHash() => r'04b7f1ea704575e368a0f1186fc1c58f317620b1'; +String _$tabStatesHash() => r'1ae4e941890116eabf1803809014b3e575c33e73'; abstract class _$TabStates extends $Notifier> { Map build(); @@ -138,6 +138,68 @@ final class TabStateFamily extends $Family String toString() => r'tabStateProvider'; } +/// Projection of [tabStatesProvider] for consumers that order or filter tabs +/// but render nothing from the state itself. Watching this instead of the full +/// map keeps the (expensive) grouping/sorting passes off the path of every +/// icon, security-info and readerable event. + +@ProviderFor(tabSortKeys) +final tabSortKeysProvider = TabSortKeysProvider._(); + +/// Projection of [tabStatesProvider] for consumers that order or filter tabs +/// but render nothing from the state itself. Watching this instead of the full +/// map keeps the (expensive) grouping/sorting passes off the path of every +/// icon, security-info and readerable event. + +final class TabSortKeysProvider + extends + $FunctionalProvider< + EquatableValue>, + EquatableValue>, + EquatableValue> + > + with $Provider>> { + /// Projection of [tabStatesProvider] for consumers that order or filter tabs + /// but render nothing from the state itself. Watching this instead of the full + /// map keeps the (expensive) grouping/sorting passes off the path of every + /// icon, security-info and readerable event. + TabSortKeysProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabSortKeysProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabSortKeysHash(); + + @$internal + @override + $ProviderElement>> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + EquatableValue> create(Ref ref) { + return tabSortKeys(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EquatableValue> value) { + return $ProviderOverride( + origin: this, + providerOverride: + $SyncValueProvider>>(value), + ); + } +} + +String _$tabSortKeysHash() => r'7bf660099d006b6df9846594d2a5d78a32fa3bdb'; + @ProviderFor(tabStateWithFallback) final tabStateWithFallbackProvider = TabStateWithFallbackFamily._(); diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart index 231e707a..fda194eb 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart @@ -35,6 +35,7 @@ import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/pending_tab_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; @@ -957,9 +958,15 @@ class TabRepository extends _$TabRepository { return; } + final historyIsEmpty = + ref + .read(tabHistoryStatesProvider)[currentTabState.id] + ?.items + .isEmpty ?? + true; + final tabIsEmpty = - currentTabState.url == TabState.defaultUrl && - currentTabState.historyState.items.isEmpty; + currentTabState.url == TabState.defaultUrl && historyIsEmpty; if (event.blocked || tabIsEmpty) { final newTabId = await addTab( @@ -976,7 +983,7 @@ class TabRepository extends _$TabRepository { return; } - if (currentTabState.historyState.items.isEmpty) { + if (historyIsEmpty) { await closeTab(currentTabState.id); if (!ref.mounted) { return; diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart index ff5c47ff..4b69d487 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'e80c972b0d1a328c170354c123d2730028a774b2'; +String _$tabRepositoryHash() => r'8abe7686d937434e2970675c143f3891ec34cce2'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart index 7c83cff2..bbde117d 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart @@ -881,17 +881,14 @@ EquatableValue> filteredTabPreviews( return EquatableValue([]); } - final sandboxCaptureMap = - ref.watch(sandboxCaptureMapProvider).value ?? const {}; + final sandboxSourceUris = ref.watch(sandboxSourceUrisProvider).value; return EquatableValue( tabSearchResults.results .where((tab) => availableTabStates.value.containsKey(tab.id)) .map((tab) { final tabState = availableTabStates.value[tab.id]!; - final sandboxSourceUri = parseSandboxSource( - sandboxCaptureMap[tab.id], - ); + final sandboxSourceUri = sandboxSourceUris[tab.id]; return TabPreview( id: tab.id, @@ -933,7 +930,12 @@ EquatableValue> groupedTabListItems( } final tabList = ref.watch(tabListProvider); - final tabStates = ref.watch(tabStatesProvider); + // Narrow projection instead of the whole `Map`: this + // provider does a graph walk plus several sorts, and it feeds the always + // visible quick tab switcher's depth map. It only reads the tab mode (for + // filtering) and the title/url (for sorting) — none of which change more + // often than once per navigation. + final tabSortKeys = ref.watch(tabSortKeysProvider).value; final filterOptions = ref.watch(tabViewFilterControllerProvider); final pinnedTabIds = ref.watch( watchPinnedTabIdsProvider.select( @@ -963,7 +965,7 @@ EquatableValue> groupedTabListItems( .where((row) => tabList.value.contains(row.id)) .where( (row) => filterOptions.matchesTab( - tabStates[row.id]?.tabMode, + tabSortKeys[row.id]?.tabMode, tabTimestamps?[row.id], ), ) @@ -1032,7 +1034,7 @@ EquatableValue> groupedTabListItems( for (final entry in byRoot.entries) { final rootMember = entry.value.firstWhere((r) => r.row.id == entry.key); final root = rootMember.row; - final state = tabStates[root.id]; + final sortKeys = tabSortKeys[root.id]; final timestamp = tabTimestamps?[root.id]; groupRecords.add( _TabGroupRecord( @@ -1043,10 +1045,10 @@ EquatableValue> groupedTabListItems( isPinned: pinnedTabIds.contains(root.id), titleKey: sortField == SortField.titleAsc || sortField == SortField.titleDesc - ? (state?.titleOrAuthority ?? '').toLowerCase() + ? (sortKeys?.titleOrAuthority ?? '').toLowerCase() : null, urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc - ? (state?.url.toString() ?? '') + ? (sortKeys?.url ?? '') : null, dateKey: sortField == SortField.dateAsc || sortField == SortField.dateDesc diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart index fa70ab96..f5f9d49b 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -1092,7 +1092,7 @@ final class FilteredTabPreviewsProvider } String _$filteredTabPreviewsHash() => - r'074df0d2000ae325fbd1db775abe4836491b32dd'; + r'e291cdb7848c9607d388f8fdfb640f25265fca03'; final class FilteredTabPreviewsFamily extends $Family with @@ -1212,7 +1212,7 @@ final class GroupedTabListItemsProvider } String _$groupedTabListItemsHash() => - r'dbb510f22c00858fcbadfb94547d46841221fca4'; + r'd7158c50ca34014d63044e7fb52a7197245c59ad'; /// Grouped flat-list rendering for the list and grid views. /// diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart index cec8b6ec..ef0c7fbb 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart'; @@ -26,6 +27,12 @@ class ContextualToolbarScope with FastEquatable { final String? selectedTabId; final Sheet? displayedSheet; final TabState? tabState; + + /// Back/forward availability. Lives outside [TabState] (see + /// `providers/tab_detail_state.dart`) but the navigation buttons need it, so + /// the toolbar resolves it once and passes it down with the scope. + final HistoryState historyState; + final bool isPreview; /// Which configuration set this scope renders for, so shared registry @@ -38,14 +45,16 @@ class ContextualToolbarScope with FastEquatable { required this.displayedSheet, required this.tabState, required this.isPreview, + HistoryState? historyState, this.location = ToolbarConfigLocation.contextual, - }); + }) : historyState = historyState ?? HistoryState.$default(); @override List get hashParameters => [ selectedTabId, displayedSheet, tabState, + historyState, isPreview, location, ]; diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart index 3a1e1a7a..11faa1ab 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart @@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; @@ -104,7 +105,7 @@ final List toolbarButtonRegistry = [ label: 'Back', icon: Icons.arrow_back, isPrimaryAvailable: (scope, ref) { - final canGoBack = scope.tabState?.historyState.canGoBack == true; + final canGoBack = scope.historyState.canGoBack; final isLoading = scope.tabState?.isLoading == true; // The back button only doubles as a stop-loading control when no // dedicated reload button is present to take over that role. @@ -132,8 +133,7 @@ final List toolbarButtonRegistry = [ spec: forwardToolbarButtonSpec, label: 'Forward', icon: Icons.arrow_forward, - isPrimaryAvailable: (scope, ref) => - scope.tabState?.historyState.canGoForward == true, + isPrimaryAvailable: (scope, ref) => scope.historyState.canGoForward, longPressActions: ['History Menu (Forward pages)'], builder: (scope, context, ref) { if (scope.isPreview) { @@ -1026,9 +1026,7 @@ class _TranslateToolbarButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isTranslated = ref.watch( - tabStateProvider( - selectedTabId, - ).select((s) => s?.translationState.isTranslated ?? false), + tabTranslationStateProvider(selectedTabId).select((s) => s.isTranslated), ); return IconButton( diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart index 5bb89bb5..1e4bdd74 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart'; @@ -48,6 +49,7 @@ class ContextualToolbar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tabState = ref.watch(tabStateProvider(selectedTabId)); + final historyState = ref.watch(tabHistoryStateProvider(selectedTabId)); final configs = ref.watch( effectiveToolbarButtonConfigsProvider(ToolbarConfigLocation.contextual), ); @@ -56,6 +58,7 @@ class ContextualToolbar extends HookConsumerWidget { selectedTabId: selectedTabId, displayedSheet: displayedSheet, tabState: tabState, + historyState: historyState, isPreview: false, ); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/quick_switcher_button_row.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/quick_switcher_button_row.dart index 038c79ea..60a54b8a 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/quick_switcher_button_row.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/quick_switcher_button_row.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart'; @@ -62,6 +63,7 @@ class QuickSwitcherButtonRow extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tabState = ref.watch(tabStateProvider(selectedTabId)); + final historyState = ref.watch(tabHistoryStateProvider(selectedTabId)); final configs = ref.watch( effectiveToolbarButtonConfigsProvider( ToolbarConfigLocation.quickSwitcher, @@ -72,6 +74,7 @@ class QuickSwitcherButtonRow extends HookConsumerWidget { selectedTabId: selectedTabId, displayedSheet: displayedSheet, tabState: tabState, + historyState: historyState, isPreview: false, location: ToolbarConfigLocation.quickSwitcher, ); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart index 687d742c..b4298a68 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart @@ -20,6 +20,7 @@ import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -45,17 +46,17 @@ class ToolbarVisibilityController extends _$ToolbarVisibilityController { }); // Show toolbar on navigation (history state change) - ref.listen(tabStatesProvider.select((tabs) => tabs[tabId]?.historyState), ( - previous, - next, - ) { - if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) { - return; - } - if (next != null && previous != null && previous != next) { - show(); - } - }); + ref.listen( + tabHistoryStatesProvider.select((histories) => histories[tabId]), + (previous, next) { + if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) { + return; + } + if (next != null && previous != null && previous != next) { + show(); + } + }, + ); // Force-show when GeckoView requests toolbar expansion // (e.g. touch on form input) diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.g.dart index 63088dae..542bd13c 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.g.dart @@ -61,7 +61,7 @@ final class ToolbarVisibilityControllerProvider } String _$toolbarVisibilityControllerHash() => - r'e947508c351d4cbbe8c63289171a6d0a34f1a61a'; + r'9d88ca83964d84acfb23f400d02ae277d6866b3f'; final class ToolbarVisibilityControllerFamily extends $Family with diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart index a488c5be..fae7ac78 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -35,6 +35,7 @@ import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; @@ -492,9 +493,14 @@ class _BrowserContentPositioned extends ConsumerWidget { final toolbarState = ref.watch( toolbarVisibilityControllerProvider(selectedTabId), ); + // Deliberately does NOT include `sheetDisplayed` (unlike the layers that + // render chrome above the page). A sheet is painted over the browser, so + // letting it force the toolbar "visible" here would move the Positioned + // that wraps the platform view — a native re-layout, and under hybrid + // composition a surface recreation, in the same frame as the sheet's + // slide-in. The geometry the page sees stays frozen while a sheet is up. final toolbarVisible = - sheetDisplayed || - (!tabInFullScreen && toolbarState == ToolbarVisibility.visible); + !tabInFullScreen && toolbarState == ToolbarVisibility.visible; // When auto-hide is disabled, constrain browser above toolbar // (unless toolbar is manually dismissed via swipe gesture). @@ -527,11 +533,14 @@ class _BrowserContentPositioned extends ConsumerWidget { // hidden state is still handled by GeckoView's dynamic // toolbar/clipping logic. On the rail there is never a bottom // bar, so the browser always needs the bottom safe inset. + // + // `sheetDisplayed` is likewise excluded here: toggling the safe-area + // padding on sheet open/close would resize the platform view for content + // the sheet covers anyway. final applyBottomSafeArea = isRail - ? (!tabInFullScreen && !isSmallWebActive && !sheetDisplayed) + ? (!tabInFullScreen && !isSmallWebActive) : (!tabInFullScreen && !isSmallWebActive && - !sheetDisplayed && bottomOffset == 0 && toolbarState == ToolbarVisibility.dismissed); @@ -812,17 +821,14 @@ class _ProgressIndicatorBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final value = ref.watch( - selectedTabStateProvider.select((state) { - if (state?.isLoading == true) { - return state?.progress ?? 100; - } - - //When not loading we assumed finished - return 100; - }), + final tabId = ref.watch(selectedTabProvider); + final isLoading = ref.watch( + tabStateProvider(tabId).select((state) => state?.isLoading == true), ); + //When not loading we assumed finished + final value = isLoading ? ref.watch(tabProgressProvider(tabId)) : 100; + return Visibility( visible: value < 100, child: LinearProgressIndicator(value: value / 100), @@ -1182,13 +1188,26 @@ class BrowserScreen extends HookConsumerWidget { // Calculate bottom toolbar size for FAB and sheet positioning final Size bottomAppBarContentSize; + // The same size computed as if no sheet were displayed. `ViewTabsSheet` + // hides the main toolbar and the quick tab switcher, which shrinks the bar + // by 48-152px — and that value feeds the browser's viewport geometry + // (dynamic toolbar inset, vertical clipping and the Positioned that wraps + // the platform view). Resizing the Gecko viewport when a sheet opens costs + // a page reflow plus, under hybrid composition, a native surface + // recreation — all in the same frame as the sheet's slide-in animation. + // The sheet is drawn *on top* of the page, so the page's viewport has no + // reason to change: the inset math below uses this frozen value while the + // widgets that actually render the bar keep the real one. + final Size viewportBottomAppBarContentSize; if (isSmallWebActive) { bottomAppBarContentSize = const Size.fromHeight( SmallWebBrowserOverlay.barHeight, ); + viewportBottomAppBarContentSize = bottomAppBarContentSize; } else if (isRail) { // The rail occupies a side, not the bottom; no bottom bar is rendered. bottomAppBarContentSize = Size.zero; + viewportBottomAppBarContentSize = bottomAppBarContentSize; } else { // Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar bottomAppBarContentSize = BrowserBottomAppBar( @@ -1198,10 +1217,21 @@ class BrowserScreen extends HookConsumerWidget { isSmallWebMode: false, displayedSheet: displayedSheet, ).preferredSize; + viewportBottomAppBarContentSize = displayedSheet == null + ? bottomAppBarContentSize + : BrowserBottomAppBar( + showMainToolbar: tabBarPosition == TabBarPosition.bottom, + showContextualToolbar: showContextualToolbar, + quickTabSwitcherRowCount: quickTabSwitcherRowCount, + isSmallWebMode: false, + displayedSheet: null, + ).preferredSize; } // Total height includes safe area padding final bottomAppBarTotalHeight = bottomAppBarContentSize.height + bottomSafeArea; + final viewportBottomAppBarTotalHeight = + viewportBottomAppBarContentSize.height + bottomSafeArea; // Side rail width reservation (vertical positions only): the fixed content // width plus the system safe-area inset on the rail's outer edge. @@ -1262,7 +1292,8 @@ class BrowserScreen extends HookConsumerWidget { final autoHideToolbarHeight = switch (tabBarPosition) { // Gecko's dynamic toolbar value is consumed as a bottom inset by Dart and // native UI. The top toolbar itself is positioned with Flutter's topOffset. - TabBarPosition.top || TabBarPosition.bottom => bottomAppBarTotalHeight, + TabBarPosition.top || + TabBarPosition.bottom => viewportBottomAppBarTotalHeight, TabBarPosition.left || TabBarPosition.right => 0.0, }; @@ -1348,7 +1379,7 @@ class BrowserScreen extends HookConsumerWidget { // When visible/dismissed or overridden by keyboard/loading: no clipping. final hiddenToolbarClippingPx = switch (tabBarPosition) { TabBarPosition.top || TabBarPosition.bottom => - -(bottomAppBarTotalHeight * pixelRatio).round(), + -(viewportBottomAppBarTotalHeight * pixelRatio).round(), TabBarPosition.left || TabBarPosition.right => 0, }; final targetClippingPx = @@ -1402,7 +1433,7 @@ class BrowserScreen extends HookConsumerWidget { keyboardVisible, tabIsLoading, tabInFullScreen, - bottomAppBarTotalHeight, + viewportBottomAppBarTotalHeight, pixelRatio, selectedTabId, tabBarPosition, @@ -1601,6 +1632,12 @@ class _SheetContainer extends HookConsumerWidget { required this.bottomAppBarHeight, }); + /// How far the scrim is allowed to extend *under* the sheet. The sheets round + /// their top corners (radius 28), and the page shows through those cutouts — + /// so the scrim has to reach a little past the sheet's top edge or the + /// corners would reveal unscrimmed content. + static const _sheetCornerOverlap = 32.0; + @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; @@ -1608,7 +1645,29 @@ class _SheetContainer extends HookConsumerWidget { Theme.of(context).bottomSheetTheme.modalBarrierColor ?? colorScheme.scrim.withValues(alpha: 0.5); - bool dismissOnThreshold(DraggableScrollableNotification notification) { + final (sheet, initialExtent) = switch (displayedSheet) { + ViewTabsSheet() => ( + _ViewTabsSheet(maxChildSize: relativeSafeArea), + _ViewTabsSheet.initialHeight, + ), + final SiteSettingsSheet parameter => ( + _SiteSettingsSheet( + initialTabState: parameter.tabState, + maxChildSize: relativeSafeArea, + bottomAppBarHeight: bottomAppBarHeight, + ), + _SiteSettingsSheet.initialHeight, + ), + }; + + // Drives the scrim's height. Seeded with the sheet's initial extent so the + // very first frame is already clipped correctly, then tracked from the + // sheet's own drag notifications. + final sheetExtent = useValueNotifier(initialExtent); + + bool onSheetNotification(DraggableScrollableNotification notification) { + sheetExtent.value = notification.extent; + if (notification.extent <= 0.1) { logger.i('Dismissing sheet, reached min extend'); ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); @@ -1617,36 +1676,61 @@ class _SheetContainer extends HookConsumerWidget { return false; } - return GestureDetector( - onTap: () { - // Dismiss sheet when tapping outside - ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); + return LayoutBuilder( + builder: (context, constraints) { + final available = constraints.maxHeight; + + return Stack( + children: [ + // Scrim. Hit-testing stays full-area (opaque) so tapping anywhere + // the sheet doesn't occupy still dismisses — including beside a + // width-constrained sheet — but the *paint* is clipped to the band + // above the sheet. Under hybrid composition every pixel painted + // over the platform view goes through an overlay surface, and once + // the tab sheet settles near full extent almost all of a + // full-screen scrim is hidden behind it anyway. + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + ref + .read(bottomSheetControllerProvider.notifier) + .requestDismiss(); + }, + child: ValueListenableBuilder( + valueListenable: sheetExtent, + builder: (context, extent, _) { + final scrimHeight = + (available * (1.0 - extent) + _sheetCornerOverlap) + .clamp(0.0, available); + + return Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: scrimHeight, + width: double.infinity, + child: ColoredBox(color: modalBarrierColor), + ), + ); + }, + ), + ), + ), + + // The sheet itself sits above the scrim, so taps on it hit its own + // widgets and taps above it fall through to the scrim's detector. + Positioned.fill( + child: Align( + alignment: Alignment.bottomCenter, + child: NotificationListener( + onNotification: onSheetNotification, + child: sheet, + ), + ), + ), + ], + ); }, - child: ColoredBox( - color: modalBarrierColor, - child: GestureDetector( - onTap: () {}, // Prevent tap from propagating to parent - child: Align( - alignment: Alignment.bottomCenter, - child: switch (displayedSheet) { - ViewTabsSheet() => - NotificationListener( - onNotification: dismissOnThreshold, - child: _ViewTabsSheet(maxChildSize: relativeSafeArea), - ), - final SiteSettingsSheet parameter => - NotificationListener( - onNotification: dismissOnThreshold, - child: _SiteSettingsSheet( - initialTabState: parameter.tabState, - maxChildSize: relativeSafeArea, - bottomAppBarHeight: bottomAppBarHeight, - ), - ), - }, - ), - ), - ), ); } } @@ -1810,7 +1894,9 @@ class _Browser extends HookConsumerWidget { .toggleReaderView(false); return true; - } else if (tabState?.historyState.canGoBack == true) { + } else if (ref + .read(tabHistoryStateProvider(tabState?.id)) + .canGoBack) { lastBackButtonPress.value = null; final controller = ref.read(selectedTabSessionProvider); @@ -2036,6 +2122,10 @@ class _SiteSettingsSheet extends HookConsumerWidget { class _ViewTabsSheet extends HookConsumerWidget { final double maxChildSize; + /// Matches [DraggableScrollableSheet]'s default, made explicit so the + /// scrim in [_SheetContainer] can seed its clip from it. + static const initialHeight = 0.5; + const _ViewTabsSheet({this.maxChildSize = 1.0}); @override @@ -2061,6 +2151,7 @@ class _ViewTabsSheet extends HookConsumerWidget { key: ValueKey(tabsReorderable), controller: draggableScrollableController, expand: false, + initialChildSize: initialHeight, minChildSize: 0.1, maxChildSize: maxChildSize, builder: (context, scrollController) { diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index 5b69e0b1..e0c8ff6c 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -20,7 +20,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:ui' as ui; import 'package:fading_scroll/fading_scroll.dart'; import 'package:fast_equatable/fast_equatable.dart'; @@ -46,6 +45,7 @@ import 'package:weblibre/features/geckoview/domain/entities/tab_container_select import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; @@ -70,6 +70,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart'; import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart'; +import 'package:weblibre/features/geckoview/utils/image_helper.dart'; import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart'; @@ -248,9 +249,7 @@ class _NavigationRow extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final history = ref.watch( - tabStateProvider(selectedTabId).select((value) => value?.historyState), - ); + final history = ref.watch(tabHistoryStateProvider(selectedTabId)); final host = ref.watch( tabStateProvider(selectedTabId).select((value) => value?.url.host), ); @@ -280,7 +279,7 @@ class _NavigationRow extends HookConsumerWidget { child: _buildNavIcon( icon: isLoading ? Icons.close : Icons.arrow_back, label: isLoading ? 'Stop' : 'Back', - disabled: !isLoading && history?.canGoBack != true, + disabled: !isLoading && !history.canGoBack, onTap: () async { final controller = ref.read( tabSessionProvider(tabId: selectedTabId).notifier, @@ -296,7 +295,7 @@ class _NavigationRow extends HookConsumerWidget { } if (context.mounted) Navigator.pop(context); }, - onLongPress: isLoading || history?.canGoBack != true + onLongPress: isLoading || !history.canGoBack ? null : () { if (backMenuController.isOpen) { @@ -314,14 +313,14 @@ class _NavigationRow extends HookConsumerWidget { child: _buildNavIcon( icon: Icons.arrow_forward, label: 'Forward', - disabled: history?.canGoForward != true, + disabled: !history.canGoForward, onTap: () async { await ref .read(tabSessionProvider(tabId: selectedTabId).notifier) .goForward(); if (context.mounted) Navigator.pop(context); }, - onLongPress: history?.canGoForward != true + onLongPress: !history.canGoForward ? null : () { if (forwardMenuController.isOpen) { @@ -899,7 +898,7 @@ class _TranslatePageTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final engineState = ref.watch(translationEngineStateProvider); final translationState = ref.watch( - tabStateProvider(selectedTabId).select((s) => s?.translationState), + tabTranslationStateProvider(selectedTabId), ); final readerActive = ref.watch( tabStateProvider( @@ -912,7 +911,7 @@ class _TranslatePageTile extends ConsumerWidget { return const SizedBox.shrink(); } - final isTranslated = translationState?.isTranslated ?? false; + final isTranslated = translationState.isTranslated; return Column( children: [ @@ -1470,29 +1469,15 @@ class _ShareExpansion extends HookConsumerWidget { final ts = ref.read(tabStateProvider(selectedTabId))!; if (screenshot != null) { - ui.decodeImageFromList(screenshot, (result) async { - try { - final png = await result.toByteData( - format: ui.ImageByteFormat.png, - ); + final png = await encodeScreenshotAsPng(screenshot); - if (png != null) { - final file = XFile.fromData( - png.buffer.asUint8List(), - mimeType: 'image/png', - ); + if (png != null) { + final file = XFile.fromData(png, mimeType: 'image/png'); - await SharePlus.instance.share( - ShareParams( - files: [file], - subject: ts.titleOrAuthority, - ), - ); - } - } finally { - result.dispose(); - } - }); + await SharePlus.instance.share( + ShareParams(files: [file], subject: ts.titleOrAuthority), + ); + } } if (context.mounted) Navigator.pop(context); @@ -1771,24 +1756,16 @@ class _ExportExpansion extends ConsumerWidget { final ts = ref.read(tabStateProvider(selectedTabId))!; if (screenshot != null) { - ui.decodeImageFromList(screenshot, (result) async { - try { - final png = await result.toByteData( - format: ui.ImageByteFormat.png, - ); + final png = await encodeScreenshotAsPng(screenshot); - if (png != null) { - await FilePicker.saveFile( - fileName: '${ts.titleOrAuthority}.png', - type: FileType.custom, - allowedExtensions: ['png'], - bytes: png.buffer.asUint8List(), - ); - } - } finally { - result.dispose(); - } - }); + if (png != null) { + await FilePicker.saveFile( + fileName: '${ts.titleOrAuthority}.png', + type: FileType.custom, + allowedExtensions: ['png'], + bytes: png, + ); + } } if (context.mounted) Navigator.pop(context); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 2043475d..1fcf724f 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -815,8 +815,7 @@ class QuickTabSwitcher extends HookConsumerWidget { ) .value : null; - final sandboxCaptureMap = - ref.watch(sandboxCaptureMapProvider).value ?? const {}; + final sandboxSourceUris = ref.watch(sandboxSourceUrisProvider).value; // Reorder is only meaningful when the bar renders the user's actual tab // order (containerTabs). Other modes (lastUsedTabs / MRU) sort by recency, // so dragging would just snap back on the next tab switch. @@ -870,9 +869,7 @@ class QuickTabSwitcher extends HookConsumerWidget { selectedTabId: selectedTabId, pinnedTabIds: pinnedTabIds, tabDepthById: tabDepthById, - sandboxSourceUri: parseSandboxSource( - sandboxCaptureMap[state.$1.id], - ), + sandboxSourceUri: sandboxSourceUris[state.$1.id], isPlaceholder: !restoreComplete && !nativeTabIds.contains(state.$1.id), ), 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 4c9e09db..76d2f4b8 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 @@ -34,6 +34,7 @@ import 'package:weblibre/features/app_links/domain/services/app_link_policy_repl import 'package:weblibre/features/bangs/data/models/web_search_bang.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart'; @@ -122,6 +123,15 @@ class _BrowserViewState extends ConsumerState return; } + // In-tree sheets (tab tray, site settings, …) are not routes — they are + // Stack layers driven by [bottomSheetControllerProvider] — so the route + // check above does not catch them. They occlude the browser just the same, + // and the resulting thumbnail event would rebuild the tray that is on + // screen, so skip the capture while one is displayed. + if (ref.read(bottomSheetControllerProvider) != null) { + return; + } + await ref .read(selectedTabSessionProvider) .requestScreenshot(requireImageResult: false) diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/history_menu.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/history_menu.dart index 637fbcfc..915850ec 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/history_menu.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/history_menu.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart'; @@ -42,9 +43,7 @@ class HistoryMenu extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final historyState = ref.watch( - tabStateProvider(selectedTabId).select((state) => state?.historyState), - ); + final historyState = ref.watch(tabHistoryStateProvider(selectedTabId)); final menuItems = useMemoized(() => _buildMenuItems(historyState), [ historyState, @@ -58,8 +57,8 @@ class HistoryMenu extends HookConsumerWidget { ); } - List _buildMenuItems(HistoryState? historyState) { - if (historyState == null || historyState.items.isEmpty) { + List _buildMenuItems(HistoryState historyState) { + if (historyState.items.isEmpty) { return [ MenuItemButton( child: Text( diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart index 4793c9e4..31d593fe 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart @@ -17,7 +17,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'dart:ui' as ui; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -35,6 +34,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/dialog import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/utils/image_helper.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; @@ -235,29 +235,15 @@ class ShareScreenshotMenuItemButton extends HookConsumerWidget { final tabState = ref.read(tabStateProvider(selectedTabId))!; if (screenshot != null) { - ui.decodeImageFromList(screenshot, (result) async { - try { - final png = await result.toByteData( - format: ui.ImageByteFormat.png, - ); + final png = await encodeScreenshotAsPng(screenshot); - if (png != null) { - final file = XFile.fromData( - png.buffer.asUint8List(), - mimeType: 'image/png', - ); + if (png != null) { + final file = XFile.fromData(png, mimeType: 'image/png'); - await SharePlus.instance.share( - ShareParams( - files: [file], - subject: tabState.titleOrAuthority, - ), - ); - } - } finally { - result.dispose(); - } - }); + await SharePlus.instance.share( + ShareParams(files: [file], subject: tabState.titleOrAuthority), + ); + } } if (context.mounted) { @@ -290,24 +276,16 @@ class ExportScreenshotMenuItemButton extends HookConsumerWidget { final tabState = ref.read(tabStateProvider(selectedTabId))!; if (screenshot != null) { - ui.decodeImageFromList(screenshot, (result) async { - try { - final png = await result.toByteData( - format: ui.ImageByteFormat.png, - ); + final png = await encodeScreenshotAsPng(screenshot); - if (png != null) { - await FilePicker.saveFile( - fileName: '${tabState.titleOrAuthority}.png', - type: FileType.custom, - allowedExtensions: ['png'], - bytes: png.buffer.asUint8List(), - ); - } - } finally { - result.dispose(); - } - }); + if (png != null) { + await FilePicker.saveFile( + fileName: '${tabState.titleOrAuthority}.png', + type: FileType.custom, + allowedExtensions: ['png'], + bytes: png, + ); + } } if (context.mounted) { diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart index 1ffaf4ec..7add8fd7 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart @@ -19,7 +19,6 @@ */ import 'dart:async'; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -37,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/domain/serv import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; +import 'package:weblibre/features/geckoview/utils/image_helper.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; @@ -164,29 +164,15 @@ class ShareBottomSheet extends HookConsumerWidget { final ts = ref.read(tabStateProvider(selectedTabId))!; if (screenshot != null) { - ui.decodeImageFromList(screenshot, (result) async { - try { - final png = await result.toByteData( - format: ui.ImageByteFormat.png, - ); + final png = await encodeScreenshotAsPng(screenshot); - if (png != null) { - final file = XFile.fromData( - png.buffer.asUint8List(), - mimeType: 'image/png', - ); + if (png != null) { + final file = XFile.fromData(png, mimeType: 'image/png'); - await SharePlus.instance.share( - ShareParams( - files: [file], - subject: ts.titleOrAuthority, - ), - ); - } - } finally { - result.dispose(); - } - }); + await SharePlus.instance.share( + ShareParams(files: [file], subject: ts.titleOrAuthority), + ); + } } if (context.mounted) Navigator.pop(context); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index 2febd6f4..5170726c 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -33,6 +33,7 @@ import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; @@ -757,7 +758,7 @@ class _TranslatePageMenuItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final engineState = ref.watch(translationEngineStateProvider); final translationState = ref.watch( - tabStateProvider(selectedTabId).select((s) => s?.translationState), + tabTranslationStateProvider(selectedTabId), ); final readerActive = ref.watch( tabStateProvider( @@ -770,7 +771,7 @@ class _TranslatePageMenuItem extends ConsumerWidget { return const SizedBox.shrink(); } - final isTranslated = translationState?.isTranslated ?? false; + final isTranslated = translationState.isTranslated; return MenuItemButton( closeOnActivate: false, @@ -833,9 +834,7 @@ class _NavigationButtonsRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final history = ref.watch( - tabStateProvider(selectedTabId).select((value) => value?.historyState), - ); + final history = ref.watch(tabHistoryStateProvider(selectedTabId)); final isLoading = ref.watch( selectedTabStateProvider.select((state) => state?.isLoading ?? false), @@ -848,7 +847,7 @@ class _NavigationButtonsRow extends ConsumerWidget { selectedTabId: selectedTabId, isLoading: isLoading, menuControllerToClose: controller, - canGoBack: history?.canGoBack == true, + canGoBack: history.canGoBack, ), ), const SizedBox(height: 48, child: VerticalDivider()), @@ -856,7 +855,7 @@ class _NavigationButtonsRow extends ConsumerWidget { child: NavigateForwardButton( selectedTabId: selectedTabId, menuControllerToClose: controller, - canGoForward: history?.canGoForward == true, + canGoForward: history.canGoForward, ), ), ], diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index 528fc39e..ce90f3aa 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -25,6 +25,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.dart'; @@ -174,6 +175,8 @@ class GridTabPreview extends HookConsumerWidget { ) ?? TabState.$default(tabId); + final thumbnail = ref.watch(tabThumbnailProvider(tabId)); + final sandboxSourceUri = ref.watch( sandboxSourceUriForTabProvider(tabId: tabId), ); @@ -233,13 +236,9 @@ class GridTabPreview extends HookConsumerWidget { child: Stack( fit: StackFit.expand, children: [ - if (tabState.thumbnail != null && - !tabState.thumbnail!.isDisposed) + if (thumbnail != null && !thumbnail.isDisposed) RepaintBoundary( - child: SafeRawImage( - image: tabState.thumbnail, - fit: BoxFit.cover, - ), + child: SafeRawImage(image: thumbnail, fit: BoxFit.cover), ) else Center(child: TabIcon(tabState: tabState, iconSize: 48)), @@ -498,6 +497,12 @@ class ListTabPreview extends HookConsumerWidget { generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons), ); + // Only the leading tile renders the thumbnail, and only when favicons are + // off — don't subscribe to screenshot churn otherwise. + final thumbnail = tabListShowFavicons + ? null + : ref.watch(tabThumbnailProvider(tabId)); + final extendedDeleteMenuController = useMenuController(); // ignore: avoid_bool_literals_in_conditional_expressions @@ -509,7 +514,7 @@ class ListTabPreview extends HookConsumerWidget { ) : false; - final leadingWidget = switch ((tabListShowFavicons, tabState.thumbnail)) { + final leadingWidget = switch ((tabListShowFavicons, thumbnail)) { (false, final thumbnail?) when !thumbnail.isDisposed => ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8.0)), child: RepaintBoundary( @@ -799,7 +804,104 @@ class SingleGridTabPreview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final dragStartPosition = useRef(Offset.zero); - final draggedDistance = useState(0.0); + // A ValueNotifier rather than useState: the drag fires per pointer event, + // and useState would rebuild this whole subtree — thumbnail, favicon, + // title, menus, badges — inside a scrolling list on every one. Only the + // ValueListenableBuilder below reruns now. + final draggedDistance = useValueNotifier(0.0); + + // Built once per rebuild of *this* widget and handed to the builder as its + // `child`, so the drag never reconstructs it. + final preview = RepaintBoundary( + child: GridTabPreview( + tabId: tabId, + isActive: tabId == activeTabId, + showPinBadge: true, + groupToggle: groupToggle, + depth: depth, + onTap: () async { + if (tabId != activeTabId) { + // Offer to locate the match within the page instead of opening + // Find in Page unprompted (see #421). Shown before onClose so it + // surfaces on the root messenger and survives the tray closing. + final query = sourceSearchQuery; + if (query != null && + query.isNotEmpty && + ref.read(findInPageControllerProvider(tabId)) == + FindInPageState.hidden()) { + final findController = ref.read( + findInPageControllerProvider(tabId).notifier, + ); + ui_helper.showFindInPageSuggestion( + context, + query: query, + onFind: () => findController.findAll(text: query), + ); + } + + //Close first to avoid rebuilds + onClose(); + await ref.read(tabRepositoryProvider.notifier).selectTab(tabId); + } else { + onClose(); + } + }, + onDeleteAll: (host) async { + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .getTabContainerId(tabId); + + final count = await ref + .read(tabDataRepositoryProvider.notifier) + .closeAllTabsByHost(containerId, host); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + count: count, + ); + } + }, + onCloseSubtree: () async { + final subtreeIds = await ref + .read(tabDataRepositoryProvider.notifier) + .getTabDescendants(tabId) + .then((descendants) => descendants.keys.toList()); + if (!context.mounted) return; + + final didClose = await closeTabsWithConfirmation( + context, + ref, + subtreeIds, + ); + + if (context.mounted && didClose) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + count: subtreeIds.length, + ); + } + }, + onDelete: () async { + onBeforeDelete?.call(); + + if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) { + return; + } + + await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + ); + } + }, + ), + ); return GestureDetector( onHorizontalDragStart: (details) { @@ -807,12 +909,10 @@ class SingleGridTabPreview extends HookConsumerWidget { draggedDistance.value = 0.0; }, onHorizontalDragUpdate: (details) { - final cappedDistance = math.min( + draggedDistance.value = math.min( (dragStartPosition.value - details.globalPosition).dx.abs(), deleteThreshold, ); - - draggedDistance.value = cappedDistance; }, onHorizontalDragEnd: (details) async { if (draggedDistance.value >= deleteThreshold) { @@ -833,96 +933,14 @@ class SingleGridTabPreview extends HookConsumerWidget { draggedDistance.value = 0.0; }, - child: Opacity( - opacity: 1.0 - draggedDistance.value / deleteThreshold, - child: GridTabPreview( - tabId: tabId, - isActive: tabId == activeTabId, - showPinBadge: true, - groupToggle: groupToggle, - depth: depth, - onTap: () async { - if (tabId != activeTabId) { - // Offer to locate the match within the page instead of opening - // Find in Page unprompted (see #421). Shown before onClose so it - // surfaces on the root messenger and survives the tray closing. - final query = sourceSearchQuery; - if (query != null && - query.isNotEmpty && - ref.read(findInPageControllerProvider(tabId)) == - FindInPageState.hidden()) { - final findController = ref.read( - findInPageControllerProvider(tabId).notifier, - ); - ui_helper.showFindInPageSuggestion( - context, - query: query, - onFind: () => findController.findAll(text: query), - ); - } - - //Close first to avoid rebuilds - onClose(); - await ref.read(tabRepositoryProvider.notifier).selectTab(tabId); - } else { - onClose(); - } - }, - onDeleteAll: (host) async { - final containerId = await ref - .read(tabDataRepositoryProvider.notifier) - .getTabContainerId(tabId); - - final count = await ref - .read(tabDataRepositoryProvider.notifier) - .closeAllTabsByHost(containerId, host); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref.read(tabRepositoryProvider.notifier).undoClose, - count: count, - ); - } - }, - onCloseSubtree: () async { - final subtreeIds = await ref - .read(tabDataRepositoryProvider.notifier) - .getTabDescendants(tabId) - .then((descendants) => descendants.keys.toList()); - if (!context.mounted) return; - - final didClose = await closeTabsWithConfirmation( - context, - ref, - subtreeIds, - ); - - if (context.mounted && didClose) { - ui_helper.showTabUndoClose( - context, - ref.read(tabRepositoryProvider.notifier).undoClose, - count: subtreeIds.length, - ); - } - }, - onDelete: () async { - onBeforeDelete?.call(); - - if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) { - return; - } - - await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref.read(tabRepositoryProvider.notifier).undoClose, - ); - } - }, - ), + child: ValueListenableBuilder( + valueListenable: draggedDistance, + // Kept unconditionally (rather than skipping it at rest) so the + // element tree doesn't reshape on drag start/end. Opacity at 1.0 + // pushes no layer, so this costs nothing when not dragging. + builder: (context, distance, child) => + Opacity(opacity: 1.0 - distance / deleteThreshold, child: child), + child: preview, ), ); } @@ -955,7 +973,100 @@ class SingleListTabPreview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final dragStartPosition = useRef(Offset.zero); - final draggedDistance = useState(0.0); + // See [SingleGridTabPreview]: per-pointer-event drag updates must not + // rebuild the row's subtree inside a scrolling list. + final draggedDistance = useValueNotifier(0.0); + + final preview = RepaintBoundary( + child: ListTabPreview( + tabId: tabId, + isActive: tabId == activeTabId, + showPinBadge: true, + groupToggle: groupToggle, + depth: depth, + onTap: () async { + if (tabId != activeTabId) { + // Offer to locate the match within the page instead of opening + // Find in Page unprompted (see #421). Shown before onClose so it + // surfaces on the root messenger and survives the tray closing. + final query = sourceSearchQuery; + if (query != null && + query.isNotEmpty && + ref.read(findInPageControllerProvider(tabId)) == + FindInPageState.hidden()) { + final findController = ref.read( + findInPageControllerProvider(tabId).notifier, + ); + ui_helper.showFindInPageSuggestion( + context, + query: query, + onFind: () => findController.findAll(text: query), + ); + } + + //Close first to avoid rebuilds + onClose(); + await ref.read(tabRepositoryProvider.notifier).selectTab(tabId); + } else { + onClose(); + } + }, + onDeleteAll: (host) async { + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .getTabContainerId(tabId); + + final count = await ref + .read(tabDataRepositoryProvider.notifier) + .closeAllTabsByHost(containerId, host); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + count: count, + ); + } + }, + onCloseSubtree: () async { + final subtreeIds = await ref + .read(tabDataRepositoryProvider.notifier) + .getTabDescendants(tabId) + .then((descendants) => descendants.keys.toList()); + if (!context.mounted) return; + + final didClose = await closeTabsWithConfirmation( + context, + ref, + subtreeIds, + ); + + if (context.mounted && didClose) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + count: subtreeIds.length, + ); + } + }, + onDelete: () async { + onBeforeDelete?.call(); + + if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) { + return; + } + + await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + ); + } + }, + ), + ); return GestureDetector( onHorizontalDragStart: (details) { @@ -963,12 +1074,10 @@ class SingleListTabPreview extends HookConsumerWidget { draggedDistance.value = 0.0; }, onHorizontalDragUpdate: (details) { - final cappedDistance = math.min( + draggedDistance.value = math.min( (dragStartPosition.value - details.globalPosition).dx.abs(), deleteThreshold, ); - - draggedDistance.value = cappedDistance; }, onHorizontalDragEnd: (details) async { if (draggedDistance.value >= deleteThreshold) { @@ -989,96 +1098,11 @@ class SingleListTabPreview extends HookConsumerWidget { draggedDistance.value = 0.0; }, - child: Opacity( - opacity: 1.0 - draggedDistance.value / deleteThreshold, - child: ListTabPreview( - tabId: tabId, - isActive: tabId == activeTabId, - showPinBadge: true, - groupToggle: groupToggle, - depth: depth, - onTap: () async { - if (tabId != activeTabId) { - // Offer to locate the match within the page instead of opening - // Find in Page unprompted (see #421). Shown before onClose so it - // surfaces on the root messenger and survives the tray closing. - final query = sourceSearchQuery; - if (query != null && - query.isNotEmpty && - ref.read(findInPageControllerProvider(tabId)) == - FindInPageState.hidden()) { - final findController = ref.read( - findInPageControllerProvider(tabId).notifier, - ); - ui_helper.showFindInPageSuggestion( - context, - query: query, - onFind: () => findController.findAll(text: query), - ); - } - - //Close first to avoid rebuilds - onClose(); - await ref.read(tabRepositoryProvider.notifier).selectTab(tabId); - } else { - onClose(); - } - }, - onDeleteAll: (host) async { - final containerId = await ref - .read(tabDataRepositoryProvider.notifier) - .getTabContainerId(tabId); - - final count = await ref - .read(tabDataRepositoryProvider.notifier) - .closeAllTabsByHost(containerId, host); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref.read(tabRepositoryProvider.notifier).undoClose, - count: count, - ); - } - }, - onCloseSubtree: () async { - final subtreeIds = await ref - .read(tabDataRepositoryProvider.notifier) - .getTabDescendants(tabId) - .then((descendants) => descendants.keys.toList()); - if (!context.mounted) return; - - final didClose = await closeTabsWithConfirmation( - context, - ref, - subtreeIds, - ); - - if (context.mounted && didClose) { - ui_helper.showTabUndoClose( - context, - ref.read(tabRepositoryProvider.notifier).undoClose, - count: subtreeIds.length, - ); - } - }, - onDelete: () async { - onBeforeDelete?.call(); - - if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) { - return; - } - - await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref.read(tabRepositoryProvider.notifier).undoClose, - ); - } - }, - ), + child: ValueListenableBuilder( + valueListenable: draggedDistance, + builder: (context, distance, child) => + Opacity(opacity: 1.0 - distance / deleteThreshold, child: child), + child: preview, ), ); } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart index 3f7fb496..e464752a 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart @@ -26,6 +26,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -67,18 +68,19 @@ class TranslationBottomSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final tabState = ref.watch(tabStateProvider(selectedTabId)); - final translationState = tabState?.translationState; + final translationState = ref.watch( + tabTranslationStateProvider(selectedTabId), + ); final engineState = ref.watch(translationEngineStateProvider); final fromLanguages = engineState?.fromLanguages?.nonNulls.toList() ?? []; final toLanguages = engineState?.toLanguages?.nonNulls.toList() ?? []; final suggestedFrom = - translationState?.detectedLanguageCode ?? - translationState?.requestedFromLanguage; + translationState.detectedLanguageCode ?? + translationState.requestedFromLanguage; final suggestedTo = - translationState?.userPreferredLanguageCode ?? - translationState?.requestedToLanguage; + translationState.userPreferredLanguageCode ?? + translationState.requestedToLanguage; final selectedFrom = useState(suggestedFrom); final selectedTo = useState(suggestedTo); @@ -93,10 +95,10 @@ class TranslationBottomSheet extends HookConsumerWidget { return null; }, [suggestedFrom, suggestedTo]); - final isTranslated = translationState?.isTranslated ?? false; - final isProcessing = translationState?.isTranslateProcessing ?? false; - final hasError = translationState?.hasError ?? false; - final errorName = translationState?.translationErrorName; + final isTranslated = translationState.isTranslated; + final isProcessing = translationState.isTranslateProcessing; + final hasError = translationState.hasError; + final errorName = translationState.translationErrorName; final translatePhase = useState(_TranslatePhase.idle); final effectiveProcessing = diff --git a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart index 8e6ee561..b219554d 100644 --- a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart +++ b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart @@ -21,6 +21,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; @@ -49,8 +50,10 @@ class FindInPageController extends _$FindInPageController { Future findNext({required String fallbackText, bool forward = true}) { final service = ref.read(findInPageRepositoryProvider(tabId).notifier); - final hasMatches = - ref.read(tabStatesProvider)[tabId]?.findResultState.hasMatches == true; + final hasMatches = ref + .read(tabFindResultStatesProvider.notifier) + .resultFor(tabId) + .hasMatches; state = state.copyWith.visible(true); diff --git a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart index 81661085..f181aa23 100644 --- a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.g.dart @@ -59,7 +59,7 @@ final class FindInPageControllerProvider } String _$findInPageControllerHash() => - r'21ca6178016dc141fcfcb71afc158d9b8eed0a7e'; + r'd2b90ef0c6096e04ad4067f48fb5e5170268446b'; final class FindInPageControllerFamily extends $Family with diff --git a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart index 19cdf351..ca09f875 100644 --- a/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart +++ b/apps/weblibre/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart @@ -20,6 +20,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; @@ -41,15 +42,13 @@ class FindInPageWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final findInPageState = ref.watch(findInPageControllerProvider(tabId)); - final searchResult = ref.watch( - selectedTabStateProvider.select((state) => state?.findResultState), - ); + final searchResult = ref.watch(tabFindResultStateProvider(tabId)); final privateTabMode = ref.watch(tabStateProvider(tabId))?.tabMode == TabMode.private; final focusNode = useFocusNode(); final textController = useTextEditingController( - text: searchResult?.lastSearchText ?? findInPageState.lastSearchText, + text: searchResult.lastSearchText ?? findInPageState.lastSearchText, ); // Sync text field when the find-in-page query is set externally (e.g., @@ -88,7 +87,7 @@ class FindInPageWidget extends HookConsumerWidget { } return Visibility( - visible: findInPageState.visible || searchResult?.hasMatches == true, + visible: findInPageState.visible || searchResult.hasMatches, child: Padding( padding: padding, child: Material( @@ -125,7 +124,7 @@ class FindInPageWidget extends HookConsumerWidget { ), ), Text( - (searchResult != null && searchResult.hasMatches) + searchResult.hasMatches ? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}' : 'Not found', ), diff --git a/apps/weblibre/lib/features/geckoview/utils/image_helper.dart b/apps/weblibre/lib/features/geckoview/utils/image_helper.dart index 22c97638..87cb98c4 100644 --- a/apps/weblibre/lib/features/geckoview/utils/image_helper.dart +++ b/apps/weblibre/lib/features/geckoview/utils/image_helper.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'dart:convert'; +import 'dart:isolate'; import 'dart:math'; import 'dart:typed_data'; import 'dart:ui'; @@ -28,7 +29,7 @@ import 'package:weblibre/core/logger.dart'; import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/utils/lru_cache.dart'; -final _cache = LRUCache(100); +final _cache = LRUCache(100); const _defaultSvgIconSize = 32; /// Clears the global image decode cache. @@ -42,13 +43,23 @@ Future tryDecodeImage( int? targetHeight, bool allowUpscaling = true, }) async { - final digest = secureHash(bytes); + // The decode options are part of the identity of the result, not just of the + // request: the same bytes decoded at a thumbnail's target width and at an + // icon's native size are different images. Keying on the content digest + // alone would both hand back a wrongly-sized image to whichever caller ran + // second, and make two genuinely different decodes compare equal. + final identity = ( + digest: secureHash(bytes), + targetWidth: targetWidth, + targetHeight: targetHeight, + allowUpscaling: allowUpscaling, + ); - final cached = _cache.get(digest); + final cached = _cache.get(identity); if (cached?.value != null) { return cached; } else if (cached != null) { - _cache.remove(digest); + _cache.remove(identity); } try { @@ -60,10 +71,10 @@ Future tryDecodeImage( ); final frameInfo = await codec.getNextFrame(); - final image = EquatableImage(frameInfo.image, hash: digest); + final image = EquatableImage(frameInfo.image, identity: identity); if (image.value != null && image.value!.width > 0) { - _cache.set(digest, image); + _cache.set(identity, image); return image; } } catch (e, s) { @@ -71,12 +82,12 @@ Future tryDecodeImage( try { final svgImage = await _tryDecodeSvg( bytes, - hash: digest, + identity: identity, targetWidth: targetWidth, targetHeight: targetHeight, ); if (svgImage != null) { - _cache.set(digest, svgImage); + _cache.set(identity, svgImage); return svgImage; } } catch (svgError, svgStackTrace) { @@ -94,6 +105,46 @@ Future tryDecodeImage( return null; } +/// Transcodes a native screenshot (delivered as WebP) to PNG off the main +/// isolate. +/// +/// Decoding a full-resolution screenshot and re-encoding it as PNG costs tens +/// of milliseconds on the UI thread, and every caller does it right as a share +/// sheet or file picker is animating in — so the cost lands as a visible hitch. +/// dart:ui's codec APIs are usable from background isolates, so the whole +/// transcode runs there; if that ever fails we fall back to doing it inline +/// rather than losing the feature. +Future encodeScreenshotAsPng(Uint8List screenshot) async { + try { + return await Isolate.run(() => _transcodeToPng(screenshot)); + } catch (e, s) { + logger.w( + 'Screenshot PNG transcode failed in background isolate, retrying inline', + error: e, + stackTrace: s, + ); + + return _transcodeToPng(screenshot); + } +} + +Future _transcodeToPng(Uint8List bytes) async { + final codec = await instantiateImageCodec(bytes); + + try { + final frameInfo = await codec.getNextFrame(); + + try { + final png = await frameInfo.image.toByteData(format: ImageByteFormat.png); + return png?.buffer.asUint8List(); + } finally { + frameInfo.image.dispose(); + } + } finally { + codec.dispose(); + } +} + bool _isLikelySvg(Uint8List bytes) { final prefix = utf8 .decode(bytes.sublist(0, min(bytes.length, 512)), allowMalformed: true) @@ -110,7 +161,7 @@ bool _isLikelySvg(Uint8List bytes) { Future _tryDecodeSvg( Uint8List bytes, { - required int hash, + required ImageIdentity identity, int? targetWidth, int? targetHeight, }) async { @@ -153,7 +204,7 @@ Future _tryDecodeSvg( dimensions.width, dimensions.height, ); - return EquatableImage(rasterized, hash: hash); + return EquatableImage(rasterized, identity: identity); } finally { scaledPicture.dispose(); } diff --git a/apps/weblibre/lib/features/proxy/domain/repositories/singbox_proxy_logs.dart b/apps/weblibre/lib/features/proxy/domain/repositories/singbox_proxy_logs.dart index d03ed260..d11ad0c2 100644 --- a/apps/weblibre/lib/features/proxy/domain/repositories/singbox_proxy_logs.dart +++ b/apps/weblibre/lib/features/proxy/domain/repositories/singbox_proxy_logs.dart @@ -33,7 +33,20 @@ part 'singbox_proxy_logs.g.dart'; /// is well within budget for a debugging surface. const int _ringBufferCapacity = 2000; +/// Lower bound between two published snapshots. A chatty proxy can emit lines +/// far faster than the screen can render them, and every publication copies the +/// whole ring buffer, so coalescing bursts is free in terms of what the user +/// actually sees. +const _publishInterval = Duration(milliseconds: 100); + /// Snapshot of buffered log entries. Most-recent-last (chronological). +/// +/// This notifier is `keepAlive` and subscribed from app start (see +/// `main.dart`) so startup messages are retained even before any UI mounts. +/// Appending and *publishing* are therefore deliberately decoupled: lines +/// always land in [_buffer], but a new immutable snapshot is only produced +/// while the log screen is on screen ([setLivePublishing]) and at most once +/// per [_publishInterval]. @Riverpod(keepAlive: true) class SingboxProxyLogs extends _$SingboxProxyLogs { final _buffer = Queue(); @@ -41,6 +54,28 @@ class SingboxProxyLogs extends _$SingboxProxyLogs { StreamSubscription? _singboxSubscription; StreamSubscription? _torSubscription; + Timer? _publishTimer; + bool _livePublishing = false; + + /// Buffer contents materialized on demand, regardless of publication state. + /// Lets a freshly mounted screen paint the backlog without waiting for the + /// first throttled snapshot. + List get snapshot => List.unmodifiable(_buffer); + + /// Enables/disables snapshot publication. Called by the log screen as it + /// mounts and unmounts; flushes on both edges so the state left behind is + /// always complete. + void setLivePublishing(bool enabled) { + if (_livePublishing == enabled) { + return; + } + + _livePublishing = enabled; + _publishTimer?.cancel(); + _publishTimer = null; + _publish(); + } + void _append(ProxyLogMessage message) { _buffer.add(message); @@ -48,11 +83,27 @@ class SingboxProxyLogs extends _$SingboxProxyLogs { _buffer.removeFirst(); } - state = List.unmodifiable(_buffer); + if (!_livePublishing || (_publishTimer?.isActive ?? false)) { + return; + } + + _publishTimer = Timer(_publishInterval, _publish); + } + + void _publish() { + _publishTimer = null; + + if (!ref.mounted) { + return; + } + + state = snapshot; } void clear() { _buffer.clear(); + _publishTimer?.cancel(); + _publishTimer = null; state = const []; } @@ -69,10 +120,11 @@ class SingboxProxyLogs extends _$SingboxProxyLogs { ); ref.onDispose(() { + _publishTimer?.cancel(); unawaited(_singboxSubscription?.cancel()); unawaited(_torSubscription?.cancel()); }); - return List.unmodifiable(_buffer); + return snapshot; } } diff --git a/apps/weblibre/lib/features/proxy/presentation/screens/singbox_proxy_logs.dart b/apps/weblibre/lib/features/proxy/presentation/screens/singbox_proxy_logs.dart index dfce4e7c..3169ca56 100644 --- a/apps/weblibre/lib/features/proxy/presentation/screens/singbox_proxy_logs.dart +++ b/apps/weblibre/lib/features/proxy/presentation/screens/singbox_proxy_logs.dart @@ -33,7 +33,26 @@ class SingboxProxyLogsScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final logs = ref.watch(singboxProxyLogsProvider); + // The ring buffer fills from app start but only publishes snapshots while + // this screen is up, so opt in for as long as we're mounted. Seeded from + // the notifier's live buffer during the first build, so the backlog is + // painted immediately rather than after the first published snapshot. + final logsState = useState( + useMemoized(() => ref.read(singboxProxyLogsProvider.notifier).snapshot), + ); + + useEffect(() { + final notifier = ref.read(singboxProxyLogsProvider.notifier); + notifier.setLivePublishing(true); + + return () => notifier.setLivePublishing(false); + }, []); + + ref.listen(singboxProxyLogsProvider, (previous, next) { + logsState.value = next; + }); + + final logs = logsState.value; final filter = useState(null); final autoScroll = useState(true); final scrollController = useScrollController(); @@ -71,9 +90,14 @@ class SingboxProxyLogsScreen extends HookConsumerWidget { return () => scrollController.removeListener(onScroll); }, [scrollController]); - final filtered = filter.value == null - ? logs - : logs.where((m) => m.level.toLowerCase() == filter.value).toList(); + // Memoized so scroll-driven rebuilds (autoScroll flipping) don't re-scan + // up to 2000 entries; only a new snapshot or a filter change does. + final filtered = useMemoized( + () => filter.value == null + ? logs + : logs.where((m) => m.level.toLowerCase() == filter.value).toList(), + [logs, filter.value], + ); return Scaffold( appBar: AppBar( diff --git a/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.dart b/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.dart index 15d4e982..4870b442 100644 --- a/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.dart +++ b/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' as fmc; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -76,6 +77,26 @@ CaptureTabData? sandboxCaptureForTab(Ref ref, {required String? tabId}) { return map?[tabId]; } +/// Canonical source URLs of sandbox-captured tabs, keyed by tabId. +/// +/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab +/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on +/// any write to that table — including ones that touch columns nothing on +/// screen renders. Consumers that build a tab-keyed list and only need the +/// source URL watch this instead, so an unrelated write can't rebuild the +/// always-visible quick tab switcher or the tab-preview list. +@Riverpod(keepAlive: true) +EquatableValue> sandboxSourceUris(Ref ref) { + final rows = ref.watch(sandboxCaptureMapProvider).value ?? const {}; + + // Equatable result, so a re-emit that doesn't change any source URL stops + // here instead of propagating. + return EquatableValue({ + for (final MapEntry(:key, :value) in rows.entries) + if (parseSandboxSource(value) case final uri?) key: uri, + }); +} + /// The canonical source URL of a sandbox-captured tab, or `null` when the /// tab is not a sandbox capture (the regular `tabState.url` should be used in /// that case). diff --git a/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.g.dart b/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.g.dart index 8fd9b3d0..c175dddb 100644 --- a/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.g.dart +++ b/apps/weblibre/lib/features/web_search/domain/controllers/sandbox_capture_controller.g.dart @@ -188,6 +188,81 @@ final class SandboxCaptureForTabFamily extends $Family String toString() => r'sandboxCaptureForTabProvider'; } +/// Canonical source URLs of sandbox-captured tabs, keyed by tabId. +/// +/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab +/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on +/// any write to that table — including ones that touch columns nothing on +/// screen renders. Consumers that build a tab-keyed list and only need the +/// source URL watch this instead, so an unrelated write can't rebuild the +/// always-visible quick tab switcher or the tab-preview list. + +@ProviderFor(sandboxSourceUris) +final sandboxSourceUrisProvider = SandboxSourceUrisProvider._(); + +/// Canonical source URLs of sandbox-captured tabs, keyed by tabId. +/// +/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab +/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on +/// any write to that table — including ones that touch columns nothing on +/// screen renders. Consumers that build a tab-keyed list and only need the +/// source URL watch this instead, so an unrelated write can't rebuild the +/// always-visible quick tab switcher or the tab-preview list. + +final class SandboxSourceUrisProvider + extends + $FunctionalProvider< + EquatableValue>, + EquatableValue>, + EquatableValue> + > + with $Provider>> { + /// Canonical source URLs of sandbox-captured tabs, keyed by tabId. + /// + /// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab + /// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on + /// any write to that table — including ones that touch columns nothing on + /// screen renders. Consumers that build a tab-keyed list and only need the + /// source URL watch this instead, so an unrelated write can't rebuild the + /// always-visible quick tab switcher or the tab-preview list. + SandboxSourceUrisProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'sandboxSourceUrisProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$sandboxSourceUrisHash(); + + @$internal + @override + $ProviderElement>> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + EquatableValue> create(Ref ref) { + return sandboxSourceUris(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EquatableValue> value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>>( + value, + ), + ); + } +} + +String _$sandboxSourceUrisHash() => r'2d04e6e0e79c5b79e640386fe29e101fdd64688d'; + /// The canonical source URL of a sandbox-captured tab, or `null` when the /// tab is not a sandbox capture (the regular `tabState.url` should be used in /// that case). diff --git a/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart b/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart index 0bf331aa..1490eec0 100644 --- a/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart +++ b/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart @@ -3,30 +3,106 @@ import 'dart:typed_data'; import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/features/geckoview/utils/image_helper.dart'; void main() { testWidgets('tryDecodeImage rasterizes svg bytes at favicon size', ( tester, ) async { - clearImageCache(); - final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon)); + // Must run outside the fake-async zone: flutter_svg's loader parses the + // document via `compute()`, i.e. a real isolate, and that future never + // completes while `testWidgets` controls the clock — the test would hang + // forever rather than fail. + await tester.runAsync(() async { + clearImageCache(); + final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon)); - final image = await tryDecodeImage(svgBytes); + final image = await tryDecodeImage(svgBytes); - expect(image, isNotNull); - expect(image!.value, isNotNull); - expect(image.value!.width, 32); - expect(image.value!.height, 32); + expect(image, isNotNull); + expect(image!.value, isNotNull); + expect(image.value!.width, 32); + expect(image.value!.height, 32); - final byteData = await image.value!.toByteData( - format: ImageByteFormat.rawRgba, + final byteData = await image.value!.toByteData( + format: ImageByteFormat.rawRgba, + ); + expect(byteData, isNotNull); + + // A pixel in the right half should be painted once the SVG is scaled + // to the requested raster size instead of being left in the top-left. + expect(_rgbaAt(byteData!, width: 32, x: 24, y: 16), [47, 128, 237, 255]); + }); + }); + + testWidgets('tryDecodeImage does not serve a cached decode across sizes', ( + tester, + ) async { + await tester.runAsync(() async { + clearImageCache(); + final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon)); + + // Same bytes, different decode options. The cache is keyed on the digest + // *and* the options, so the second call must not be answered with the + // first call's differently-sized image. + final sized = await tryDecodeImage(svgBytes, targetWidth: 64); + expect(sized?.value?.width, 64); + expect(sized?.value?.height, 64); + + final unsized = await tryDecodeImage(svgBytes); + expect(unsized?.value?.width, 32); + expect(unsized?.value?.height, 32); + + // Reversed order, to catch a cache that only poisons one direction. + clearImageCache(); + + final unsizedFirst = await tryDecodeImage(svgBytes); + expect(unsizedFirst?.value?.width, 32); + + final sizedSecond = await tryDecodeImage(svgBytes, targetWidth: 64); + expect(sizedSecond?.value?.width, 64); + + // Two decodes that differ only in size must not compare equal, or the + // `state[id] == next` guards in the tab-state notifiers would drop a + // genuine change. + expect(sizedSecond, isNot(equals(unsizedFirst))); + + // ...while a repeat of the *same* request is still deduped. + final unsizedAgain = await tryDecodeImage(svgBytes); + expect(unsizedAgain, equals(unsizedFirst)); + expect(identical(unsizedAgain, unsizedFirst), isTrue); + }); + }); + + test('ImageIdentity compares structurally, not by a folded hash', () { + const a = ( + digest: 0x0123456789ABCDEF, + targetWidth: 720, + targetHeight: null, + allowUpscaling: false, + ); + // Differs only in the low bits of the 64-bit content digest. Folding the + // identity into a single hash code would truncate to 30 bits and could + // collapse these two onto the same cache entry; structural equality can't. + const b = ( + digest: 0x0123456789ABCDEE, + targetWidth: 720, + targetHeight: null, + allowUpscaling: false, ); - expect(byteData, isNotNull); - // A pixel in the right half should be painted once the SVG is scaled - // to the requested raster size instead of being left in the top-left. - expect(_rgbaAt(byteData!, width: 32, x: 24, y: 16), [47, 128, 237, 255]); + expect(a, isNot(equals(b))); + expect({a: 'a', b: 'b'}.length, 2); + + // Options are part of the key too. + const sameDigestOtherWidth = ( + digest: 0x0123456789ABCDEF, + targetWidth: 32, + targetHeight: null, + allowUpscaling: false, + ); + expect(a, isNot(equals(sameDigestOtherWidth))); }); }