From 47d0ec86f5826a703a1f853680340ec88e972f6f Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 14:11:35 +0200 Subject: [PATCH 1/7] feat(ui): redesign the desktop screen share picker Rebuilds the picker on the design system's modal dialog, and stops it re-enumerating the platform's screens and windows every two seconds. The old picker ran a `Timer.periodic(2s)` calling `updateSources()` for as long as it was open, which made the platform re-capture a full-resolution bitmap of every screen and window, rebuild the whole dialog per thumbnail event, and re-decode each image on the UI isolate. The timer also outlived a dismissal by escape or barrier tap, so it kept running for the rest of the app's life, one more per open. `ScreenShareSourceController` reads both source types in a single call when it opens, and again only when the refresh button is pressed. It asks for 480x300 thumbnails rather than whatever the platform defaults to, holds no timers and no stream subscriptions, and is disposed whichever way the dialog goes away. `StreamModalDialog`, `StreamBlurScrim` and `StreamTabBar` join the design-system candidates; `StreamScreenShareSelector` is the grid, with `StreamScreenShareSelectorThemeData` to restyle it. Co-Authored-By: Claude Opus 5 --- dogfooding/lib/screens/call_screen.dart | 67 ++-- packages/stream_video_flutter/CHANGELOG.md | 11 + .../src/l10n/arb/stream_video_flutter_en.arb | 8 + .../src/l10n/arb/stream_video_flutter_nl.arb | 2 + .../stream_video_flutter_localizations.dart | 12 + ...stream_video_flutter_localizations_en.dart | 6 + ...stream_video_flutter_localizations_nl.dart | 6 + .../screen_share/desktop_screen_selector.dart | 296 ++++++++++-------- .../screen_selector_state_notifier.dart | 114 ------- .../lib/src/screen_share/screen_share.dart | 2 +- .../screen_share_selector_defaults.dart | 102 ++++++ .../screen_share_source_controller.dart | 161 ++++++++++ .../screen_share_thumbnail_widget.dart | 165 +++++----- .../lib/src/theme/components/components.dart | 1 + .../screen_share_selector_theme.dart | 223 +++++++++++++ .../screen_share_selector_theme.g.theme.dart | 279 +++++++++++++++++ .../lib/src/theme/stream_video_theme.dart | 20 ++ .../stream_modal_dialog.dart | 291 +++++++++++++++++ .../stream_tab_bar.dart | 137 ++++++++ .../lib/stream_video_flutter.dart | 2 + .../desktop_screen_selector_test.dart | 172 ++++++++++ .../screen_share/fake_desktop_capturer.dart | 77 +++++ .../screen_share_selector_golden_test.dart | 103 ++++++ .../screen_share_source_controller_test.dart | 128 ++++++++ .../stream_modal_dialog_test.dart | 99 ++++++ .../stream_tab_bar_test.dart | 65 ++++ 26 files changed, 2205 insertions(+), 344 deletions(-) delete mode 100644 packages/stream_video_flutter/lib/src/screen_share/screen_selector_state_notifier.dart create mode 100644 packages/stream_video_flutter/lib/src/screen_share/screen_share_selector_defaults.dart create mode 100644 packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart create mode 100644 packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.dart create mode 100644 packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.g.theme.dart create mode 100644 packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_modal_dialog.dart create mode 100644 packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_tab_bar.dart create mode 100644 packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart create mode 100644 packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart create mode 100644 packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart create mode 100644 packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart create mode 100644 packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_modal_dialog_test.dart create mode 100644 packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_tab_bar_test.dart diff --git a/dogfooding/lib/screens/call_screen.dart b/dogfooding/lib/screens/call_screen.dart index 31fd51305..391ca41bc 100644 --- a/dogfooding/lib/screens/call_screen.dart +++ b/dogfooding/lib/screens/call_screen.dart @@ -766,26 +766,55 @@ class __ShowChatButtonState extends State<_ShowChatButton> { Future _customDesktopScreenShareSelector( BuildContext context, ) { - final stateNotifier = ScreenSelectorStateNotifier( - sourceTypes: [SourceType.Screen], - ); - return showModalBottomSheet( context: context, - builder: (BuildContext context) { - return ValueListenableBuilder( - valueListenable: stateNotifier, - builder: - (BuildContext context, ScreenSelectorState value, Widget? child) => - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ThumbnailGrid( - sources: value.sources.values.toList(), - selectedSource: value.selectedSource, - onSelectSource: (source) => Navigator.pop(context, source), - ), - ), - ); - }, + builder: (context) => const _ScreenOnlySelectorSheet(), ); } + +class _ScreenOnlySelectorSheet extends StatefulWidget { + const _ScreenOnlySelectorSheet(); + + @override + State<_ScreenOnlySelectorSheet> createState() => + _ScreenOnlySelectorSheetState(); +} + +class _ScreenOnlySelectorSheetState extends State<_ScreenOnlySelectorSheet> { + late final _controller = ScreenShareSourceController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) { + final sources = [ + for (final source in state.sources) + if (source.type == SourceType.Screen) source, + ]; + + return GridView.builder( + padding: const EdgeInsets.all(16), + itemCount: sources.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + mainAxisExtent: 164, + ), + itemBuilder: (context, index) => StreamScreenShareThumbnail( + source: sources[index], + selected: false, + onTap: (source) => Navigator.pop(context, source), + ), + ); + }, + ); + } +} diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 9c70a21c9..c6955a58c 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -2,6 +2,12 @@ ### ✅ Added +- Added `StreamModalDialog` and `showStreamModalDialog`, a centered modal surface with a title, header actions and a footer, over a blurred `StreamBlurScrim`. +- Added `StreamTabBar`, a row of equal-width tabs whose selected index the caller owns. It, `StreamModalDialog` and `StreamBlurScrim` are design-system candidates, living in `src/widgets/design_system_candidates` until they graduate to core. +- Added `StreamScreenShareSelector`, the redesigned grid of screens and windows behind the desktop screen share picker, and `StreamScreenShareThumbnail`, one tile of it. +- Added `ScreenShareSourceController`, which holds the screens and windows on offer and the one that is picked. +- Added `StreamScreenShareSelectorThemeData` on `StreamVideoTheme`, and `StreamScreenShareSelectorTheme` to restyle the selector over a subtree. +- Added `desktopScreenShareRefresh` and `desktopScreenShareNoSources` to the localizations, in English and Dutch. - `StreamLayoutButton` draws the participant layout in effect and offers the rest through a `StreamAdaptiveMenuAnchor`. - `StreamLayoutButton.defaultLayouts` is `auto` and `speakerBottom`, so the button toggles unless it is given more. - Added layout strings to the localizations, in English and Dutch: `layoutMenuTitle`, `layoutSelectTooltip`, `layoutDefault`, `layoutGrid`, `layoutSpeakerTop`, `layoutSpeakerBottom`, `layoutSpeakerLeft`, `layoutSpeakerRight` and `layoutSpeakerOneToOne`. @@ -204,6 +210,8 @@ ### ⚠️ Breaking +- The desktop screen share picker is rebuilt on the design system. `TabbedScreenSelectWidget`, `ThumbnailGrid`, `ScreenSelectorStateNotifier` and `ScreenSelectorState` are gone; `StreamScreenShareSelector` and `ScreenShareSourceController` replace them. `showDefaultScreenSelectionDialog` keeps its signature. +- `ScreenShareThumbnailWidget` is `StreamScreenShareThumbnail` now, and takes the thumbnail from the source it is given rather than subscribing for one. - `ParticipantLayoutMode.auto` is the default layout of `StreamCallContent`, `StreamCallParticipants` and `RegularCallParticipantsContent`, and renders what `grid` used to. The livestream widgets still default to `grid`. - `ParticipantLayoutMode.grid` gives the local participant a tile of its own instead of floating them over the grid. - `ParticipantLayoutMode.auto` floats the self-view on mobile only while at most two other people are in the call, and gives the local participant a tile beyond that. @@ -268,6 +276,9 @@ ### 🔄 Changed +- The desktop screen share picker reads the platform's screens and windows once, and again on its refresh button, instead of re-enumerating and re-capturing all of them every two seconds. +- The picker asks the platform for 480x300 thumbnails instead of whatever size it defaults to. +- The picker's sources are released whichever way it is dismissed, including the escape key and a tap outside. - `StreamLobbyView` is restyled onto the design system — its typography, spacing and icons come from `StreamTheme`, and the close action is a ghost `StreamButton` instead of a Material `IconButton`. - Requires `stream_core_flutter` 0.5.0 for the button styles, error badge and theme accessors the components above use. diff --git a/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_en.arb b/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_en.arb index d9767b914..d3ed8a91d 100644 --- a/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_en.arb +++ b/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_en.arb @@ -19,6 +19,14 @@ "@desktopScreenShareWindow": { "description": "Tab to select a single window to share" }, + "desktopScreenShareRefresh": "Refresh", + "@desktopScreenShareRefresh": { + "description": "Tooltip of the action that re-reads the screens and windows on offer" + }, + "desktopScreenShareNoSources": "Nothing to share here.", + "@desktopScreenShareNoSources": { + "description": "Shown in place of the grid when the platform offers no screen or window of the selected type" + }, "layoutMenuTitle": "Layout", "@layoutMenuTitle": { "description": "Title of the sheet that picks how participants are laid out" diff --git a/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_nl.arb b/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_nl.arb index 5d885657e..8d8f087b1 100644 --- a/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_nl.arb +++ b/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_nl.arb @@ -4,6 +4,8 @@ "desktopScreenShareChooseDialogCancel": "Annuleren", "desktopScreenShareEntireScreen": "Volledig scherm", "desktopScreenShareWindow": "Venster", + "desktopScreenShareRefresh": "Vernieuwen", + "desktopScreenShareNoSources": "Hier valt niets te delen.", "layoutMenuTitle": "Indeling", "layoutSelectTooltip": "Indeling wijzigen", "layoutDefault": "Standaard", diff --git a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations.dart b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations.dart index 6054e6c02..1cb36277e 100644 --- a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations.dart +++ b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations.dart @@ -131,6 +131,18 @@ abstract class StreamVideoFlutterLocalizations { /// **'Window'** String get desktopScreenShareWindow; + /// Tooltip of the action that re-reads the screens and windows on offer + /// + /// In en, this message translates to: + /// **'Refresh'** + String get desktopScreenShareRefresh; + + /// Shown in place of the grid when the platform offers no screen or window of the selected type + /// + /// In en, this message translates to: + /// **'Nothing to share here.'** + String get desktopScreenShareNoSources; + /// Title of the sheet that picks how participants are laid out /// /// In en, this message translates to: diff --git a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_en.dart b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_en.dart index 1ac4e4d72..b8737b957 100644 --- a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_en.dart +++ b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_en.dart @@ -24,6 +24,12 @@ class StreamVideoFlutterLocalizationsEn @override String get desktopScreenShareWindow => 'Window'; + @override + String get desktopScreenShareRefresh => 'Refresh'; + + @override + String get desktopScreenShareNoSources => 'Nothing to share here.'; + @override String get layoutMenuTitle => 'Layout'; diff --git a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_nl.dart b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_nl.dart index bed363915..4bca0594c 100644 --- a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_nl.dart +++ b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_nl.dart @@ -24,6 +24,12 @@ class StreamVideoFlutterLocalizationsNl @override String get desktopScreenShareWindow => 'Venster'; + @override + String get desktopScreenShareRefresh => 'Vernieuwen'; + + @override + String get desktopScreenShareNoSources => 'Hier valt niets te delen.'; + @override String get layoutMenuTitle => 'Indeling'; diff --git a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart index 8120c0b66..0b2e523d7 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart @@ -1,163 +1,213 @@ -// ignore_for_file: comment_references - import 'package:flutter/material.dart'; - import '../../stream_video_flutter.dart'; import '../l10n/localization_extension.dart'; +import 'screen_share_selector_defaults.dart'; +/// Picks the screen or window to share, on a platform that offers no chooser +/// of its own. typedef DesktopScreenSelectorBuilder = Future Function( BuildContext context, ); -/// Default screen selection dialog. This shows a dialog with 2 tabs for screens and windows. -/// Can be styled using overlay from [StreamColorTheme]; body, bodyBold and tabBar from [StreamTextTheme]. +/// Shows the default screen selection dialog: the screens and windows on +/// offer, in two tabs, over a blurred scrim. +/// +/// Resolves to the picked source, or null when the dialog was cancelled or +/// dismissed. /// -/// For more customizations you can use [TabbedScreenSelectWidget] or [ThumbnailGrid] directly. +/// Style it through [StreamScreenShareSelectorTheme]. For a picker of a +/// different shape, build one out of [StreamScreenShareSelector] or +/// [StreamScreenShareThumbnail] and pass it to +/// [StreamScreenShareButton.desktopScreenSelectorBuilder]. Future showDefaultScreenSelectionDialog( BuildContext context, ) { - final streamVideoTheme = StreamVideoTheme.of(context); - final screenSelectorState = ScreenSelectorStateNotifier(); - final translations = context.translations; - - return showDialog( + return showStreamModalDialog( context: context, - builder: (context) => AlertDialog( - title: Text(translations.desktopScreenShareChooseDialogTitle), - backgroundColor: streamVideoTheme.colorTheme.overlay, - content: TabbedScreenSelectWidget( - screenSelectorState: screenSelectorState, - ), - actions: [ - TextButton( - child: Text(translations.desktopScreenShareChooseDialogCancel), - onPressed: () { - Navigator.pop(context); - screenSelectorState.dispose(); - }, - ), - ElevatedButton( - child: Text(translations.desktopScreenShareChooseDialogShare), - onPressed: () { - Navigator.pop( - context, - screenSelectorState.value.selectedSource, - ); - screenSelectorState.dispose(); - }, - ), - ], - ), + builder: (context) => const _ScreenSelectionDialog(), ); } -class TabbedScreenSelectWidget extends StatelessWidget { - const TabbedScreenSelectWidget({ - required ScreenSelectorStateNotifier screenSelectorState, +class _ScreenSelectionDialog extends StatefulWidget { + const _ScreenSelectionDialog(); + + @override + State<_ScreenSelectionDialog> createState() => _ScreenSelectionDialogState(); +} + +class _ScreenSelectionDialogState extends State<_ScreenSelectionDialog> { + late final _controller = ScreenShareSourceController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final translations = context.translations; + + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) => StreamModalDialog( + title: Text(translations.desktopScreenShareChooseDialogTitle), + headerActions: [ + StreamButton.icon( + icon: Icon(context.streamIcons.refresh), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + tooltip: translations.desktopScreenShareRefresh, + onPressed: state.isLoading ? null : _controller.refresh, + ), + ], + actions: [ + StreamButton( + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () => Navigator.pop(context), + child: Text(translations.desktopScreenShareChooseDialogCancel), + ), + StreamButton( + onPressed: switch (state.selectedSource) { + final source? => () => Navigator.pop( + context, + source, + ), + null => null, + }, + child: Text(translations.desktopScreenShareChooseDialogShare), + ), + ], + child: StreamScreenShareSelector(controller: _controller), + ), + ); + } +} + +/// The body of the screen share picker: a tab per source type over a grid of +/// [StreamScreenShareThumbnail]s. +/// +/// Reports a pick to the [controller], which the caller reads to find out what +/// to share. The caller owns the controller and disposes it. +/// +/// {@tool snippet} +/// +/// ```dart +/// StreamScreenShareSelector(controller: _controller) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [showDefaultScreenSelectionDialog], which shows this in a dialog. +/// * [StreamScreenShareSelectorTheme], for restyling it over a subtree. +class StreamScreenShareSelector extends StatelessWidget { + /// Creates a screen share selector. + const StreamScreenShareSelector({ super.key, - }) : _screenSelectorState = screenSelectorState; - final ScreenSelectorStateNotifier _screenSelectorState; - Map get _sources => - _screenSelectorState.value.sources; - DesktopCapturerSource? get _selectedSource => - _screenSelectorState.value.selectedSource; + required this.controller, + this.style, + }); + + /// Holds the sources on offer and the one that is picked. + final ScreenShareSourceController controller; + + /// Overrides for the selector's styling. + /// + /// Merged over the ambient [StreamScreenShareSelectorTheme]. + final StreamScreenShareSelectorStyle? style; @override Widget build(BuildContext context) { final translations = context.translations; - return SizedBox( - width: 640, - height: 560, - child: ValueListenableBuilder( - valueListenable: _screenSelectorState, - builder: (context, state, _) { - final streamVideoTheme = StreamVideoTheme.of(context); - final textTheme = streamVideoTheme.textTheme; - - return DefaultTabController( - length: 2, - child: Column( - children: [ - TabBar( - onTap: (value) => _screenSelectorState.setSourceType( - [if (value == 0) SourceType.Screen else SourceType.Window], - ), - tabs: - [ - translations.desktopScreenShareEntireScreen, - translations.desktopScreenShareWindow, - ] - .map( - (e) => Tab(child: Text(e, style: textTheme.tabBar)), - ) - .toList(), - ), - Expanded( - child: TabBarView( - children: [ - ThumbnailGrid( - sources: _sources.values - .where( - (element) => element.type == SourceType.Screen, - ) - .toList(), - selectedSource: _selectedSource, - onSelectSource: _screenSelectorState.setSelectedSource, - ), - ThumbnailGrid( - crossAxisCount: 3, - sources: _sources.values - .where( - (element) => element.type == SourceType.Window, - ) - .toList(), - selectedSource: _selectedSource, - onSelectSource: _screenSelectorState.setSelectedSource, - ), - ], - ), - ), - ], + const types = [SourceType.Screen, SourceType.Window]; + final labels = [ + translations.desktopScreenShareEntireScreen, + translations.desktopScreenShareWindow, + ]; + + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, _) => Column( + children: [ + StreamTabBar( + selectedIndex: types.indexOf(state.sourceType), + onSelected: (index) => controller.setSourceType(types[index]), + tabs: [ + for (final label in labels) StreamTabBarItem(label: label), + ], + ), + Expanded( + child: _SourceGrid( + state: state, + style: style, + onSelectSource: controller.setSelectedSource, ), - ); - }, + ), + ], ), ); } } -class ThumbnailGrid extends StatelessWidget { - const ThumbnailGrid({ - required this.sources, - this.crossAxisCount = 2, - this.crossAxisSpacing = 8, - required this.selectedSource, +class _SourceGrid extends StatelessWidget { + const _SourceGrid({ + required this.state, + required this.style, required this.onSelectSource, - super.key, }); - final List sources; - final DesktopCapturerSource? selectedSource; + final ScreenShareSourceState state; + final StreamScreenShareSelectorStyle? style; final OnThumbnailTapped onSelectSource; - final double crossAxisSpacing; - final int crossAxisCount; @override Widget build(BuildContext context) { - return GridView.count( - crossAxisSpacing: crossAxisSpacing, - crossAxisCount: crossAxisCount, - children: sources - .map( - (e) => ScreenShareThumbnailWidget( - onTap: onSelectSource, - source: e, - selected: selectedSource?.id == e.id, + final style = resolveScreenShareSelectorStyle(context, this.style); + final sources = state.visibleSources; + + if (sources.isEmpty) { + // A first load has nothing to show yet; a refresh keeps the grid it has. + if (state.isLoading) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + return Padding( + padding: style.padding, + child: Center( + child: Text( + context.translations.desktopScreenShareNoSources, + textAlign: TextAlign.center, + style: context.streamTextTheme.bodyDefault.copyWith( + color: context.streamColorScheme.textSecondary, ), - ) - .toList(), + ), + ), + ); + } + + return GridView.builder( + padding: style.padding, + itemCount: sources.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: style.crossAxisCount, + crossAxisSpacing: style.spacing, + mainAxisSpacing: style.spacing, + mainAxisExtent: style.tileHeight, + ), + itemBuilder: (context, index) { + final source = sources[index]; + return StreamScreenShareThumbnail( + key: ValueKey(source.id), + source: source, + selected: state.selectedSourceId == source.id, + onTap: onSelectSource, + style: this.style, + ); + }, ); } } diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_selector_state_notifier.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_selector_state_notifier.dart deleted file mode 100644 index 5f541823b..000000000 --- a/packages/stream_video_flutter/lib/src/screen_share/screen_selector_state_notifier.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; - -import 'desktop_screen_selector.dart'; -import 'screen_share_logger.dart'; - -/// The [ScreenSelectorStateNotifier] is used to keep track of the available screens and windows to share. -/// This is used by [showDefaultScreenSelectionDialog], but can be used directly if you want to build a custom widget. -/// Can be used in combination with a [ValueListenableBuilder] to get stateful updates. -class ScreenSelectorStateNotifier extends ValueNotifier { - /// Constructor of the [ScreenSelectorStateNotifier]. The [sourceTypes] can be used to set which source types are loaded first. - ScreenSelectorStateNotifier({ - List sourceTypes = const [SourceType.Screen], - }) : super(ScreenSelectorState._(sourceTypes: sourceTypes)) { - _subscriptions.add( - desktopCapturer.onAdded.stream.listen((source) { - final map = Map.from(value.sources); - map[source.id] = source; - value = value._copyWith(sources: UnmodifiableMapView(map)); - }), - ); - - _subscriptions.add( - desktopCapturer.onRemoved.stream.listen((source) { - final map = Map.from(value.sources); - map.remove(source.id); - value = value._copyWith(sources: UnmodifiableMapView(map)); - }), - ); - - _subscriptions.add( - desktopCapturer.onThumbnailChanged.stream.listen((source) { - value = value._copyWith(); - }), - ); - - _getSources(); - } - - final List> _subscriptions = []; - Timer? _timer; - - /// Update the sourceTypes. It's recommended to only show [SourceType.Screen] or - /// [SourceType.Window], but it is possible to show both. - void setSourceType(List sourceTypes) { - if (listEquals(sourceTypes, value.sourceTypes)) return; - - value = value._copyWith(sources: {}, sourceTypes: sourceTypes); - _getSources(); - } - - /// Updates the current selected source. Has no real effect other than the - /// option to show the selection in the UI. - void setSelectedSource(DesktopCapturerSource source) { - value = value._copyWith(selectedSource: source); - } - - @override - void dispose() { - _timer?.cancel(); - for (final subscription in _subscriptions) { - subscription.cancel(); - } - super.dispose(); - } - - Future _getSources() async { - try { - _timer?.cancel(); - final capturerSources = await desktopCapturer.getSources( - types: value.sourceTypes, - ); - _timer = Timer.periodic(const Duration(seconds: 2), (timer) { - desktopCapturer.updateSources(types: value.sourceTypes); - }); - final sources = {}; - for (final capturerSource in capturerSources) { - sources[capturerSource.id] = capturerSource; - } - - value = value._copyWith(sources: sources); - } catch (e) { - screenShareLogger.e(() => '[_getSources] failed: $e'); - } - } -} - -@immutable -class ScreenSelectorState { - const ScreenSelectorState._({ - this.sources = const {}, - required this.sourceTypes, - this.selectedSource, - }); - final Map sources; - final List sourceTypes; - final DesktopCapturerSource? selectedSource; - - ScreenSelectorState _copyWith({ - Map? sources, - List? sourceTypes, - DesktopCapturerSource? selectedSource, - }) { - return ScreenSelectorState._( - sources: sources ?? this.sources, - sourceTypes: sourceTypes ?? this.sourceTypes, - selectedSource: selectedSource ?? this.selectedSource, - ); - } -} diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share.dart index 6aa28e439..0eb2d1e03 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/screen_share.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share.dart @@ -2,5 +2,5 @@ export 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' show DesktopCapturerSource, SourceType; export 'desktop_screen_selector.dart'; -export 'screen_selector_state_notifier.dart'; +export 'screen_share_source_controller.dart'; export 'screen_share_thumbnail_widget.dart'; diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share_selector_defaults.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share_selector_defaults.dart new file mode 100644 index 000000000..7dfaa9ac7 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share_selector_defaults.dart @@ -0,0 +1,102 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../stream_video_flutter.dart'; + +/// Resolves the style a [StreamScreenShareSelector] draws itself with. +/// +/// Merges the ambient [StreamScreenShareSelectorTheme] with [style] and fills +/// in whatever neither supplied, so callers can read every property without a +/// fallback of their own. +@internal +StreamScreenShareSelectorStyleDefaults resolveScreenShareSelectorStyle( + BuildContext context, + StreamScreenShareSelectorStyle? style, +) { + final themeStyle = StreamScreenShareSelectorTheme.of(context).style; + return StreamScreenShareSelectorStyleDefaults( + context, + themeStyle?.merge(style) ?? style, + ); +} + +/// Default style values for [StreamScreenShareSelector]. +/// +/// Shared with the thumbnails the grid is built from, so a default lives in +/// one place rather than once per widget that draws it. Deliberately not +/// exported; reach it through [resolveScreenShareSelectorStyle]. +@internal +class StreamScreenShareSelectorStyleDefaults + extends StreamScreenShareSelectorStyle { + /// Resolves the selector's defaults from the theme on the given context, + /// letting [_style] win wherever it has a value. + StreamScreenShareSelectorStyleDefaults(this._context, [this._style]); + + final BuildContext _context; + final StreamScreenShareSelectorStyle? _style; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + late final _spacing = _context.streamSpacing; + late final _radius = _context.streamRadius; + + @override + EdgeInsetsGeometry get padding => + _style?.padding ?? EdgeInsets.all(_spacing.xxl); + + @override + int get crossAxisCount => _style?.crossAxisCount ?? 3; + + @override + double get spacing => _style?.spacing ?? _spacing.md; + + @override + double get tileHeight => _style?.tileHeight ?? 164; + + @override + EdgeInsetsGeometry get tilePadding => + _style?.tilePadding ?? EdgeInsets.all(_spacing.xs); + + @override + double get tileSpacing => _style?.tileSpacing ?? _spacing.xs; + + @override + BorderRadius get tileBorderRadius => + _style?.tileBorderRadius ?? BorderRadius.all(_radius.xl); + + @override + BorderRadius get imageBorderRadius => + _style?.imageBorderRadius ?? BorderRadius.all(_radius.md); + + @override + Color get borderColor => _style?.borderColor ?? _colorScheme.borderDefault; + + @override + double get borderWidth => _style?.borderWidth ?? 1; + + @override + Color get selectedBorderColor => + _style?.selectedBorderColor ?? _colorScheme.accentPrimary; + + @override + double get selectedBorderWidth => _style?.selectedBorderWidth ?? 2; + + @override + Color get placeholderColor => + _style?.placeholderColor ?? _colorScheme.backgroundSurfaceSubtle; + + @override + TextStyle get labelTextStyle => + _style?.labelTextStyle ?? _textTheme.captionEmphasis; + + @override + Color get labelColor => _style?.labelColor ?? _colorScheme.textTertiary; + + @override + Color get selectedLabelColor => + _style?.selectedLabelColor ?? _colorScheme.accentPrimary; + + @override + Size get thumbnailSize => + _style?.thumbnailSize ?? ScreenShareSourceController.defaultThumbnailSize; +} diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart new file mode 100644 index 000000000..6bf2760d3 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; + +import 'screen_share_logger.dart'; + +/// The screens and windows a desktop user can share, loaded once and held +/// until asked to reload. +/// +/// Used by `showDefaultScreenSelectionDialog`, and directly by a custom +/// picker. Pair it with a [ValueListenableBuilder] to rebuild as the load +/// finishes. +/// +/// The platform enumerates every screen and window and captures a bitmap of +/// each one on every load, which is expensive enough to be visible, so the +/// list is a snapshot: it is read on construction and again on [refresh], and +/// never on a timer. A source opened after the load appears once the user asks +/// for it. +class ScreenShareSourceController + extends ValueNotifier { + /// Creates a controller and starts loading. + /// + /// [sourceType] is the type shown first. Both types are loaded either way, + /// so switching between them costs nothing. + ScreenShareSourceController({ + DesktopCapturer? capturer, + SourceType sourceType = SourceType.Screen, + Size thumbnailSize = defaultThumbnailSize, + }) : _capturer = capturer ?? desktopCapturer, + _thumbnailSize = thumbnailSize, + super(ScreenShareSourceState(sourceType: sourceType)) { + unawaited(refresh()); + } + + /// The resolution asked of the platform for each thumbnail. + /// + /// The platform captures and encodes one bitmap per screen and window at + /// this size, and a picker decodes all of them, so this is the main cost of + /// opening one. + static const defaultThumbnailSize = Size(480, 300); + + final DesktopCapturer _capturer; + final Size _thumbnailSize; + bool _disposed = false; + + /// Shows the sources of [sourceType], leaving the loaded list alone. + /// + /// Both types are already in [ScreenShareSourceState.sources], so this is a + /// filter rather than a reload. + void setSourceType(SourceType sourceType) { + if (sourceType == value.sourceType) return; + value = value.copyWith(sourceType: sourceType); + } + + /// Marks [source] as the one to share. + void setSelectedSource(DesktopCapturerSource source) { + value = value.copyWith(selectedSourceId: source.id); + } + + /// Re-reads the screens and windows from the platform. + /// + /// Does nothing while a load is already running. A selection that is no + /// longer on offer is dropped. + Future refresh() async { + if (value.isLoading) return; + value = value.copyWith(isLoading: true, error: null); + + try { + final sources = await _capturer.getSources( + types: const [SourceType.Screen, SourceType.Window], + thumbnailSize: ThumbnailSize( + _thumbnailSize.width.round(), + _thumbnailSize.height.round(), + ), + ); + + if (_disposed) return; + value = value.copyWith( + sources: List.unmodifiable(sources), + isLoading: false, + ); + } catch (e, stk) { + screenShareLogger.e(() => '[refresh] failed: $e, $stk'); + if (_disposed) return; + value = value.copyWith(isLoading: false, error: e); + } + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +/// The state a [ScreenShareSourceController] holds. +@immutable +class ScreenShareSourceState { + /// Creates a screen share source state. + const ScreenShareSourceState({ + required this.sourceType, + this.sources = const [], + this.selectedSourceId, + this.isLoading = false, + this.error, + }); + + /// Every screen and window the last load found, in the order the platform + /// reported them. + final List sources; + + /// The type of source being shown. + final SourceType sourceType; + + /// The id of the source the user picked, if any. + final String? selectedSourceId; + + /// Whether a load is running. + final bool isLoading; + + /// What the last load failed with, if it did. + final Object? error; + + /// The sources of [sourceType]. + List get visibleSources => [ + for (final source in sources) + if (source.type == sourceType) source, + ]; + + /// The source the user picked, or null when nothing is picked or the pick is + /// no longer on offer. + DesktopCapturerSource? get selectedSource { + for (final source in sources) { + if (source.id == selectedSourceId) return source; + } + return null; + } + + /// Creates a copy of this state with the given fields replaced. + /// + /// [error] is cleared by passing null explicitly; the other nullable fields + /// are left alone when omitted. + ScreenShareSourceState copyWith({ + List? sources, + SourceType? sourceType, + String? selectedSourceId, + bool? isLoading, + Object? error = _unchanged, + }) { + return ScreenShareSourceState( + sources: sources ?? this.sources, + sourceType: sourceType ?? this.sourceType, + selectedSourceId: selectedSourceId ?? this.selectedSourceId, + isLoading: isLoading ?? this.isLoading, + error: identical(error, _unchanged) ? this.error : error, + ); + } + + static const _unchanged = Object(); +} diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart index 8937634a7..8ac1a9811 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart @@ -1,112 +1,103 @@ -import 'dart:async'; -import 'dart:typed_data'; - import 'package:flutter/material.dart'; -import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; -import '../theme/stream_video_theme.dart'; +import '../../stream_video_flutter.dart'; import 'screen_share_logger.dart'; +import 'screen_share_selector_defaults.dart'; +/// Called with the source a thumbnail stands for. typedef OnThumbnailTapped = void Function(DesktopCapturerSource); -class ScreenShareThumbnailWidget extends StatefulWidget { - const ScreenShareThumbnailWidget({ +/// One screen or window in a [StreamScreenShareSelector]: a still of the +/// source over its name, outlined in the accent color while it is picked. +/// +/// Mirrors the `Web / Screen Share Thumbnail` component from the design. +/// +/// The still is the bitmap the platform captured when the source was loaded, +/// which is what [ScreenShareSourceController] holds. It is not a live preview +/// and does not update on its own. +class StreamScreenShareThumbnail extends StatelessWidget { + /// Creates a screen share thumbnail. + const StreamScreenShareThumbnail({ super.key, required this.source, required this.selected, required this.onTap, + this.style, }); - final DesktopCapturerSource source; - final bool selected; - final OnThumbnailTapped onTap; - - @override - State createState() => - _ScreenShareThumbnailWidgetState(); -} - -class _ScreenShareThumbnailWidgetState - extends State { - final List> _subscriptions = []; - Uint8List? _thumbnail; - @override - void initState() { - super.initState(); - _subscribe(); - } - @override - void didUpdateWidget(covariant ScreenShareThumbnailWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.source.id != oldWidget.source.id) { - _unsubscribe(); - _subscribe(); - } - } + /// The screen or window this stands for. + final DesktopCapturerSource source; - @override - void dispose() { - _unsubscribe(); - super.dispose(); - } + /// Whether this is the source the user picked. + final bool selected; - void _subscribe() { - _subscriptions.add( - widget.source.onThumbnailChanged.stream.listen((event) { - setState(() { - _thumbnail = event; - }); - }), - ); - _subscriptions.add( - widget.source.onNameChanged.stream.listen((event) { - setState(() {}); - }), - ); - } + /// Called with [source] when the thumbnail is tapped. + final OnThumbnailTapped onTap; - void _unsubscribe() { - for (final element in _subscriptions) { - element.cancel(); - } - _subscriptions.clear(); - } + /// Overrides for the thumbnail's styling. + /// + /// Merged over the ambient [StreamScreenShareSelectorTheme]. + final StreamScreenShareSelectorStyle? style; @override Widget build(BuildContext context) { - final theme = StreamVideoTheme.of(context); - final textTheme = theme.textTheme; + final style = resolveScreenShareSelectorStyle(context, this.style); + final thumbnail = source.thumbnail; - return Column( - children: [ - Expanded( - child: DecoratedBox( - decoration: widget.selected - ? BoxDecoration( - border: Border.all(width: 2, color: Colors.blueAccent), - ) - : const BoxDecoration(), - child: InkWell( - onTap: () { - screenShareLogger.d( - () => 'Selected source id => ${widget.source.id}', - ); - widget.onTap(widget.source); - }, - child: _thumbnail != null - ? Image.memory( - _thumbnail!, - gaplessPlayback: true, - ) - : Container(), + return Semantics( + selected: selected, + button: true, + label: source.name, + child: InkWell( + onTap: () { + screenShareLogger.d(() => 'Selected source id => ${source.id}'); + onTap(source); + }, + borderRadius: style.tileBorderRadius, + child: Container( + padding: style.tilePadding, + // A foreground border paints inside the tile, so the extra pixel a + // selected tile's border carries does not resize it. + foregroundDecoration: BoxDecoration( + borderRadius: style.tileBorderRadius, + border: Border.all( + color: selected ? style.selectedBorderColor : style.borderColor, + width: selected ? style.selectedBorderWidth : style.borderWidth, ), ), + child: Column( + spacing: style.tileSpacing, + children: [ + Expanded( + child: ClipRRect( + borderRadius: style.imageBorderRadius, + child: ColoredBox( + color: style.placeholderColor, + child: thumbnail == null + ? const SizedBox.expand() + : SizedBox.expand( + child: Image.memory( + thumbnail, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + ), + ), + ), + Text( + source.name, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: style.labelTextStyle.copyWith( + color: selected ? style.selectedLabelColor : style.labelColor, + ), + ), + ], + ), ), - Text( - widget.source.name, - style: widget.selected ? textTheme.bodyBold : textTheme.body, - ), - ], + ), ); } } diff --git a/packages/stream_video_flutter/lib/src/theme/components/components.dart b/packages/stream_video_flutter/lib/src/theme/components/components.dart index 2f1b1f398..eb0cc8162 100644 --- a/packages/stream_video_flutter/lib/src/theme/components/components.dart +++ b/packages/stream_video_flutter/lib/src/theme/components/components.dart @@ -5,3 +5,4 @@ export 'floating_participant_tile_theme.dart'; export 'lobby_view_theme.dart'; export 'participant_label_theme.dart'; export 'participant_tile_theme.dart'; +export 'screen_share_selector_theme.dart'; diff --git a/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.dart new file mode 100644 index 000000000..a3b60ca4c --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.dart @@ -0,0 +1,223 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../../../stream_video_flutter.dart'; + +part 'screen_share_selector_theme.g.theme.dart'; + +/// Applies a screen share selector theme to descendant +/// [StreamScreenShareSelector] widgets. +/// +/// Wrap a subtree with [StreamScreenShareSelectorTheme] to override the +/// styling of the grid a desktop user picks a screen or window from. +/// +/// {@tool snippet} +/// +/// Draw the sources two to a row, in taller tiles: +/// +/// ```dart +/// StreamScreenShareSelectorTheme( +/// data: StreamScreenShareSelectorThemeData( +/// style: StreamScreenShareSelectorStyle( +/// crossAxisCount: 2, +/// tileHeight: 220, +/// ), +/// ), +/// child: child, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamScreenShareSelectorThemeData], which describes the theme. +/// * [StreamScreenShareSelectorStyle], the visual style it carries. +class StreamScreenShareSelectorTheme extends InheritedTheme { + /// Creates a screen share selector theme. + const StreamScreenShareSelectorTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The selector theme data for descendant widgets. + final StreamScreenShareSelectorThemeData data; + + /// Returns the [StreamScreenShareSelectorThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamScreenShareSelectorTheme] ancestor + /// take precedence over the global values from + /// [StreamVideoTheme.screenShareSelectorTheme]. This allows partial + /// overrides: setting only [StreamScreenShareSelectorStyle.crossAxisCount] + /// leaves the remaining properties coming from the global theme. + static StreamScreenShareSelectorThemeData of(BuildContext context) { + final localTheme = context + .dependOnInheritedWidgetOfExactType(); + return StreamVideoTheme.of( + context, + ).screenShareSelectorTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamScreenShareSelectorTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamScreenShareSelectorTheme oldWidget) => + data != oldWidget.data; +} + +/// Theme data for customizing [StreamScreenShareSelector] widgets. +/// +/// Wraps a [StreamScreenShareSelectorStyle] so it can be served by +/// [StreamScreenShareSelectorTheme] and slotted into [StreamVideoTheme] +/// alongside the other component theme data classes. +/// +/// See also: +/// +/// * [StreamScreenShareSelectorStyle], the style embedded here. +/// * [StreamScreenShareSelectorTheme], for overriding it in a subtree. +@themeGen +@immutable +class StreamScreenShareSelectorThemeData + with _$StreamScreenShareSelectorThemeData { + /// Creates screen share selector theme data. + const StreamScreenShareSelectorThemeData({this.style}); + + /// Visual styling for the selector. + final StreamScreenShareSelectorStyle? style; + + /// Linearly interpolate between two theme data objects. + static StreamScreenShareSelectorThemeData? lerp( + StreamScreenShareSelectorThemeData? a, + StreamScreenShareSelectorThemeData? b, + double t, + ) => _$StreamScreenShareSelectorThemeData.lerp(a, b, t); +} + +/// Visual styling properties for a [StreamScreenShareSelector]. +/// +/// The selector is a grid of thumbnails, one per screen or window the platform +/// offers, with the picked one outlined in the accent color. +@themeGen +@immutable +class StreamScreenShareSelectorStyle with _$StreamScreenShareSelectorStyle { + /// Creates a selector style with optional property overrides. + const StreamScreenShareSelectorStyle({ + this.padding, + this.crossAxisCount, + this.spacing, + this.tileHeight, + this.tilePadding, + this.tileSpacing, + this.tileBorderRadius, + this.imageBorderRadius, + this.borderColor, + this.borderWidth, + this.selectedBorderColor, + this.selectedBorderWidth, + this.placeholderColor, + this.labelTextStyle, + this.labelColor, + this.selectedLabelColor, + this.thumbnailSize, + }); + + /// The inset around the grid. + /// + /// Defaults to `spacing.xxl` on every side. + final EdgeInsetsGeometry? padding; + + /// How many tiles fit in a row. + /// + /// Defaults to 3. + final int? crossAxisCount; + + /// The gap between tiles, in both directions. + /// + /// Defaults to `spacing.md`. + final double? spacing; + + /// The height of a tile, thumbnail and label together. + /// + /// Defaults to 164. + final double? tileHeight; + + /// The inset between a tile's border and its contents. + /// + /// Defaults to `spacing.xs` on every side. + final EdgeInsetsGeometry? tilePadding; + + /// The gap between a tile's thumbnail and its label. + /// + /// Defaults to `spacing.xs`. + final double? tileSpacing; + + /// The corner radius of a tile. + /// + /// Defaults to `radius.xl`. + final BorderRadius? tileBorderRadius; + + /// The corner radius of the thumbnail inside a tile. + /// + /// Defaults to `radius.md`. + final BorderRadius? imageBorderRadius; + + /// The color of an unselected tile's border. + /// + /// Defaults to `colorScheme.borderDefault`. + final Color? borderColor; + + /// The width of an unselected tile's border. + /// + /// Defaults to 1. Both border widths paint inside the tile, so a tile does + /// not resize as it is selected. + final double? borderWidth; + + /// The color of the selected tile's border. + /// + /// Defaults to `colorScheme.accentPrimary`. + final Color? selectedBorderColor; + + /// The width of the selected tile's border. + /// + /// Defaults to 2. + final double? selectedBorderWidth; + + /// The fill drawn where a source has no thumbnail yet. + /// + /// Defaults to `colorScheme.backgroundSurfaceSubtle`. + final Color? placeholderColor; + + /// The text style of a tile's label. + /// + /// Defaults to `textTheme.captionEmphasis`. + final TextStyle? labelTextStyle; + + /// The color of an unselected tile's label. + /// + /// Defaults to `colorScheme.textTertiary`. + final Color? labelColor; + + /// The color of the selected tile's label. + /// + /// Defaults to `colorScheme.accentPrimary`. + final Color? selectedLabelColor; + + /// The resolution asked of the platform for each thumbnail. + /// + /// Defaults to 480x300. The platform captures and encodes one bitmap per + /// screen and window at this size, and the grid decodes all of them, so a + /// larger value costs on both sides for detail a tile this size cannot + /// show. + final Size? thumbnailSize; + + /// Linearly interpolate between two styles. + static StreamScreenShareSelectorStyle? lerp( + StreamScreenShareSelectorStyle? a, + StreamScreenShareSelectorStyle? b, + double t, + ) => _$StreamScreenShareSelectorStyle.lerp(a, b, t); +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.g.theme.dart new file mode 100644 index 000000000..767952328 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.g.theme.dart @@ -0,0 +1,279 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'screen_share_selector_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamScreenShareSelectorThemeData { + bool get canMerge => true; + + static StreamScreenShareSelectorThemeData? lerp( + StreamScreenShareSelectorThemeData? a, + StreamScreenShareSelectorThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamScreenShareSelectorThemeData( + style: StreamScreenShareSelectorStyle.lerp(a.style, b.style, t), + ); + } + + StreamScreenShareSelectorThemeData copyWith({ + StreamScreenShareSelectorStyle? style, + }) { + final _this = (this as StreamScreenShareSelectorThemeData); + + return StreamScreenShareSelectorThemeData(style: style ?? _this.style); + } + + StreamScreenShareSelectorThemeData merge( + StreamScreenShareSelectorThemeData? other, + ) { + final _this = (this as StreamScreenShareSelectorThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamScreenShareSelectorThemeData); + final _other = (other as StreamScreenShareSelectorThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamScreenShareSelectorThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamScreenShareSelectorStyle { + bool get canMerge => true; + + static StreamScreenShareSelectorStyle? lerp( + StreamScreenShareSelectorStyle? a, + StreamScreenShareSelectorStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamScreenShareSelectorStyle( + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + crossAxisCount: t < 0.5 ? a.crossAxisCount : b.crossAxisCount, + spacing: lerpDouble$(a.spacing, b.spacing, t), + tileHeight: lerpDouble$(a.tileHeight, b.tileHeight, t), + tilePadding: EdgeInsetsGeometry.lerp(a.tilePadding, b.tilePadding, t), + tileSpacing: lerpDouble$(a.tileSpacing, b.tileSpacing, t), + tileBorderRadius: BorderRadius.lerp( + a.tileBorderRadius, + b.tileBorderRadius, + t, + ), + imageBorderRadius: BorderRadius.lerp( + a.imageBorderRadius, + b.imageBorderRadius, + t, + ), + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + borderWidth: lerpDouble$(a.borderWidth, b.borderWidth, t), + selectedBorderColor: Color.lerp( + a.selectedBorderColor, + b.selectedBorderColor, + t, + ), + selectedBorderWidth: lerpDouble$( + a.selectedBorderWidth, + b.selectedBorderWidth, + t, + ), + placeholderColor: Color.lerp(a.placeholderColor, b.placeholderColor, t), + labelTextStyle: TextStyle.lerp(a.labelTextStyle, b.labelTextStyle, t), + labelColor: Color.lerp(a.labelColor, b.labelColor, t), + selectedLabelColor: Color.lerp( + a.selectedLabelColor, + b.selectedLabelColor, + t, + ), + thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), + ); + } + + StreamScreenShareSelectorStyle copyWith({ + EdgeInsetsGeometry? padding, + int? crossAxisCount, + double? spacing, + double? tileHeight, + EdgeInsetsGeometry? tilePadding, + double? tileSpacing, + BorderRadius? tileBorderRadius, + BorderRadius? imageBorderRadius, + Color? borderColor, + double? borderWidth, + Color? selectedBorderColor, + double? selectedBorderWidth, + Color? placeholderColor, + TextStyle? labelTextStyle, + Color? labelColor, + Color? selectedLabelColor, + Size? thumbnailSize, + }) { + final _this = (this as StreamScreenShareSelectorStyle); + + return StreamScreenShareSelectorStyle( + padding: padding ?? _this.padding, + crossAxisCount: crossAxisCount ?? _this.crossAxisCount, + spacing: spacing ?? _this.spacing, + tileHeight: tileHeight ?? _this.tileHeight, + tilePadding: tilePadding ?? _this.tilePadding, + tileSpacing: tileSpacing ?? _this.tileSpacing, + tileBorderRadius: tileBorderRadius ?? _this.tileBorderRadius, + imageBorderRadius: imageBorderRadius ?? _this.imageBorderRadius, + borderColor: borderColor ?? _this.borderColor, + borderWidth: borderWidth ?? _this.borderWidth, + selectedBorderColor: selectedBorderColor ?? _this.selectedBorderColor, + selectedBorderWidth: selectedBorderWidth ?? _this.selectedBorderWidth, + placeholderColor: placeholderColor ?? _this.placeholderColor, + labelTextStyle: labelTextStyle ?? _this.labelTextStyle, + labelColor: labelColor ?? _this.labelColor, + selectedLabelColor: selectedLabelColor ?? _this.selectedLabelColor, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamScreenShareSelectorStyle merge(StreamScreenShareSelectorStyle? other) { + final _this = (this as StreamScreenShareSelectorStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + padding: other.padding, + crossAxisCount: other.crossAxisCount, + spacing: other.spacing, + tileHeight: other.tileHeight, + tilePadding: other.tilePadding, + tileSpacing: other.tileSpacing, + tileBorderRadius: other.tileBorderRadius, + imageBorderRadius: other.imageBorderRadius, + borderColor: other.borderColor, + borderWidth: other.borderWidth, + selectedBorderColor: other.selectedBorderColor, + selectedBorderWidth: other.selectedBorderWidth, + placeholderColor: other.placeholderColor, + labelTextStyle: + _this.labelTextStyle?.merge(other.labelTextStyle) ?? + other.labelTextStyle, + labelColor: other.labelColor, + selectedLabelColor: other.selectedLabelColor, + thumbnailSize: other.thumbnailSize, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamScreenShareSelectorStyle); + final _other = (other as StreamScreenShareSelectorStyle); + + return _other.padding == _this.padding && + _other.crossAxisCount == _this.crossAxisCount && + _other.spacing == _this.spacing && + _other.tileHeight == _this.tileHeight && + _other.tilePadding == _this.tilePadding && + _other.tileSpacing == _this.tileSpacing && + _other.tileBorderRadius == _this.tileBorderRadius && + _other.imageBorderRadius == _this.imageBorderRadius && + _other.borderColor == _this.borderColor && + _other.borderWidth == _this.borderWidth && + _other.selectedBorderColor == _this.selectedBorderColor && + _other.selectedBorderWidth == _this.selectedBorderWidth && + _other.placeholderColor == _this.placeholderColor && + _other.labelTextStyle == _this.labelTextStyle && + _other.labelColor == _this.labelColor && + _other.selectedLabelColor == _this.selectedLabelColor && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamScreenShareSelectorStyle); + + return Object.hash( + runtimeType, + _this.padding, + _this.crossAxisCount, + _this.spacing, + _this.tileHeight, + _this.tilePadding, + _this.tileSpacing, + _this.tileBorderRadius, + _this.imageBorderRadius, + _this.borderColor, + _this.borderWidth, + _this.selectedBorderColor, + _this.selectedBorderWidth, + _this.placeholderColor, + _this.labelTextStyle, + _this.labelColor, + _this.selectedLabelColor, + _this.thumbnailSize, + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart b/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart index d14eab5d6..d933bf436 100644 --- a/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart +++ b/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart @@ -42,6 +42,7 @@ class StreamVideoTheme extends ThemeExtension { StreamParticipantLabelThemeData? participantLabelTheme, StreamConnectionQualityIndicatorThemeData? connectionQualityIndicatorTheme, StreamCallParticipantsGridThemeData? callParticipantsGridTheme, + StreamScreenShareSelectorThemeData? screenShareSelectorTheme, StreamLivestreamThemeData? livestreamTheme, }) { final isDark = brightness == Brightness.dark; @@ -87,6 +88,7 @@ class StreamVideoTheme extends ThemeExtension { callParticipantsGridTheme: callParticipantsGridTheme ?? legacy?.toCallParticipantsGridThemeData(), + screenShareSelectorTheme: screenShareSelectorTheme, livestreamTheme: livestreamTheme, ); @@ -131,6 +133,7 @@ class StreamVideoTheme extends ThemeExtension { const StreamConnectionQualityIndicatorThemeData(), this.callParticipantsGridTheme = const StreamCallParticipantsGridThemeData(), + this.screenShareSelectorTheme = const StreamScreenShareSelectorThemeData(), required this.livestreamTheme, }); @@ -414,6 +417,9 @@ class StreamVideoTheme extends ThemeExtension { /// Theme for the participants grid layout. final StreamCallParticipantsGridThemeData callParticipantsGridTheme; + /// Theme for the desktop screen share selector. + final StreamScreenShareSelectorThemeData screenShareSelectorTheme; + /// Theme for the outgoing call widget. final StreamLivestreamThemeData livestreamTheme; @@ -454,6 +460,7 @@ class StreamVideoTheme extends ThemeExtension { StreamParticipantLabelThemeData? participantLabelTheme, StreamConnectionQualityIndicatorThemeData? connectionQualityIndicatorTheme, StreamCallParticipantsGridThemeData? callParticipantsGridTheme, + StreamScreenShareSelectorThemeData? screenShareSelectorTheme, StreamLivestreamThemeData? livestreamTheme, }) => StreamVideoTheme.raw( textTheme: this.textTheme.merge(textTheme), @@ -482,6 +489,9 @@ class StreamVideoTheme extends ThemeExtension { callParticipantsGridTheme: this.callParticipantsGridTheme.merge( callParticipantsGridTheme, ), + screenShareSelectorTheme: this.screenShareSelectorTheme.merge( + screenShareSelectorTheme, + ), livestreamTheme: this.livestreamTheme.merge(livestreamTheme), ); @@ -517,6 +527,9 @@ class StreamVideoTheme extends ThemeExtension { callParticipantsGridTheme: callParticipantsGridTheme.merge( other.callParticipantsGridTheme, ), + screenShareSelectorTheme: screenShareSelectorTheme.merge( + other.screenShareSelectorTheme, + ), livestreamTheme: livestreamTheme.merge(other.livestreamTheme), ); } @@ -591,6 +604,13 @@ class StreamVideoTheme extends ThemeExtension { t, ) ?? callParticipantsGridTheme, + screenShareSelectorTheme: + StreamScreenShareSelectorThemeData.lerp( + screenShareSelectorTheme, + other.screenShareSelectorTheme, + t, + ) ?? + screenShareSelectorTheme, livestreamTheme: livestreamTheme.lerp(other.livestreamTheme, t), ); } diff --git a/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_modal_dialog.dart b/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_modal_dialog.dart new file mode 100644 index 000000000..93e087718 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_modal_dialog.dart @@ -0,0 +1,291 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// Shows [builder] as a modal dialog over a blurred scrim. +/// +/// The dialog itself is typically a [StreamModalDialog], which supplies the +/// surface, the header and the footer. +/// +/// Mirrors the `Web / Blur Scrim` component from the design: the barrier is +/// the scrim color over a backdrop blur, rather than the flat translucent +/// black Material's [showDialog] paints. +/// +/// Returns the value the dialog was popped with, or null when it was +/// dismissed. +Future showStreamModalDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + String? barrierLabel, + bool useRootNavigator = true, + RouteSettings? routeSettings, +}) { + final scrimColor = context.streamColorScheme.backgroundScrim; + + return showGeneralDialog( + context: context, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + barrierDismissible: barrierDismissible, + barrierLabel: + barrierLabel ?? + MaterialLocalizations.of(context).modalBarrierDismissLabel, + // The scrim is drawn as part of the transition instead, so that it can + // carry the blur. A colored barrier here would paint a second, flat one + // underneath it. + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 150), + pageBuilder: (context, animation, secondaryAnimation) => builder(context), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final curve = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + + return FadeTransition( + opacity: curve, + child: Stack( + children: [ + // Behind the dialog and out of the way of the modal barrier that + // sits below, which is what a tap outside dismisses. + Positioned.fill( + child: IgnorePointer(child: StreamBlurScrim(color: scrimColor)), + ), + ScaleTransition( + scale: Tween(begin: 0.96, end: 1).animate(curve), + child: child, + ), + ], + ), + ); + }, + ); +} + +/// The scrim a modal surface sits on: a translucent fill over a blur of +/// whatever is behind it. +/// +/// Mirrors the `Web / Blur Scrim` component from the design. +/// +/// This is a design-system candidate: it lives in this SDK until the +/// component is finalized and can graduate to stream_core_flutter. +class StreamBlurScrim extends StatelessWidget { + /// Creates a blur scrim. + const StreamBlurScrim({super.key, this.color, this.blurSigma = 12.5}); + + /// The fill drawn over the blur. + /// + /// Defaults to `colorScheme.backgroundScrim`. + final Color? color; + + /// The blur applied to whatever sits behind the scrim. + /// + /// Defaults to 12.5. Set to `0` to skip the blur, which costs a render + /// layer the size of the window. + final double blurSigma; + + @override + Widget build(BuildContext context) { + final fill = ColoredBox( + color: color ?? context.streamColorScheme.backgroundScrim, + ); + + if (blurSigma <= 0) return fill; + + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), + child: fill, + ); + } +} + +/// A centered modal surface with a title, an optional row of header actions +/// and an optional row of footer actions. +/// +/// Mirrors the `Web / Modal Dialog Header` and `Web / Modal Dialog Footer` +/// components from the design, on an elevation-1 surface. +/// +/// Pass it to [showStreamModalDialog], which supplies the scrim: +/// +/// {@tool snippet} +/// +/// ```dart +/// final confirmed = await showStreamModalDialog( +/// context: context, +/// builder: (context) => StreamModalDialog( +/// title: const Text('Leave the call?'), +/// actions: [ +/// StreamButton( +/// style: StreamButtonStyle.secondary, +/// type: StreamButtonType.ghost, +/// onPressed: () => Navigator.pop(context, false), +/// child: const Text('Stay'), +/// ), +/// StreamButton( +/// style: StreamButtonStyle.destructive, +/// onPressed: () => Navigator.pop(context, true), +/// child: const Text('Leave'), +/// ), +/// ], +/// child: const Text('Everyone else stays in the call.'), +/// ), +/// ); +/// ``` +/// {@end-tool} +/// +/// This is a design-system candidate: it lives in this SDK until the +/// component is finalized and can graduate to stream_core_flutter. +class StreamModalDialog extends StatelessWidget { + /// Creates a modal dialog. + const StreamModalDialog({ + super.key, + this.title, + this.headerActions = const [], + this.showCloseButton = true, + this.onClose, + this.actions = const [], + this.constraints = defaultConstraints, + required this.child, + }); + + /// The size the design gives a modal window. + /// + /// A maximum rather than a fixed size: the dialog shrinks with the window, + /// and takes only the height its content needs. + static const defaultConstraints = BoxConstraints( + maxWidth: 720, + maxHeight: 640, + ); + + /// The dialog's title, drawn at the leading edge of the header. + final Widget? title; + + /// Buttons drawn in the header, before the close button. + /// + /// Typically [StreamButton.icon]s. + final List headerActions; + + /// Whether the header draws a close button after [headerActions]. + final bool showCloseButton; + + /// Called when the close button is pressed. + /// + /// Defaults to popping the dialog with no value. + final VoidCallback? onClose; + + /// Buttons drawn in the footer, aligned to the trailing edge. + /// + /// An empty list draws no footer at all. + final List actions; + + /// The bounds of the dialog. + /// + /// Defaults to [defaultConstraints]. + final BoxConstraints constraints; + + /// The dialog's body, between the header and the footer. + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + + return Dialog( + backgroundColor: colorScheme.backgroundElevation1, + surfaceTintColor: Colors.transparent, + elevation: context.streamElevation.level4, + clipBehavior: Clip.antiAlias, + insetPadding: EdgeInsets.all(spacing.xxl), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(context.streamRadius.xl), + ), + child: ConstrainedBox( + constraints: constraints, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ModalDialogHeader( + title: title, + actions: [ + ...headerActions, + if (showCloseButton) + StreamButton.icon( + icon: Icon(context.streamIcons.xmark), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + onPressed: onClose ?? () => Navigator.of(context).pop(), + ), + ], + ), + Flexible(child: child), + if (actions.isNotEmpty) _ModalDialogFooter(actions: actions), + ], + ), + ), + ); + } +} + +class _ModalDialogHeader extends StatelessWidget { + const _ModalDialogHeader({required this.title, required this.actions}); + + final Widget? title; + final List actions; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Padding( + padding: EdgeInsets.all(spacing.xl), + child: Row( + spacing: spacing.md, + children: [ + Expanded( + child: DefaultTextStyle.merge( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.streamTextTheme.headingSm.copyWith( + color: context.streamColorScheme.textPrimary, + ), + child: title ?? const SizedBox.shrink(), + ), + ), + if (actions.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xs, + children: actions, + ), + ], + ), + ); + } +} + +class _ModalDialogFooter extends StatelessWidget { + const _ModalDialogFooter({required this.actions}); + + final List actions; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Padding( + padding: EdgeInsets.all(spacing.xl), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: spacing.xs, + children: actions, + ), + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_tab_bar.dart b/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_tab_bar.dart new file mode 100644 index 000000000..1b07250c9 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_tab_bar.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// A row of tabs, each taking an equal share of the width, marking the +/// selected one with an accent label over an accent underline. +/// +/// Mirrors the `Web / Tab Bar` component from the design. +/// +/// Unlike Material's [TabBar] this carries no controller and no page view: +/// [selectedIndex] comes from the caller and [onSelected] reports a tap. That +/// keeps a tab switch a plain state change, which is what a tab bar over +/// already-loaded content wants — there is nothing to animate between and +/// nothing to reload. +/// +/// This is a design-system candidate: it lives in this SDK until the +/// component is finalized and can graduate to stream_core_flutter. +class StreamTabBar extends StatelessWidget { + /// Creates a tab bar. + const StreamTabBar({ + super.key, + required this.tabs, + required this.selectedIndex, + required this.onSelected, + }) : assert(tabs.length > 0, 'A tab bar needs at least one tab.'); + + /// The tabs to draw, in order. + final List tabs; + + /// The index into [tabs] of the selected tab. + final int selectedIndex; + + /// Called with the index of the tapped tab. + final ValueChanged onSelected; + + /// The height of the bar, underline included. + static const height = 48.0; + + static const _indicatorWeight = 2.0; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: Row( + children: [ + for (final (index, tab) in tabs.indexed) + Expanded( + child: _StreamTab( + item: tab, + selected: index == selectedIndex, + onPressed: () => onSelected(index), + ), + ), + ], + ), + ); + } +} + +/// One tab of a [StreamTabBar]. +@immutable +class StreamTabBarItem { + /// Creates a tab. + const StreamTabBarItem({required this.label, this.icon}); + + /// The tab's label. + final String label; + + /// An icon drawn before [label]. + final Widget? icon; +} + +class _StreamTab extends StatelessWidget { + const _StreamTab({ + required this.item, + required this.selected, + required this.onPressed, + }); + + final StreamTabBarItem item; + final bool selected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + + final color = selected + ? colorScheme.accentPrimary + : colorScheme.textSecondary; + + return Semantics( + selected: selected, + button: true, + child: InkWell( + onTap: onPressed, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: StreamTabBar._indicatorWeight, + color: selected + ? colorScheme.accentPrimary + : colorScheme.borderDefault, + ), + ), + ), + child: Padding( + padding: EdgeInsets.all(spacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: spacing.xs, + children: [ + if (item.icon case final icon?) + IconTheme.merge( + data: IconThemeData(color: color), + child: icon, + ), + Flexible( + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.streamTextTheme.captionEmphasis.copyWith( + color: color, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/stream_video_flutter/lib/stream_video_flutter.dart b/packages/stream_video_flutter/lib/stream_video_flutter.dart index 73434db15..28e5a04c3 100644 --- a/packages/stream_video_flutter/lib/stream_video_flutter.dart +++ b/packages/stream_video_flutter/lib/stream_video_flutter.dart @@ -87,8 +87,10 @@ export 'src/utils/screen_size.dart'; export 'src/widgets/design_system_candidates/stream_adaptive_menu_anchor.dart'; export 'src/widgets/design_system_candidates/stream_context_menu_anchor.dart'; export 'src/widgets/design_system_candidates/stream_context_menu_heading.dart'; +export 'src/widgets/design_system_candidates/stream_modal_dialog.dart'; export 'src/widgets/design_system_candidates/stream_radio_indicator.dart'; export 'src/widgets/design_system_candidates/stream_select_input.dart'; +export 'src/widgets/design_system_candidates/stream_tab_bar.dart'; export 'src/widgets/floating_view/floating_view_alignment.dart'; export 'src/widgets/floating_view/floating_view_container.dart'; export 'src/widgets/partial_call_state_builder.dart'; diff --git a/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart new file mode 100644 index 000000000..61efd6b28 --- /dev/null +++ b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/test_wrapper.dart'; +import 'fake_desktop_capturer.dart'; + +void main() { + late FakeDesktopCapturer capturer; + + final screen1 = FakeDesktopCapturerSource( + id: 'screen-1', + name: 'Screen 1', + type: SourceType.Screen, + thumbnail: blueThumbnail, + ); + final screen2 = FakeDesktopCapturerSource( + id: 'screen-2', + name: 'Screen 2', + type: SourceType.Screen, + thumbnail: greyThumbnail, + ); + final window = FakeDesktopCapturerSource( + id: 'window-1', + name: 'Notes', + type: SourceType.Window, + thumbnail: greyThumbnail, + ); + + setUp(() { + capturer = FakeDesktopCapturer(sources: [screen1, screen2, window]); + }); + + Future pumpSelector(WidgetTester tester) async { + final controller = ScreenShareSourceController(capturer: capturer); + addTearDown(controller.dispose); + + await tester.pumpWidget( + TestWrapper( + child: SizedBox( + width: 720, + height: 500, + child: StreamScreenShareSelector(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + return controller; + } + + group('StreamScreenShareSelector', () { + testWidgets('shows the screens of the selected tab', (tester) async { + await pumpSelector(tester); + + expect(find.text('Screen 1'), findsOneWidget); + expect(find.text('Screen 2'), findsOneWidget); + expect(find.text('Notes'), findsNothing); + }); + + testWidgets('switching to the window tab shows windows, and reloads ' + 'nothing', (tester) async { + await pumpSelector(tester); + + await tester.tap(find.text('Window')); + await tester.pumpAndSettle(); + + expect(find.text('Notes'), findsOneWidget); + expect(find.text('Screen 1'), findsNothing); + expect( + capturer.getSourcesCalls, + hasLength(1), + reason: 'both types were loaded up front', + ); + }); + + testWidgets('tapping a thumbnail selects its source', (tester) async { + final controller = await pumpSelector(tester); + + await tester.tap(find.text('Screen 2')); + await tester.pumpAndSettle(); + + expect(controller.value.selectedSource, screen2); + }); + + testWidgets('says so when the platform offers nothing', (tester) async { + capturer.sources = []; + await pumpSelector(tester); + + expect(find.text('Nothing to share here.'), findsOneWidget); + }); + + testWidgets('never polls the platform while it is open', (tester) async { + await pumpSelector(tester); + // A leaked Timer.periodic would also fail the test outright, at + // teardown; this says which one it was. + await tester.pump(const Duration(seconds: 10)); + + expect(capturer.updateSourcesCallCount, 0); + }); + }); + + group('showDefaultScreenSelectionDialog', () { + testWidgets('shares the picked source and cancels with nothing', ( + tester, + ) async { + // The dialog builds its own controller off the global capturer, which a + // test cannot reach, so the dialog chrome is exercised around a selector + // driven by the fake. + final controller = ScreenShareSourceController(capturer: capturer); + addTearDown(controller.dispose); + + DesktopCapturerSource? result; + var popped = false; + + await tester.pumpWidget( + TestWrapper( + child: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showStreamModalDialog( + context: context, + builder: (context) => ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, _) => StreamModalDialog( + title: const Text('Choose what to share'), + actions: [ + StreamButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + StreamButton( + onPressed: state.selectedSource == null + ? null + : () => Navigator.pop( + context, + state.selectedSource, + ), + child: const Text('Share'), + ), + ], + child: StreamScreenShareSelector(controller: controller), + ), + ), + ); + popped = true; + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Choose what to share'), findsOneWidget); + + // Nothing picked yet, so Share does nothing. + await tester.tap(find.text('Share')); + await tester.pumpAndSettle(); + expect(popped, isFalse); + + await tester.tap(find.text('Screen 1')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Share')); + await tester.pumpAndSettle(); + + expect(popped, isTrue); + expect(result, screen1); + }); + }); +} diff --git a/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart b/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart new file mode 100644 index 000000000..23d94b33d --- /dev/null +++ b/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; + +/// A [DesktopCapturer] that hands back the sources it was built with and +/// counts what was asked of it. +/// +/// The real one is a global reached through `desktopCapturer`, which is why +/// [ScreenShareSourceController] takes one. +class FakeDesktopCapturer extends DesktopCapturer { + FakeDesktopCapturer({this.sources = const []}); + + /// What the next [getSources] resolves to. + List sources; + + /// The `types` of every [getSources] call, in order. + final List> getSourcesCalls = []; + + /// The `thumbnailSize` of every [getSources] call, in order. + final List requestedThumbnailSizes = []; + + /// How many times [updateSources] was called. + int updateSourcesCallCount = 0; + + @override + Future> getSources({ + required List types, + ThumbnailSize? thumbnailSize, + }) async { + getSourcesCalls.add(types); + requestedThumbnailSizes.add(thumbnailSize); + return sources; + } + + @override + Future updateSources({required List types}) async { + updateSourcesCallCount++; + return true; + } +} + +/// A [DesktopCapturerSource] with fixed values. +class FakeDesktopCapturerSource extends DesktopCapturerSource { + FakeDesktopCapturerSource({ + required this.id, + required this.name, + required this.type, + this.thumbnail, + }); + + @override + final String id; + + @override + final String name; + + @override + final SourceType type; + + @override + final Uint8List? thumbnail; + + @override + ThumbnailSize get thumbnailSize => ThumbnailSize(480, 300); +} + +/// A 2x2 PNG in the design's accent blue. +final Uint8List blueThumbnail = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVR4nGNgiP8PQhAKACJaBXnC' + '+/yRAAAAAElFTkSuQmCC', +); + +/// A 2x2 PNG in a neutral grey. +final Uint8List greyThumbnail = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGPIKG4FIgYIBQAmGgWB' + '3tA7ugAAAABJRU5ErkJggg==', +); diff --git a/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart b/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart new file mode 100644 index 000000000..0cdfef098 --- /dev/null +++ b/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart @@ -0,0 +1,103 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/goldens.dart'; +import 'fake_desktop_capturer.dart'; + +// The selector is snapshotted on its own rather than through +// `showDefaultScreenSelectionDialog`: the CI capture path drops anything +// painted into an Overlay, so a dialog comes out blank. The dialog chrome is +// asserted in stream_modal_dialog_test.dart instead. +void main() { + final sources = [ + FakeDesktopCapturerSource( + id: 'screen-1', + name: 'Screen 1', + type: SourceType.Screen, + thumbnail: blueThumbnail, + ), + FakeDesktopCapturerSource( + id: 'screen-2', + name: 'Screen 2', + type: SourceType.Screen, + thumbnail: greyThumbnail, + ), + FakeDesktopCapturerSource( + id: 'window-1', + name: 'A window with a name too long to fit in its tile', + type: SourceType.Window, + thumbnail: greyThumbnail, + ), + FakeDesktopCapturerSource( + id: 'window-2', + name: 'Notes', + type: SourceType.Window, + thumbnail: blueThumbnail, + ), + ]; + + Widget selector(SourceType sourceType) => _DisposingSelector( + controller: ScreenShareSourceController( + capturer: FakeDesktopCapturer(sources: sources), + sourceType: sourceType, + ), + ); + + for (final brightness in Brightness.values) { + streamGoldenTest( + 'StreamScreenShareSelector outlines the picked screen', + fileName: 'screen_share_selector_screens', + brightness: brightness, + // The selection is made by tapping, which is also what proves the + // selected tile looks different from its neighbour. + pumpBeforeTest: (tester) => _settle(tester, tap: 'Screen 1'), + builder: () => selector(SourceType.Screen), + ); + + streamGoldenTest( + 'StreamScreenShareSelector ellipsises a long window name', + fileName: 'screen_share_selector_windows', + brightness: brightness, + pumpBeforeTest: _settle, + builder: () => selector(SourceType.Window), + ); + } +} + +Future _settle(WidgetTester tester, {String? tap}) async { + await tester.pumpAndSettle(); + if (tap != null) { + await tester.tap(find.text(tap)); + await tester.pumpAndSettle(); + } + await precacheImages(tester); + await tester.pumpAndSettle(); +} + +/// Sizes the selector to the body of a 720x640 modal — its 88px header and +/// 88px footer taken off — and disposes the controller when the test is over. +class _DisposingSelector extends StatefulWidget { + const _DisposingSelector({required this.controller}); + + final ScreenShareSourceController controller; + + @override + State<_DisposingSelector> createState() => _DisposingSelectorState(); +} + +class _DisposingSelectorState extends State<_DisposingSelector> { + @override + void dispose() { + widget.controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SizedBox( + width: 720, + height: 464, + child: StreamScreenShareSelector(controller: widget.controller), + ); +} diff --git a/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart b/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart new file mode 100644 index 000000000..25c895249 --- /dev/null +++ b/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; + +import 'fake_desktop_capturer.dart'; + +void main() { + late FakeDesktopCapturer capturer; + + final screen = FakeDesktopCapturerSource( + id: 'screen-1', + name: 'Screen 1', + type: SourceType.Screen, + ); + final window = FakeDesktopCapturerSource( + id: 'window-1', + name: 'A window', + type: SourceType.Window, + ); + + setUp(() { + capturer = FakeDesktopCapturer(sources: [screen, window]); + }); + + ScreenShareSourceController controller({ + SourceType sourceType = SourceType.Screen, + }) { + final controller = ScreenShareSourceController( + capturer: capturer, + sourceType: sourceType, + ); + addTearDown(controller.dispose); + return controller; + } + + group('ScreenShareSourceController', () { + test('loads both source types in a single call', () async { + final subject = controller(); + await pumpEventQueue(); + + expect(capturer.getSourcesCalls, [ + [SourceType.Screen, SourceType.Window], + ]); + expect(subject.value.sources, [screen, window]); + expect(subject.value.isLoading, isFalse); + }); + + test('caps the resolution it asks the platform to capture', () async { + controller(); + await pumpEventQueue(); + + final size = capturer.requestedThumbnailSizes.single; + expect(size, isNotNull); + expect( + size!.width, + ScreenShareSourceController.defaultThumbnailSize.width, + ); + expect( + size.height, + ScreenShareSourceController.defaultThumbnailSize.height, + ); + }); + + test('never polls the platform for updates', () async { + controller(); + await pumpEventQueue(); + + expect(capturer.updateSourcesCallCount, 0); + }); + + test('switching source type filters rather than reloading', () async { + final subject = controller(); + await pumpEventQueue(); + + expect(subject.value.visibleSources, [screen]); + + subject.setSourceType(SourceType.Window); + await pumpEventQueue(); + + expect(subject.value.visibleSources, [window]); + expect(capturer.getSourcesCalls, hasLength(1)); + }); + + test('refresh reads the platform once more', () async { + final subject = controller(); + await pumpEventQueue(); + + await subject.refresh(); + + expect(capturer.getSourcesCalls, hasLength(2)); + }); + + test('drops a selection the platform no longer offers', () async { + final subject = controller(); + await pumpEventQueue(); + + subject.setSelectedSource(screen); + expect(subject.value.selectedSource, screen); + + capturer.sources = [window]; + await subject.refresh(); + + expect(subject.value.selectedSource, isNull); + }); + + test('reports a failed load without throwing', () async { + final failing = _FailingDesktopCapturer(); + final subject = ScreenShareSourceController(capturer: failing); + addTearDown(subject.dispose); + await pumpEventQueue(); + + expect(subject.value.error, isNotNull); + expect(subject.value.isLoading, isFalse); + expect(subject.value.sources, isEmpty); + }); + }); +} + +class _FailingDesktopCapturer extends DesktopCapturer { + @override + Future> getSources({ + required List types, + ThumbnailSize? thumbnailSize, + }) async => throw Exception('no capturer here'); + + @override + Future updateSources({required List types}) async => false; +} diff --git a/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_modal_dialog_test.dart b/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_modal_dialog_test.dart new file mode 100644 index 000000000..1a67969be --- /dev/null +++ b/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_modal_dialog_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../../test_utils/test_wrapper.dart'; + +void main() { + Future openDialog( + WidgetTester tester, { + required Widget Function(BuildContext context) builder, + }) async { + String? result; + + await tester.pumpWidget( + TestWrapper( + child: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showStreamModalDialog( + context: context, + builder: builder, + ); + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return result; + } + + group('StreamModalDialog', () { + testWidgets('draws the title, the header actions and the footer actions', ( + tester, + ) async { + await openDialog( + tester, + builder: (context) => StreamModalDialog( + title: const Text('Choose what to share'), + headerActions: [ + StreamButton.icon( + icon: Icon(context.streamIcons.refresh), + onPressed: () {}, + ), + ], + actions: [ + StreamButton(onPressed: () {}, child: const Text('Cancel')), + StreamButton(onPressed: () {}, child: const Text('Share')), + ], + child: const Text('body'), + ), + ); + + expect(find.text('Choose what to share'), findsOneWidget); + expect(find.byIcon(const StreamIcons().refresh), findsOneWidget); + expect(find.text('body'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Share'), findsOneWidget); + }); + + testWidgets('the close button dismisses with no value', (tester) async { + await openDialog( + tester, + builder: (context) => const StreamModalDialog(child: Text('body')), + ); + + await tester.tap(find.byIcon(const StreamIcons().xmark)); + await tester.pumpAndSettle(); + + expect(find.text('body'), findsNothing); + }); + + testWidgets('draws no footer without actions', (tester) async { + await openDialog( + tester, + builder: (context) => const StreamModalDialog(child: Text('body')), + ); + + expect(find.byType(StreamButton), findsOneWidget); // the close button + }); + + testWidgets('a tap outside dismisses it through the scrim', (tester) async { + await openDialog( + tester, + builder: (context) => const StreamModalDialog(child: Text('body')), + ); + + expect(find.byType(StreamBlurScrim), findsOneWidget); + + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + expect(find.text('body'), findsNothing); + }); + }); +} diff --git a/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_tab_bar_test.dart b/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_tab_bar_test.dart new file mode 100644 index 000000000..0e6cd092d --- /dev/null +++ b/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_tab_bar_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../../test_utils/test_wrapper.dart'; + +void main() { + Future pumpTabBar( + WidgetTester tester, { + required int selectedIndex, + ValueChanged? onSelected, + }) { + return tester.pumpWidget( + TestWrapper( + child: SizedBox( + width: 400, + child: StreamTabBar( + selectedIndex: selectedIndex, + onSelected: onSelected ?? (_) {}, + tabs: const [ + StreamTabBarItem(label: 'Entire Screen'), + StreamTabBarItem(label: 'Window'), + ], + ), + ), + ), + ); + } + + Color labelColorOf(WidgetTester tester, String label) => + tester.widget(find.text(label)).style!.color!; + + group('StreamTabBar', () { + testWidgets('marks the selected tab with the accent color', (tester) async { + await pumpTabBar(tester, selectedIndex: 0); + + final colorScheme = StreamTheme.of( + tester.element(find.text('Entire Screen')), + ).colorScheme; + + expect(labelColorOf(tester, 'Entire Screen'), colorScheme.accentPrimary); + expect(labelColorOf(tester, 'Window'), colorScheme.textSecondary); + }); + + testWidgets('reports the index of the tapped tab', (tester) async { + final tapped = []; + await pumpTabBar(tester, selectedIndex: 0, onSelected: tapped.add); + + await tester.tap(find.text('Window')); + await tester.pumpAndSettle(); + + expect(tapped, [1]); + }); + + testWidgets('gives every tab an equal share of the width', (tester) async { + await pumpTabBar(tester, selectedIndex: 0); + + expect(tester.getSize(find.text('Entire Screen')).width, lessThan(200)); + expect( + tester.getTopLeft(find.text('Window')).dx, + greaterThanOrEqualTo(200), + ); + }); + }); +} From a9b151eae9eb129c007eb7a81664d727d38fe17c Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 14:15:21 +0200 Subject: [PATCH 2/7] test(ui): cover the screen share picker with widget tests and goldens Co-Authored-By: Claude Opus 5 --- .../desktop_screen_selector_test.dart | 40 ++++++++++ .../screen_share_selector_golden_test.dart | 77 +++++++++++++++++-- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart index 61efd6b28..708511987 100644 --- a/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart +++ b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart @@ -100,6 +100,46 @@ void main() { }); group('showDefaultScreenSelectionDialog', () { + testWidgets('opens the modal and cancels with nothing', (tester) async { + // The real entry point, on the real global capturer: there is no + // platform behind it under `flutter test`, so the load fails and the + // grid says it has nothing — which is enough to prove the dialog opens, + // dismisses, and returns null. + DesktopCapturerSource? result; + var popped = false; + + await tester.pumpWidget( + TestWrapper( + child: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showDefaultScreenSelectionDialog(context); + popped = true; + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + // Explicit pumps rather than pumpAndSettle: the loading spinner repeats + // forever, and there is no platform here to finish the load. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Choose what to share'), findsOneWidget); + expect(find.text('Entire Screen'), findsOneWidget); + expect(find.text('Window'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(popped, isTrue); + expect(result, isNull); + }); + testWidgets('shares the picked source and cancels with nothing', ( tester, ) async { diff --git a/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart b/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart index 0cdfef098..4e8f77a4d 100644 --- a/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart +++ b/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart @@ -46,6 +46,22 @@ void main() { ); for (final brightness in Brightness.values) { + streamGoldenTest( + 'the screen share modal, header and footer included', + fileName: 'screen_share_modal', + brightness: brightness, + // A dialog built inline rather than shown as a route, so the snapshot + // catches it: the CI capture drops overlay content. + constraints: const BoxConstraints.tightFor(width: 800, height: 720), + pumpBeforeTest: (tester) => _settle(tester, tap: 'Screen 1'), + builder: () => _DisposingSelector( + controller: ScreenShareSourceController( + capturer: FakeDesktopCapturer(sources: sources), + ), + asModal: true, + ), + ); + streamGoldenTest( 'StreamScreenShareSelector outlines the picked screen', fileName: 'screen_share_selector_screens', @@ -78,10 +94,13 @@ Future _settle(WidgetTester tester, {String? tap}) async { /// Sizes the selector to the body of a 720x640 modal — its 88px header and /// 88px footer taken off — and disposes the controller when the test is over. +/// +/// With [asModal] it draws the whole modal around it instead. class _DisposingSelector extends StatefulWidget { - const _DisposingSelector({required this.controller}); + const _DisposingSelector({required this.controller, this.asModal = false}); final ScreenShareSourceController controller; + final bool asModal; @override State<_DisposingSelector> createState() => _DisposingSelectorState(); @@ -95,9 +114,55 @@ class _DisposingSelectorState extends State<_DisposingSelector> { } @override - Widget build(BuildContext context) => SizedBox( - width: 720, - height: 464, - child: StreamScreenShareSelector(controller: widget.controller), - ); + Widget build(BuildContext context) { + final selector = StreamScreenShareSelector(controller: widget.controller); + + if (!widget.asModal) { + return SizedBox(width: 720, height: 464, child: selector); + } + + // On the scrim over a filled backdrop, the way it is seen in a call — a + // white modal on the wrapper's white page has no visible edge. The blur is + // a no-op under `flutter test`, so the scrim snapshots as a flat fill. + return Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: context.streamColorScheme.backgroundInverse), + const StreamBlurScrim(), + _modal(context), + ], + ); + } + + Widget _modal(BuildContext context) { + final selector = StreamScreenShareSelector(controller: widget.controller); + + return ValueListenableBuilder( + valueListenable: widget.controller, + builder: (context, state, _) => StreamModalDialog( + title: const Text('Choose what to share'), + headerActions: [ + StreamButton.icon( + icon: Icon(context.streamIcons.refresh), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + ], + actions: [ + StreamButton( + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + child: const Text('Cancel'), + ), + StreamButton( + onPressed: state.selectedSource == null ? null : () {}, + child: const Text('Share'), + ), + ], + child: selector, + ), + ); + } } From cf6fa183c783aef2fecdaf810698f8c92c78c39b Mon Sep 17 00:00:00 2001 From: renefloor <15101411+renefloor@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:18:33 +0000 Subject: [PATCH 3/7] chore: update goldens --- .../goldens/ci/screen_share_modal_dark.png | Bin 0 -> 14633 bytes .../goldens/ci/screen_share_modal_light.png | Bin 0 -> 14397 bytes .../ci/screen_share_selector_screens_dark.png | Bin 0 -> 6121 bytes .../ci/screen_share_selector_screens_light.png | Bin 0 -> 5821 bytes .../ci/screen_share_selector_windows_dark.png | Bin 0 -> 4866 bytes .../ci/screen_share_selector_windows_light.png | Bin 0 -> 4800 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_dark.png create mode 100644 packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_light.png create mode 100644 packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_dark.png create mode 100644 packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_light.png create mode 100644 packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_dark.png create mode 100644 packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_light.png diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_dark.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..67ce7acf158a0af0c482d75f60a8e5b7fdbcb6b1 GIT binary patch literal 14633 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 literal 0 HcmV?d00001 diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_light.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_light.png new file mode 100644 index 0000000000000000000000000000000000000000..1f428709ce4ad45b2dfbbafeba91e4088829b605 GIT binary patch literal 14397 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 literal 0 HcmV?d00001 diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_light.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_light.png new file mode 100644 index 0000000000000000000000000000000000000000..c54fb513d9eb04cbecb86ae0be40165e95988d84 GIT binary patch literal 5821 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 literal 0 HcmV?d00001 diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_dark.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..61c1546432f6d8ace7b549a48be05b31d41aec30 GIT binary patch literal 4866 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 literal 0 HcmV?d00001 From 8fcbe916e16130471499449e0b61493449676628 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 14:20:35 +0200 Subject: [PATCH 4/7] style(ui): group the screen selector's imports Co-Authored-By: Claude Opus 5 --- .../src/screen_share/desktop_screen_selector.dart | 1 + pubspec.lock | 13 ++++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart index 0b2e523d7..b5012f817 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; + import '../../stream_video_flutter.dart'; import '../l10n/localization_extension.dart'; import 'screen_share_selector_defaults.dart'; diff --git a/pubspec.lock b/pubspec.lock index ae4f729a2..ceba69e78 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1955,14 +1955,13 @@ packages: source: hosted version: "10.4.0" stream_core: - dependency: "direct overridden" + dependency: transitive description: - path: "packages/stream_core" - ref: f83b5d4d706a79fc429de2d27aead4394b83c1fb - resolved-ref: f83b5d4d706a79fc429de2d27aead4394b83c1fb - url: "https://github.com/GetStream/stream-core-flutter.git" - source: git - version: "0.4.0" + name: stream_core + sha256: "25c19466b96050354e9a64fb13767956b48b9405576472005598733502ef219c" + url: "https://pub.dev" + source: hosted + version: "0.5.0" stream_core_flutter: dependency: "direct overridden" description: From a4e4493d6c53613f70a7723f2d25b8f165031244 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 14:24:09 +0200 Subject: [PATCH 5/7] refactor(ui): make the screen share dialog a widget of its own StreamScreenShareDialog takes an optional controller, so the modal's header and footer are reachable from a test and from a golden. The golden used to hand-roll a copy of the chrome, which would have kept passing while the real dialog changed underneath it. Co-Authored-By: Claude Opus 5 --- packages/stream_video_flutter/CHANGELOG.md | 1 + .../screen_share/desktop_screen_selector.dart | 55 +++++++++++++++---- .../desktop_screen_selector_test.dart | 43 +++++++++++++++ .../screen_share_selector_golden_test.dart | 33 +---------- 4 files changed, 89 insertions(+), 43 deletions(-) diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index c6955a58c..4bf458578 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -4,6 +4,7 @@ - Added `StreamModalDialog` and `showStreamModalDialog`, a centered modal surface with a title, header actions and a footer, over a blurred `StreamBlurScrim`. - Added `StreamTabBar`, a row of equal-width tabs whose selected index the caller owns. It, `StreamModalDialog` and `StreamBlurScrim` are design-system candidates, living in `src/widgets/design_system_candidates` until they graduate to core. +- Added `StreamScreenShareDialog`, the desktop screen share picker as a widget, so it can be presented some way other than through `showDefaultScreenSelectionDialog`. - Added `StreamScreenShareSelector`, the redesigned grid of screens and windows behind the desktop screen share picker, and `StreamScreenShareThumbnail`, one tile of it. - Added `ScreenShareSourceController`, which holds the screens and windows on offer and the one that is picked. - Added `StreamScreenShareSelectorThemeData` on `StreamVideoTheme`, and `StreamScreenShareSelectorTheme` to restyle the selector over a subtree. diff --git a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart index b5012f817..7298ce8de 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart @@ -18,40 +18,71 @@ typedef DesktopScreenSelectorBuilder = /// dismissed. /// /// Style it through [StreamScreenShareSelectorTheme]. For a picker of a -/// different shape, build one out of [StreamScreenShareSelector] or -/// [StreamScreenShareThumbnail] and pass it to +/// different shape, build one out of [StreamScreenShareDialog], +/// [StreamScreenShareSelector] or [StreamScreenShareThumbnail] and pass it to /// [StreamScreenShareButton.desktopScreenSelectorBuilder]. Future showDefaultScreenSelectionDialog( BuildContext context, ) { return showStreamModalDialog( context: context, - builder: (context) => const _ScreenSelectionDialog(), + builder: (context) => const StreamScreenShareDialog(), ); } -class _ScreenSelectionDialog extends StatefulWidget { - const _ScreenSelectionDialog(); +/// The default screen share picker, as a widget: a [StreamModalDialog] around +/// a [StreamScreenShareSelector], with a refresh action in the header and +/// Cancel and Share in the footer. +/// +/// Pops the [Navigator] with the picked source, or with nothing when +/// cancelled. [showDefaultScreenSelectionDialog] shows it over a scrim; use +/// this directly to present it some other way. +class StreamScreenShareDialog extends StatefulWidget { + /// Creates a screen share dialog. + const StreamScreenShareDialog({super.key, this.controller}); + + /// Holds the sources on offer and the one that is picked. + /// + /// Null builds one — and disposes it — for the life of the dialog, which is + /// what [showDefaultScreenSelectionDialog] does. A controller passed here + /// belongs to the caller, who disposes it. + final ScreenShareSourceController? controller; @override - State<_ScreenSelectionDialog> createState() => _ScreenSelectionDialogState(); + State createState() => + _StreamScreenShareDialogState(); } -class _ScreenSelectionDialogState extends State<_ScreenSelectionDialog> { - late final _controller = ScreenShareSourceController(); +class _StreamScreenShareDialogState extends State { + ScreenShareSourceController? _ownedController; + + ScreenShareSourceController get _controller => + widget.controller ?? (_ownedController ??= ScreenShareSourceController()); + + @override + void didUpdateWidget(StreamScreenShareDialog oldWidget) { + super.didUpdateWidget(oldWidget); + // A controller arriving where the dialog had been making its own leaves + // the owned one with nothing to drive. + if (widget.controller != null) { + _ownedController?.dispose(); + _ownedController = null; + } + } @override void dispose() { - _controller.dispose(); + _ownedController?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final translations = context.translations; + final controller = _controller; return ValueListenableBuilder( - valueListenable: _controller, + valueListenable: controller, builder: (context, state, _) => StreamModalDialog( title: Text(translations.desktopScreenShareChooseDialogTitle), headerActions: [ @@ -60,7 +91,7 @@ class _ScreenSelectionDialogState extends State<_ScreenSelectionDialog> { style: StreamButtonStyle.secondary, type: StreamButtonType.ghost, tooltip: translations.desktopScreenShareRefresh, - onPressed: state.isLoading ? null : _controller.refresh, + onPressed: state.isLoading ? null : controller.refresh, ), ], actions: [ @@ -81,7 +112,7 @@ class _ScreenSelectionDialogState extends State<_ScreenSelectionDialog> { child: Text(translations.desktopScreenShareChooseDialogShare), ), ], - child: StreamScreenShareSelector(controller: _controller), + child: StreamScreenShareSelector(controller: controller), ), ); } diff --git a/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart index 708511987..555cef8e9 100644 --- a/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart +++ b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart @@ -99,6 +99,49 @@ void main() { }); }); + group('StreamScreenShareDialog', () { + Future pumpDialog(WidgetTester tester) async { + final controller = ScreenShareSourceController(capturer: capturer); + addTearDown(controller.dispose); + + await tester.pumpWidget( + TestWrapper( + child: StreamScreenShareDialog(controller: controller), + ), + ); + await tester.pumpAndSettle(); + return controller; + } + + testWidgets('the refresh action re-reads the platform', (tester) async { + await pumpDialog(tester); + expect(capturer.getSourcesCalls, hasLength(1)); + + await tester.tap(find.byIcon(const StreamIcons().refresh)); + await tester.pumpAndSettle(); + + expect(capturer.getSourcesCalls, hasLength(2)); + }); + + testWidgets('Share is disabled until a source is picked', (tester) async { + await pumpDialog(tester); + + StreamButton shareButton() => tester.widget( + find.ancestor( + of: find.text('Share'), + matching: find.byType(StreamButton), + ), + ); + + expect(shareButton().props.onPressed, isNull); + + await tester.tap(find.text('Screen 1')); + await tester.pumpAndSettle(); + + expect(shareButton().props.onPressed, isNotNull); + }); + }); + group('showDefaultScreenSelectionDialog', () { testWidgets('opens the modal and cancels with nothing', (tester) async { // The real entry point, on the real global capturer: there is no diff --git a/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart b/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart index 4e8f77a4d..920050a7c 100644 --- a/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart +++ b/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart @@ -134,35 +134,6 @@ class _DisposingSelectorState extends State<_DisposingSelector> { ); } - Widget _modal(BuildContext context) { - final selector = StreamScreenShareSelector(controller: widget.controller); - - return ValueListenableBuilder( - valueListenable: widget.controller, - builder: (context, state, _) => StreamModalDialog( - title: const Text('Choose what to share'), - headerActions: [ - StreamButton.icon( - icon: Icon(context.streamIcons.refresh), - style: StreamButtonStyle.secondary, - type: StreamButtonType.ghost, - onPressed: () {}, - ), - ], - actions: [ - StreamButton( - style: StreamButtonStyle.secondary, - type: StreamButtonType.ghost, - onPressed: () {}, - child: const Text('Cancel'), - ), - StreamButton( - onPressed: state.selectedSource == null ? null : () {}, - child: const Text('Share'), - ), - ], - child: selector, - ), - ); - } + Widget _modal(BuildContext context) => + StreamScreenShareDialog(controller: widget.controller); } From 5b145524d71c7ebbe187cec8a3ade020c66a43df Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 14:28:15 +0200 Subject: [PATCH 6/7] fix(ui): build the screen share dialog's controller in initState A controller starts reading the platform as soon as it exists, so creating one lazily on first build made that a side effect of building. Co-Authored-By: Claude Opus 5 --- .../screen_share/desktop_screen_selector.dart | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart index 7298ce8de..d062d491f 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart @@ -57,14 +57,29 @@ class _StreamScreenShareDialogState extends State { ScreenShareSourceController? _ownedController; ScreenShareSourceController get _controller => - widget.controller ?? (_ownedController ??= ScreenShareSourceController()); + widget.controller ?? _ownedController!; + + @override + void initState() { + super.initState(); + // Built here rather than lazily on first build: a controller starts + // reading the platform as soon as it exists, which is not something to do + // as a side effect of building. + if (widget.controller == null) { + _ownedController = ScreenShareSourceController(); + } + } @override void didUpdateWidget(StreamScreenShareDialog oldWidget) { super.didUpdateWidget(oldWidget); - // A controller arriving where the dialog had been making its own leaves - // the owned one with nothing to drive. - if (widget.controller != null) { + if (widget.controller == oldWidget.controller) return; + + if (widget.controller == null) { + _ownedController ??= ScreenShareSourceController(); + } else { + // A controller arriving where the dialog had been making its own leaves + // the owned one with nothing to drive. _ownedController?.dispose(); _ownedController = null; } From 4ad21bb0ab0c0ec56815d03e744c0bdde229f73b Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 14:55:44 +0200 Subject: [PATCH 7/7] fix(ui): show the screen share thumbnails on first load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS leaves the bitmaps out of the getDesktopSources result entirely — the `thumbnail` key is commented out natively and thumbnailSize comes back as {0,0}. They arrive only as events raised by the enumeration, and the plugin's own getSources then rebuilds its source map from the bitmap-less response, dropping whatever those events had delivered. The old picker's two-second timer was the only thing that ever put an image on screen, one tick after the dialog opened. The controller now subscribes to onAdded and onThumbnailChanged before the first load — getDesktopSources passes forceReload:YES, so every source is re-added with its bitmap — and keeps the bytes by source id in its own state, where a reload cannot clobber them. A capture pass is asked for only if something is still missing afterwards, so the platforms that report bitmaps inline pay nothing. Co-Authored-By: Claude Opus 5 --- dogfooding/macos/Podfile | 2 +- packages/stream_video_flutter/CHANGELOG.md | 2 +- .../screen_share/desktop_screen_selector.dart | 1 + .../screen_share_source_controller.dart | 94 ++++++++++++++++--- .../screen_share_thumbnail_widget.dart | 10 +- .../desktop_screen_selector_test.dart | 9 +- .../screen_share/fake_desktop_capturer.dart | 42 ++++++++- .../screen_share_source_controller_test.dart | 65 +++++++++++-- 8 files changed, 198 insertions(+), 27 deletions(-) diff --git a/dogfooding/macos/Podfile b/dogfooding/macos/Podfile index 10dfe2343..86a124687 100644 --- a/dogfooding/macos/Podfile +++ b/dogfooding/macos/Podfile @@ -46,7 +46,7 @@ post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) target.build_configurations.each do |config| - config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.15' + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '12.0' end end end diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 4bf458578..829c148f1 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -278,7 +278,7 @@ ### 🔄 Changed - The desktop screen share picker reads the platform's screens and windows once, and again on its refresh button, instead of re-enumerating and re-capturing all of them every two seconds. -- The picker asks the platform for 480x300 thumbnails instead of whatever size it defaults to. +- The picker asks the platform for 480x300 thumbnails where the platform honours a size; macOS captures at its own. - The picker's sources are released whichever way it is dismissed, including the escape key and a tap outside. - `StreamLobbyView` is restyled onto the design system — its typography, spacing and icons come from `StreamTheme`, and the close action is a ghost `StreamButton` instead of a Material `IconButton`. - Requires `stream_core_flutter` 0.5.0 for the button styles, error badge and theme accessors the components above use. diff --git a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart index d062d491f..5d09f9316 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart @@ -250,6 +250,7 @@ class _SourceGrid extends StatelessWidget { return StreamScreenShareThumbnail( key: ValueKey(source.id), source: source, + thumbnail: state.thumbnailFor(source), selected: state.selectedSourceId == source.id, onTap: onSelectSource, style: this.style, diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart index 6bf2760d3..326609a27 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; @@ -12,11 +13,10 @@ import 'screen_share_logger.dart'; /// picker. Pair it with a [ValueListenableBuilder] to rebuild as the load /// finishes. /// -/// The platform enumerates every screen and window and captures a bitmap of -/// each one on every load, which is expensive enough to be visible, so the -/// list is a snapshot: it is read on construction and again on [refresh], and -/// never on a timer. A source opened after the load appears once the user asks -/// for it. +/// A load makes the platform enumerate every screen and window and capture a +/// bitmap of each, which is expensive enough to be visible, so the list is a +/// snapshot: it is read on construction and again on [refresh], and never on a +/// timer. A source opened afterwards appears once the user asks for it. class ScreenShareSourceController extends ValueNotifier { /// Creates a controller and starts loading. @@ -30,18 +30,36 @@ class ScreenShareSourceController }) : _capturer = capturer ?? desktopCapturer, _thumbnailSize = thumbnailSize, super(ScreenShareSourceState(sourceType: sourceType)) { + // Subscribed before the first load, because the enumeration raises its + // events while it runs. These getters have no implementation to fall back + // on in the platform interface, so a capturer that reports its bitmaps + // inline and raises no events need not provide them. + try { + _events.addAll([ + _capturer.onAdded.stream.listen(_onThumbnail), + _capturer.onThumbnailChanged.stream.listen(_onThumbnail), + ]); + // The platform interface reports "this capturer has no such stream" by + // throwing from the getter, so there is nothing else to catch here. + // ignore: avoid_catching_errors + } on UnimplementedError catch (e) { + screenShareLogger.w(() => '[init] capturer posts no thumbnails: $e'); + } + unawaited(refresh()); } /// The resolution asked of the platform for each thumbnail. /// - /// The platform captures and encodes one bitmap per screen and window at - /// this size, and a picker decodes all of them, so this is the main cost of - /// opening one. + /// Honoured where the platform offers a choice; macOS captures at its own + /// fixed size whatever this says. static const defaultThumbnailSize = Size(480, 300); + static const _types = [SourceType.Screen, SourceType.Window]; + final DesktopCapturer _capturer; final Size _thumbnailSize; + final List> _events = []; bool _disposed = false; /// Shows the sources of [sourceType], leaving the loaded list alone. @@ -68,7 +86,7 @@ class ScreenShareSourceController try { final sources = await _capturer.getSources( - types: const [SourceType.Screen, SourceType.Window], + types: _types, thumbnailSize: ThumbnailSize( _thumbnailSize.width.round(), _thumbnailSize.height.round(), @@ -76,10 +94,18 @@ class ScreenShareSourceController ); if (_disposed) return; - value = value.copyWith( - sources: List.unmodifiable(sources), - isLoading: false, - ); + value = value._withSources(List.unmodifiable(sources)); + + // Not every platform puts the bitmaps in the `getSources` result — + // macOS leaves them out and reports them through the events the + // enumeration raises, which is what the subscriptions above are for. + // Only if something is still missing is it worth asking for a capture + // pass, since that recaptures every screen and window. Deliberately not + // on a timer: the old picker's two-second one spent exactly this to + // redraw what was already on screen. + if (value.sources.any((it) => value.thumbnailFor(it) == null)) { + await _capturer.updateSources(types: _types); + } } catch (e, stk) { screenShareLogger.e(() => '[refresh] failed: $e, $stk'); if (_disposed) return; @@ -87,9 +113,21 @@ class ScreenShareSourceController } } + void _onThumbnail(DesktopCapturerSource source) { + final thumbnail = source.thumbnail; + if (_disposed || thumbnail == null) return; + + value = value.copyWith( + thumbnails: {...value.thumbnails, source.id: thumbnail}, + ); + } + @override void dispose() { _disposed = true; + for (final subscription in _events) { + unawaited(subscription.cancel()); + } super.dispose(); } } @@ -101,6 +139,7 @@ class ScreenShareSourceState { const ScreenShareSourceState({ required this.sourceType, this.sources = const [], + this.thumbnails = const {}, this.selectedSourceId, this.isLoading = false, this.error, @@ -110,6 +149,13 @@ class ScreenShareSourceState { /// reported them. final List sources; + /// The bitmap of each source that has reported one, by source id. + /// + /// Held here rather than read off the source because the platform delivers + /// thumbnails after the source list, and a reload replaces the source + /// objects. + final Map thumbnails; + /// The type of source being shown. final SourceType sourceType; @@ -137,12 +183,33 @@ class ScreenShareSourceState { return null; } + /// The bitmap to draw for [source], if there is one yet. + Uint8List? thumbnailFor(DesktopCapturerSource source) => + thumbnails[source.id] ?? source.thumbnail; + + /// A copy holding [sources], with the thumbnails of sources that are no + /// longer on offer dropped and the load marked finished. + ScreenShareSourceState _withSources(List sources) { + final ids = {for (final source in sources) source.id}; + + return ScreenShareSourceState( + sources: sources, + thumbnails: { + for (final entry in thumbnails.entries) + if (ids.contains(entry.key)) entry.key: entry.value, + }, + sourceType: sourceType, + selectedSourceId: selectedSourceId, + ); + } + /// Creates a copy of this state with the given fields replaced. /// /// [error] is cleared by passing null explicitly; the other nullable fields /// are left alone when omitted. ScreenShareSourceState copyWith({ List? sources, + Map? thumbnails, SourceType? sourceType, String? selectedSourceId, bool? isLoading, @@ -150,6 +217,7 @@ class ScreenShareSourceState { }) { return ScreenShareSourceState( sources: sources ?? this.sources, + thumbnails: thumbnails ?? this.thumbnails, sourceType: sourceType ?? this.sourceType, selectedSourceId: selectedSourceId ?? this.selectedSourceId, isLoading: isLoading ?? this.isLoading, diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart index 8ac1a9811..b383e466b 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart @@ -22,6 +22,7 @@ class StreamScreenShareThumbnail extends StatelessWidget { required this.source, required this.selected, required this.onTap, + this.thumbnail, this.style, }); @@ -34,6 +35,13 @@ class StreamScreenShareThumbnail extends StatelessWidget { /// Called with [source] when the thumbnail is tapped. final OnThumbnailTapped onTap; + /// The bitmap to draw. + /// + /// Defaults to the one on [source]. Pass it explicitly where the platform + /// reports thumbnails separately from the source list, as + /// [ScreenShareSourceState.thumbnailFor] does. + final Uint8List? thumbnail; + /// Overrides for the thumbnail's styling. /// /// Merged over the ambient [StreamScreenShareSelectorTheme]. @@ -42,7 +50,7 @@ class StreamScreenShareThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { final style = resolveScreenShareSelectorStyle(context, this.style); - final thumbnail = source.thumbnail; + final thumbnail = this.thumbnail ?? source.thumbnail; return Semantics( selected: selected, diff --git a/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart index 555cef8e9..3cfab19df 100644 --- a/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart +++ b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart @@ -29,6 +29,7 @@ void main() { setUp(() { capturer = FakeDesktopCapturer(sources: [screen1, screen2, window]); + addTearDown(capturer.close); }); Future pumpSelector(WidgetTester tester) async { @@ -91,8 +92,12 @@ void main() { testWidgets('never polls the platform while it is open', (tester) async { await pumpSelector(tester); - // A leaked Timer.periodic would also fail the test outright, at - // teardown; this says which one it was. + // These sources came back with their bitmaps, so there is nothing left + // to capture. The old picker asked for a full capture pass every two + // seconds regardless. A leaked Timer.periodic would also fail the test + // outright, at teardown; this says which one it was. + expect(capturer.updateSourcesCallCount, 0); + await tester.pump(const Duration(seconds: 10)); expect(capturer.updateSourcesCallCount, 0); diff --git a/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart b/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart index 23d94b33d..e460cc6a1 100644 --- a/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart +++ b/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'dart:convert'; + import 'package:stream_video_flutter/stream_video_flutter.dart'; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; @@ -7,12 +9,24 @@ import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; /// /// The real one is a global reached through `desktopCapturer`, which is why /// [ScreenShareSourceController] takes one. +/// +/// Models how the platform actually delivers bitmaps: macOS leaves them out +/// of the `getSources` result and posts them as events instead. A source built +/// with a bitmap of its own stands for the platforms that do return them +/// inline; one listed in [pendingThumbnails] only gets its bitmap once an +/// update asks for it. class FakeDesktopCapturer extends DesktopCapturer { - FakeDesktopCapturer({this.sources = const []}); + FakeDesktopCapturer({ + this.sources = const [], + this.pendingThumbnails = const {}, + }); /// What the next [getSources] resolves to. List sources; + /// Bitmaps the platform hands over only on an update, by source id. + Map pendingThumbnails; + /// The `types` of every [getSources] call, in order. final List> getSourcesCalls = []; @@ -22,6 +36,14 @@ class FakeDesktopCapturer extends DesktopCapturer { /// How many times [updateSources] was called. int updateSourcesCallCount = 0; + @override + final StreamController onAdded = + StreamController.broadcast(sync: true); + + @override + final StreamController onThumbnailChanged = + StreamController.broadcast(sync: true); + @override Future> getSources({ required List types, @@ -35,11 +57,25 @@ class FakeDesktopCapturer extends DesktopCapturer { @override Future updateSources({required List types}) async { updateSourcesCallCount++; + for (final source in sources) { + final pending = pendingThumbnails[source.id]; + if (pending != null && source is FakeDesktopCapturerSource) { + source.thumbnail = pending; + } + if (source.thumbnail != null) onThumbnailChanged.add(source); + } return true; } + + /// Releases the event controllers. + Future close() async { + await onAdded.close(); + await onThumbnailChanged.close(); + } } -/// A [DesktopCapturerSource] with fixed values. +/// A [DesktopCapturerSource] with fixed values and, like the native one, a +/// bitmap the platform can fill in later. class FakeDesktopCapturerSource extends DesktopCapturerSource { FakeDesktopCapturerSource({ required this.id, @@ -58,7 +94,7 @@ class FakeDesktopCapturerSource extends DesktopCapturerSource { final SourceType type; @override - final Uint8List? thumbnail; + Uint8List? thumbnail; @override ThumbnailSize get thumbnailSize => ThumbnailSize(480, 300); diff --git a/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart b/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart index 25c895249..50f7af5d1 100644 --- a/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart +++ b/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart @@ -19,7 +19,10 @@ void main() { ); setUp(() { + screen.thumbnail = null; + window.thumbnail = null; capturer = FakeDesktopCapturer(sources: [screen, window]); + addTearDown(capturer.close); }); ScreenShareSourceController controller({ @@ -61,11 +64,63 @@ void main() { ); }); - test('never polls the platform for updates', () async { + test( + 'skips the capture pass when the sources carry their bitmaps', + () async { + screen.thumbnail = blueThumbnail; + window.thumbnail = greyThumbnail; + + final subject = controller(); + await pumpEventQueue(); + + expect(capturer.updateSourcesCallCount, 0); + expect(subject.value.thumbnailFor(screen), blueThumbnail); + }, + ); + + test('asks for the thumbnails once per load, never on a timer', () async { controller(); await pumpEventQueue(); - expect(capturer.updateSourcesCallCount, 0); + expect(capturer.updateSourcesCallCount, 1); + + // Whatever the old picker's two-second timer would have fired by now. + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + await pumpEventQueue(); + } + + expect(capturer.updateSourcesCallCount, 1); + }); + + test( + 'picks up a thumbnail the platform posts after the source list', + () async { + // macOS leaves the bitmaps out of the getSources result entirely. + capturer.pendingThumbnails = {screen.id: blueThumbnail}; + final subject = controller(); + + expect(subject.value.thumbnailFor(screen), isNull); + + await pumpEventQueue(); + + expect(subject.value.thumbnailFor(screen), blueThumbnail); + }, + ); + + test('drops the thumbnail of a source that is gone', () async { + capturer.pendingThumbnails = {screen.id: blueThumbnail}; + final subject = controller(); + await pumpEventQueue(); + expect(subject.value.thumbnails, hasLength(1)); + + capturer + ..sources = [window] + ..pendingThumbnails = {}; + await subject.refresh(); + await pumpEventQueue(); + + expect(subject.value.thumbnails, isEmpty); }); test('switching source type filters rather than reloading', () async { @@ -105,6 +160,7 @@ void main() { test('reports a failed load without throwing', () async { final failing = _FailingDesktopCapturer(); + addTearDown(failing.close); final subject = ScreenShareSourceController(capturer: failing); addTearDown(subject.dispose); await pumpEventQueue(); @@ -116,13 +172,10 @@ void main() { }); } -class _FailingDesktopCapturer extends DesktopCapturer { +class _FailingDesktopCapturer extends FakeDesktopCapturer { @override Future> getSources({ required List types, ThumbnailSize? thumbnailSize, }) async => throw Exception('no capturer here'); - - @override - Future updateSources({required List types}) async => false; }