From d60edcf45f355bd75dee7419edfd1b1a99f3426d Mon Sep 17 00:00:00 2001 From: help4bis Date: Fri, 3 Apr 2026 06:47:53 +1000 Subject: [PATCH] =?UTF-8?q?fix:=20remote=20video=20not=20showing=20?= =?UTF-8?q?=E2=80=94=20Room=20ChangeNotifier=20not=20listened=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _RemoteVideoView and _LocalVideoPip watched the Riverpod provider but not the LiveKit Room's ChangeNotifier. When a remote participant joined or published a track, notifyListeners() fired but no widget rebuilt. Fix: wrap both views in ListenableBuilder(listenable: room) so they rebuild on every Room event (participant connect, track subscribe, etc). Added _AudioOnlyPlaceholder for when remote has audio but no video. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../calls/presentation/call_screen.dart | 126 ++++++++++++------ 1 file changed, 86 insertions(+), 40 deletions(-) diff --git a/lib/features/calls/presentation/call_screen.dart b/lib/features/calls/presentation/call_screen.dart index 9b3b885..d5d5dc7 100644 --- a/lib/features/calls/presentation/call_screen.dart +++ b/lib/features/calls/presentation/call_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.2.1 | Created: 2026-04-01 | Updated: 2026-04-03 +// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-03 // Full call screen with LiveKit video/audio. // - Remote video: full screen background // - Local video: picture-in-picture overlay (bottom right) @@ -107,6 +107,8 @@ class _CallScreenState extends ConsumerState { // Remote video view // --------------------------------------------------------------------------- +/// Listens to the LiveKit Room's ChangeNotifier so it rebuilds when +/// remote participants join/leave or publish/unpublish tracks. class _RemoteVideoView extends ConsumerWidget { const _RemoteVideoView(); @@ -117,23 +119,28 @@ class _RemoteVideoView extends ConsumerWidget { return const _NoVideoPlaceholder(); } - final remoteParticipants = room.remoteParticipants.values.toList(); - if (remoteParticipants.isEmpty) { - return const _NoVideoPlaceholder(); - } + // Room extends ChangeNotifier — rebuilds on every participant/track event. + return ListenableBuilder( + listenable: room, + builder: (context, _) { + final remoteParticipants = room.remoteParticipants.values.toList(); + if (remoteParticipants.isEmpty) { + return const _NoVideoPlaceholder(); + } - // Show the first remote participant's first video track. - final firstParticipant = remoteParticipants.first; - final videoPubs = firstParticipant.videoTrackPublications; - if (videoPubs.isEmpty || videoPubs.first.track == null) { - return const _NoVideoPlaceholder(); - } + final firstParticipant = remoteParticipants.first; + final videoPubs = firstParticipant.videoTrackPublications; + if (videoPubs.isEmpty || videoPubs.first.track == null) { + // Participant joined but no video track yet — show audio indicator. + return _AudioOnlyPlaceholder(name: firstParticipant.identity); + } - // safe: checked non-null above - final videoTrack = videoPubs.first.track!; - return VideoTrackRenderer( - videoTrack, - fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + final videoTrack = videoPubs.first.track!; + return VideoTrackRenderer( + videoTrack, + fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + ); + }, ); } } @@ -148,32 +155,38 @@ class _LocalVideoPip extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final room = ref.watch(liveKitServiceProvider).activeRoom; - final local = room?.localParticipant; - if (local == null) return const SizedBox.shrink(); + if (room == null) return const SizedBox.shrink(); - final videoPubs = local.videoTrackPublications; - if (videoPubs.isEmpty || videoPubs.first.track == null) { - return const SizedBox.shrink(); - } + return ListenableBuilder( + listenable: room, + builder: (context, _) { + final local = room.localParticipant; + if (local == null) return const SizedBox.shrink(); - // safe: checked non-null above - final localTrack = videoPubs.first.track!; - return Positioned( - right: 16, - bottom: 120, - child: Container( - width: 100, - height: 150, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white38, width: 1), - ), - clipBehavior: Clip.hardEdge, - child: VideoTrackRenderer( - localTrack, - mirrorMode: VideoViewMirrorMode.mirror, - ), - ), + final videoPubs = local.videoTrackPublications; + if (videoPubs.isEmpty || videoPubs.first.track == null) { + return const SizedBox.shrink(); + } + + final localTrack = videoPubs.first.track!; + return Positioned( + right: 16, + bottom: 120, + child: Container( + width: 100, + height: 150, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white38, width: 1), + ), + clipBehavior: Clip.hardEdge, + child: VideoTrackRenderer( + localTrack, + mirrorMode: VideoViewMirrorMode.mirror, + ), + ), + ); + }, ); } } @@ -182,6 +195,39 @@ class _LocalVideoPip extends ConsumerWidget { // Placeholders and overlays // --------------------------------------------------------------------------- +class _AudioOnlyPlaceholder extends StatelessWidget { + const _AudioOnlyPlaceholder({required this.name}); + final String name; + + @override + Widget build(BuildContext context) { + final initials = name.isNotEmpty ? name[0].toUpperCase() : '?'; + return Container( + color: const Color(0xFF1A1A2E), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: 48, + backgroundColor: const Color(0xFF5C35C9), + child: Text(initials, + style: const TextStyle(fontSize: 36, color: Colors.white)), + ), + const SizedBox(height: 16), + Text(name, + style: const TextStyle(color: Colors.white, fontSize: 18)), + const SizedBox(height: 8), + Text('Audio only', + style: TextStyle( + color: Colors.white.withAlpha(153), fontSize: 14)), + ], + ), + ), + ); + } +} + class _NoVideoPlaceholder extends StatelessWidget { const _NoVideoPlaceholder();