From 18c1a677267ccb1b51fab9ac7dabcbc758838fc6 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Fri, 25 Oct 2024 14:17:40 +0800 Subject: [PATCH] Initial version is 1.0.0. --- CHANGELOG.md | 6 +- example/lib/main.dart | 33 +++-- lib/livekit_components.dart | 45 ++++--- lib/src/context/room_context.dart | 38 ++++-- .../builder/participant/participant_loop.dart | 127 +++++++++++------- .../participant/participant_track.dart | 44 ++++++ lib/src/ui/layout/garousel_ayout.dart | 31 ++++- lib/src/ui/layout/grid_layout.dart | 8 +- lib/src/ui/layout/layouts.dart | 13 +- lib/src/ui/layout/sorting.dart | 1 - lib/src/ui/widgets/room/chat_widget.dart | 15 +-- lib/src/ui/widgets/track/focus_toggle.dart | 15 ++- .../ui/widgets/track/track_stats_widget.dart | 4 +- pubspec.yaml | 2 +- 14 files changed, 249 insertions(+), 133 deletions(-) create mode 100644 lib/src/ui/builder/participant/participant_track.dart delete mode 100644 lib/src/ui/layout/sorting.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cc7d8..6a51c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ -## 0.0.1 +# Changelog -* TODO: Describe initial release. +## 1.0.0 (2024-10-27) + +* Initial release diff --git a/example/lib/main.dart b/example/lib/main.dart index 49c006d..03f9218 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -89,21 +89,18 @@ class MyHomePage extends StatelessWidget { (deviceScreenType == DeviceScreenType.mobile && roomCtx.isChatEnabled) ? Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 50), - child: ChatBuilder( - builder: - (context, enabled, chatCtx, messages) { - return ChatWidget( - messages: messages, - onSend: (message) => - chatCtx.sendMessage(message), - onClose: () { - chatCtx.disableChat(); - }, - ); - }, - ), + child: ChatBuilder( + builder: + (context, enabled, chatCtx, messages) { + return ChatWidget( + messages: messages, + onSend: (message) => + chatCtx.sendMessage(message), + onClose: () { + chatCtx.disableChat(); + }, + ); + }, ), ) : Expanded( @@ -119,9 +116,9 @@ class MyHomePage extends StatelessWidget { /// layout builder layoutBuilder: - roomCtx.focusedTrackSid != null - ? const CarouselLayoutBuilder() - : const GridLayoutBuilder(), + roomCtx.isPinnedTracksEmpty + ? const GridLayoutBuilder() + : const CarouselLayoutBuilder(), /// participant builder participantBuilder: (context) { diff --git a/lib/livekit_components.dart b/lib/livekit_components.dart index 2aa1be8..a0d60e0 100644 --- a/lib/livekit_components.dart +++ b/lib/livekit_components.dart @@ -10,13 +10,19 @@ export 'src/debug/logger.dart'; export 'src/ui/builder/camera_preview.dart'; export 'src/ui/builder/room/chat_toggle.dart'; export 'src/ui/builder/room/chat.dart'; -export 'src/ui/builder/track/connection_quality_indicator.dart'; export 'src/ui/builder/room/disconnect_button.dart'; -export 'src/ui/builder/track/e2e_encryption_indicator.dart'; -export 'src/ui/builder/track/is_speaking_indicator.dart'; export 'src/ui/builder/room/join_button.dart'; export 'src/ui/builder/room/media_device_select_button.dart'; export 'src/ui/builder/room/media_device.dart'; +export 'src/ui/builder/room/room_connection_state.dart'; +export 'src/ui/builder/room/room_metadata.dart'; +export 'src/ui/builder/room/room_name.dart'; +export 'src/ui/builder/room/room_participants.dart'; +export 'src/ui/builder/room/room.dart'; +export 'src/ui/builder/room/screenshare_toggle.dart'; +export 'src/ui/builder/room/room_active_recording_indicator.dart'; + +export 'src/ui/builder/participant/participant_track.dart'; export 'src/ui/builder/participant/participant_attributes.dart'; export 'src/ui/builder/participant/participant_loop.dart'; export 'src/ui/builder/participant/participant_metadata.dart'; @@ -25,33 +31,32 @@ export 'src/ui/builder/participant/participant_name.dart'; export 'src/ui/builder/participant/participant_kind.dart'; export 'src/ui/builder/participant/participant_permissions.dart'; export 'src/ui/builder/participant/participant_transcription.dart'; -export 'src/ui/builder/room/room_active_recording_indicator.dart'; -export 'src/ui/builder/room/room_connection_state.dart'; -export 'src/ui/builder/room/room_metadata.dart'; -export 'src/ui/builder/room/room_name.dart'; -export 'src/ui/builder/room/room_participants.dart'; -export 'src/ui/builder/room/room.dart'; -export 'src/ui/widgets/toast.dart'; -export 'src/ui/builder/room/screenshare_toggle.dart'; -export 'src/ui/widgets/camera_preview.dart'; +export 'src/ui/builder/track/connection_quality_indicator.dart'; +export 'src/ui/builder/track/e2e_encryption_indicator.dart'; +export 'src/ui/builder/track/is_speaking_indicator.dart'; + +export 'src/ui/widgets/participant/connection_quality_indicator.dart'; +export 'src/ui/widgets/participant/participant_status_bar.dart'; +export 'src/ui/widgets/participant/participant_tile_widget.dart'; +export 'src/ui/widgets/participant/is_speaking_indicator.dart'; + export 'src/ui/widgets/room/camera_select_button.dart'; export 'src/ui/widgets/room/chat_toggle.dart'; export 'src/ui/widgets/room/chat_widget.dart'; -export 'src/ui/widgets/participant/connection_quality_indicator.dart'; +export 'src/ui/widgets/room/media_device_select_button.dart'; +export 'src/ui/widgets/room/microphone_select_button.dart'; export 'src/ui/widgets/room/control_bar.dart'; export 'src/ui/widgets/room/disconnect_button.dart'; +export 'src/ui/widgets/room/screenshare_toggle.dart'; + export 'src/ui/widgets/track/focus_toggle.dart'; -export 'src/ui/widgets/participant/is_speaking_indicator.dart'; -export 'src/ui/widgets/room/media_device_select_button.dart'; -export 'src/ui/widgets/room/microphone_select_button.dart'; export 'src/ui/widgets/track/no_track_widget.dart'; -export 'src/ui/widgets/participant/participant_status_bar.dart'; -export 'src/ui/widgets/participant/participant_tile_widget.dart'; export 'src/ui/widgets/track/video_track_widget.dart'; -export 'src/ui/widgets/room/screenshare_toggle.dart'; -export 'src/ui/widgets/theme.dart'; export 'src/ui/widgets/track/track_stats_widget.dart'; +export 'src/ui/widgets/theme.dart'; +export 'src/ui/widgets/toast.dart'; +export 'src/ui/widgets/camera_preview.dart'; export 'src/ui/layout/layouts.dart'; export 'src/ui/layout/grid_layout.dart'; diff --git a/lib/src/context/room_context.dart b/lib/src/context/room_context.dart index e0b13ff..8cbbb32 100644 --- a/lib/src/context/room_context.dart +++ b/lib/src/context/room_context.dart @@ -50,7 +50,7 @@ class RoomContext extends ChangeNotifier with ChatContextMixin { _roomMetadata = event.room.metadata; _activeRecording = event.room.isRecording; _roomName = event.room.name; - _sortParticipants(); + _buildParticipants(); notifyListeners(); }) ..on((event) { @@ -86,7 +86,7 @@ class RoomContext extends ChangeNotifier with ChatContextMixin { ..on((event) { Debug.event( 'RoomContext: ParticipantConnectedEvent participant = ${event.participant.identity}'); - _sortParticipants(); + _buildParticipants(); }) ..on((event) { Debug.event( @@ -97,21 +97,21 @@ class RoomContext extends ChangeNotifier with ChatContextMixin { }) ..on((event) { Debug.event('ParticipantContext: TrackPublishedEvent'); - _sortParticipants(); + _buildParticipants(); }) ..on((event) { Debug.event('ParticipantContext: TrackUnpublishedEvent'); - _sortParticipants(); + _buildParticipants(); }) ..on((event) { Debug.event( 'RoomContext: LocalTrackPublishedEvent track = ${event.publication.sid}'); - _sortParticipants(); + _buildParticipants(); }) ..on((event) { Debug.event( 'RoomContext: LocalTrackUnpublishedEvent track = ${event.publication.sid}'); - _sortParticipants(); + _buildParticipants(); }); if (connect && url != null && token != null) { @@ -209,7 +209,7 @@ class RoomContext extends ChangeNotifier with ChatContextMixin { /// Get the list of participants. List get participants => _participants; - void _sortParticipants() { + void _buildParticipants() { _participants.clear(); if (!connected) { @@ -224,14 +224,28 @@ class RoomContext extends ChangeNotifier with ChatContextMixin { notifyListeners(); } - void setFocusedTrack(String? sid) { - _focusedTrackSid = sid; - Debug.log('Focused track: $sid'); + void pinningTrack(String sid) { + _pinnedTrackSid.remove(sid); + _pinnedTrackSid.insert(0, sid); + Debug.event('Pinning track: $sid'); notifyListeners(); } - String? _focusedTrackSid; - String? get focusedTrackSid => _focusedTrackSid; + void unpinningTrack(String sid) { + _pinnedTrackSid.remove(sid); + Debug.event('Unpinning track: $sid'); + notifyListeners(); + } + + void clearPinnedTracks() { + _pinnedTrackSid.clear(); + notifyListeners(); + } + + final List _pinnedTrackSid = []; + List get pinnedTracks => _pinnedTrackSid; + + bool get isPinnedTracksEmpty => _pinnedTrackSid.isEmpty; LocalVideoTrack? _localVideoTrack; diff --git a/lib/src/ui/builder/participant/participant_loop.dart b/lib/src/ui/builder/participant/participant_loop.dart index eaa811c..191d9e8 100644 --- a/lib/src/ui/builder/participant/participant_loop.dart +++ b/lib/src/ui/builder/participant/participant_loop.dart @@ -3,28 +3,63 @@ import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:provider/provider.dart'; -import '../../../context/participant_context.dart'; import '../../../context/room_context.dart'; -import '../../../context/track_reference_context.dart'; import '../../../debug/logger.dart'; import '../../layout/grid_layout.dart'; import '../../layout/layouts.dart'; +import 'participant_track.dart'; class ParticipantLoop extends StatelessWidget { const ParticipantLoop({ super.key, required this.participantBuilder, this.layoutBuilder = const GridLayoutBuilder(), + this.sorting, this.showAudioTracks = false, this.showVideoTracks = true, }); final WidgetBuilder participantBuilder; + final Map Function( + Map tracks)? sorting; final ParticipantLayoutBuilder layoutBuilder; final bool showAudioTracks; final bool showVideoTracks; + Map buildTracksMap( + bool audio, bool video, List participants) { + final Map trackMap = {}; + int index = 0; + for (Participant participant in participants) { + Debug.log('=> participant ${participant.identity}, index: $index'); + index++; + var tracks = participant.trackPublications.values; + for (var track in tracks) { + if (track.kind == TrackType.AUDIO && !audio) { + continue; + } + if (track.kind == TrackType.VIDEO && !video) { + continue; + } + trackMap[participant] = track; + + Debug.log( + '=> ${track.source.toString()} track ${track.sid} for ${participant.identity}'); + } + + if (!audio && !tracks.any((t) => t.kind == TrackType.VIDEO) || + !video && tracks.any((t) => t.kind == TrackType.AUDIO) || + tracks.isEmpty) { + trackMap[participant] = null; + + Debug.log('=> no tracks for ${participant.identity}'); + } + } + + return trackMap; + } + @override Widget build(BuildContext context) { return Consumer( @@ -35,65 +70,53 @@ class ParticipantLoop extends StatelessWidget { shouldRebuild: (previous, next) => previous.length != next.length, builder: (context, participants, child) { var roomCtx = Provider.of(context); - final Map trackWidgets = {}; - int index = 0; - for (Participant participant in participants) { - Debug.log( - '=> participant ${participant.identity}, index: $index'); - index++; - var tracks = participant.trackPublications.values; - for (var track in tracks) { - if (track.kind == TrackType.AUDIO && !showAudioTracks) { - continue; - } - if (track.kind == TrackType.VIDEO && !showVideoTracks) { - continue; - } - trackWidgets[track.sid] = MultiProvider( - providers: [ - ChangeNotifierProvider( - key: ValueKey(track.sid), - create: (_) => ParticipantContext(participant), - ), - ChangeNotifierProvider( - key: ValueKey(track.sid), - create: (_) => - TrackReferenceContext(participant, pub: track), - ), - ], - child: participantBuilder(context), - ); - Debug.log( - '=> ${track.source.toString()} track ${track.sid} for ${participant.identity}'); - } + Map trackWidgets = {}; - if (!showAudioTracks && - !tracks.any((t) => t.kind == TrackType.VIDEO) || - !showVideoTracks && - tracks.any((t) => t.kind == TrackType.AUDIO) || - tracks.isEmpty) { - trackWidgets[participant.identity] = ChangeNotifierProvider( - key: ValueKey(participant.identity), - create: (_) => ParticipantContext(participant), - child: participantBuilder(context), - ); + var trackMap = buildTracksMap( + showAudioTracks, showVideoTracks, participants); + + if (sorting != null) { + trackMap = sorting!(trackMap); + } - Debug.log('=> no tracks for ${participant.identity}'); + for (var item in trackMap.entries) { + var participant = item.key; + var track = item.value; + if (track == null) { + trackWidgets[participant.identity] = ParticipantTrack( + participant: participant, + builder: (context) => participantBuilder(context), + ); + } else { + trackWidgets[track.sid] = ParticipantTrack( + participant: participant, + track: track, + builder: (context) => participantBuilder(context), + ); } } + var children = trackWidgets.values.toList(); + List pinned = []; - /// Move focused track to the front - var focused = trackWidgets.entries - .where((e) => e.key == roomCtx.focusedTrackSid) - .firstOrNull; + /// Move focused tracks to the pinned list + var focused = roomCtx.pinnedTracks + .map((e) { + if (trackWidgets.containsKey(e)) { + return trackWidgets[e]!; + } + return null; + }) + .whereType() + .toList(); - if (focused != null) { - children.remove(focused.value); - children.insert(0, focused.value); + if (focused.isNotEmpty) { + children.removeWhere((e) => focused.contains(e)); + pinned.addAll(focused); } - return layoutBuilder.build(context, children); + + return layoutBuilder.build(context, children, pinned); }); }, ); diff --git a/lib/src/ui/builder/participant/participant_track.dart b/lib/src/ui/builder/participant/participant_track.dart new file mode 100644 index 0000000..825ddfb --- /dev/null +++ b/lib/src/ui/builder/participant/participant_track.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:provider/provider.dart'; + +import '../../../context/participant_context.dart'; +import '../../../context/track_reference_context.dart'; + +class ParticipantTrack extends StatelessWidget { + const ParticipantTrack({ + super.key, + required this.participant, + this.track, + required this.builder, + }); + + final lk.Participant participant; + final lk.TrackPublication? track; + + final Widget Function(BuildContext context) builder; + + @override + Widget build(BuildContext context) { + if (track == null) { + return ChangeNotifierProvider( + key: ValueKey(participant.identity), + create: (_) => ParticipantContext(participant), + child: builder(context), + ); + } + return MultiProvider( + providers: [ + ChangeNotifierProvider( + key: ValueKey(track!.sid), + create: (_) => ParticipantContext(participant), + ), + ChangeNotifierProvider( + key: ValueKey(track!.sid), + create: (_) => TrackReferenceContext(participant, pub: track), + ), + ], + child: builder(context), + ); + } +} diff --git a/lib/src/ui/layout/garousel_ayout.dart b/lib/src/ui/layout/garousel_ayout.dart index c5cdaff..f5b5d08 100644 --- a/lib/src/ui/layout/garousel_ayout.dart +++ b/lib/src/ui/layout/garousel_ayout.dart @@ -10,7 +10,24 @@ class CarouselLayoutBuilder implements ParticipantLayoutBuilder { const CarouselLayoutBuilder(); @override - Widget build(BuildContext context, List children) { + Widget build( + BuildContext context, List children, List? pinned) { + Widget? pinnedWidget; + List otherWidgets = []; + + if (pinned != null) { + pinnedWidget = pinned.firstOrNull; + if (children.length > 1) { + otherWidgets = pinned.skip(1).toList(); + otherWidgets.addAll(children); + } + } else { + pinnedWidget = children.firstOrNull; + if (children.length > 1) { + otherWidgets = children.skip(1).toList(); + } + } + var deviceScreenType = getDeviceType(MediaQuery.of(context).size); if (deviceScreenType == DeviceScreenType.mobile) { return Column( @@ -21,16 +38,16 @@ class CarouselLayoutBuilder implements ParticipantLayoutBuilder { height: 80, child: ListView.builder( scrollDirection: Axis.horizontal, - itemCount: math.max(0, children.length - 1), + itemCount: math.max(0, otherWidgets.length), itemBuilder: (BuildContext context, int index) => SizedBox( width: 120, height: 80, - child: children[index + 1], + child: otherWidgets[index], ), ), ), Expanded( - child: children.isNotEmpty ? children.first : Container(), + child: pinnedWidget ?? Container(), ), ], ); @@ -42,16 +59,16 @@ class CarouselLayoutBuilder implements ParticipantLayoutBuilder { width: 180, child: ListView.builder( scrollDirection: Axis.vertical, - itemCount: math.max(0, children.length - 1), + itemCount: math.max(0, otherWidgets.length), itemBuilder: (BuildContext context, int index) => SizedBox( width: 180, height: 120, - child: children[index + 1], + child: otherWidgets[index], ), ), ), Expanded( - child: children.isNotEmpty ? children.first : Container(), + child: pinnedWidget ?? Container(), ), ], ); diff --git a/lib/src/ui/layout/grid_layout.dart b/lib/src/ui/layout/grid_layout.dart index 7a133bc..6ee0438 100644 --- a/lib/src/ui/layout/grid_layout.dart +++ b/lib/src/ui/layout/grid_layout.dart @@ -8,12 +8,16 @@ class GridLayoutBuilder implements ParticipantLayoutBuilder { const GridLayoutBuilder(); @override - Widget build(BuildContext context, List children) { + Widget build( + BuildContext context, List children, List? pinned) { var deviceScreenType = getDeviceType(MediaQuery.of(context).size); return GridView.count( crossAxisCount: deviceScreenType == DeviceScreenType.mobile ? 2 : 4, childAspectRatio: 1.5, - children: children, + children: [ + if (pinned != null) ...pinned, + ...children, + ], ); } } diff --git a/lib/src/ui/layout/layouts.dart b/lib/src/ui/layout/layouts.dart index b8cd2bf..1b65767 100644 --- a/lib/src/ui/layout/layouts.dart +++ b/lib/src/ui/layout/layouts.dart @@ -1,5 +1,16 @@ import 'package:flutter/widgets.dart'; +class ParticipantIdentity { + const ParticipantIdentity({ + required this.identity, + this.sid, + }); + + final String identity; + final String? sid; +} + abstract class ParticipantLayoutBuilder { - Widget build(BuildContext context, List children); + Widget build( + BuildContext context, List children, List? pinned); } diff --git a/lib/src/ui/layout/sorting.dart b/lib/src/ui/layout/sorting.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/src/ui/layout/sorting.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/src/ui/widgets/room/chat_widget.dart b/lib/src/ui/widgets/room/chat_widget.dart index 0e3119f..2540e87 100644 --- a/lib/src/ui/widgets/room/chat_widget.dart +++ b/lib/src/ui/widgets/room/chat_widget.dart @@ -47,6 +47,11 @@ class ChatWidget extends StatelessWidget { return msgWidgets; } + void scrollToBottom() { + _scrollController.animateTo(_scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + } + @override Widget build(BuildContext context) { return Container( @@ -94,18 +99,12 @@ class ChatWidget extends StatelessWidget { replyWidgetColor: LKColors.lkDarkBlue, onSend: (msg) { onSend(msg); - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut); + scrollToBottom(); }, onTextChanged: (msg) { if (msg.isNotEmpty && msg.codeUnits.last == 10) { onSend(msg.substring(0, msg.length - 1)); - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut); + scrollToBottom(); } }, ), diff --git a/lib/src/ui/widgets/track/focus_toggle.dart b/lib/src/ui/widgets/track/focus_toggle.dart index b64c222..e7c4d23 100644 --- a/lib/src/ui/widgets/track/focus_toggle.dart +++ b/lib/src/ui/widgets/track/focus_toggle.dart @@ -20,22 +20,23 @@ class FocusToggle extends StatelessWidget { if (trackCtx == null) { return const SizedBox(); } + var showBackToGridView = + roomCtx.pinnedTracks.contains(sid) && sid == roomCtx.pinnedTracks.first; + return Padding( padding: const EdgeInsets.all(2), child: IconButton( - icon: Icon(sid == roomCtx.focusedTrackSid - ? Icons.grid_view - : Icons.open_in_full), + icon: Icon(showBackToGridView ? Icons.grid_view : Icons.open_in_full), color: Colors.white70, onPressed: () { if (sid == null) { return; } - if (sid == roomCtx.focusedTrackSid) { - roomCtx.setFocusedTrack(null); - return; + if (showBackToGridView) { + roomCtx.clearPinnedTracks(); + } else { + roomCtx.pinningTrack(sid); } - roomCtx.setFocusedTrack(sid); }, ), ); diff --git a/lib/src/ui/widgets/track/track_stats_widget.dart b/lib/src/ui/widgets/track/track_stats_widget.dart index 42e54ee..c6ac3c6 100644 --- a/lib/src/ui/widgets/track/track_stats_widget.dart +++ b/lib/src/ui/widgets/track/track_stats_widget.dart @@ -11,9 +11,9 @@ class TrackStatsWidget extends StatelessWidget { @override Widget build(BuildContext context) { var trackCtx = Provider.of(context); - var roomCtx = Provider.of(context); + var roomCtx = Provider.of(context); - if (trackCtx == null || roomCtx?.focusedTrackSid != trackCtx.sid) { + if (trackCtx == null || !roomCtx.pinnedTracks.contains(trackCtx.sid)) { return const SizedBox(); } diff --git a/pubspec.yaml b/pubspec.yaml index de2ff4b..e4d715e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: livekit_components description: "Out-of-the-box flutter widgets for livekit client." -version: 0.0.1 +version: 1.0.0 homepage: https://github.com/livekit/components-flutter environment: