You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			628 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Dart
		
	
			
		
		
	
	
			628 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Dart
		
	
/*
 | 
						|
 *   Famedly
 | 
						|
 *   Copyright (C) 2019, 2020, 2021 Famedly GmbH
 | 
						|
 *
 | 
						|
 *   This program is free software: you can redistribute it and/or modify
 | 
						|
 *   it under the terms of the GNU Affero General Public License as
 | 
						|
 *   published by the Free Software Foundation, either version 3 of the
 | 
						|
 *   License, or (at your option) any later version.
 | 
						|
 *
 | 
						|
 *   This program is distributed in the hope that it will be useful,
 | 
						|
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
						|
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 | 
						|
 *   GNU Affero General Public License for more details.
 | 
						|
 *
 | 
						|
 *   You should have received a copy of the GNU Affero General Public License
 | 
						|
 *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | 
						|
 */
 | 
						|
 | 
						|
import 'dart:async';
 | 
						|
import 'dart:math';
 | 
						|
 | 
						|
import 'package:flutter/foundation.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:flutter/services.dart';
 | 
						|
 | 
						|
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
 | 
						|
import 'package:flutter_gen/gen_l10n/l10n.dart';
 | 
						|
import 'package:flutter_webrtc/flutter_webrtc.dart' hide VideoRenderer;
 | 
						|
import 'package:just_audio/just_audio.dart';
 | 
						|
import 'package:matrix/matrix.dart';
 | 
						|
import 'package:wakelock_plus/wakelock_plus.dart';
 | 
						|
 | 
						|
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
 | 
						|
import 'package:fluffychat/utils/platform_infos.dart';
 | 
						|
import 'package:fluffychat/utils/voip/video_renderer.dart';
 | 
						|
import 'package:fluffychat/widgets/avatar.dart';
 | 
						|
import 'pip/pip_view.dart';
 | 
						|
 | 
						|
class _StreamView extends StatelessWidget {
 | 
						|
  const _StreamView(
 | 
						|
    this.wrappedStream, {
 | 
						|
    this.mainView = false,
 | 
						|
    required this.matrixClient,
 | 
						|
  });
 | 
						|
 | 
						|
  final WrappedMediaStream wrappedStream;
 | 
						|
  final Client matrixClient;
 | 
						|
 | 
						|
  final bool mainView;
 | 
						|
 | 
						|
  Uri? get avatarUrl => wrappedStream.getUser().avatarUrl;
 | 
						|
 | 
						|
  String? get displayName => wrappedStream.displayName;
 | 
						|
 | 
						|
  String get avatarName => wrappedStream.avatarName;
 | 
						|
 | 
						|
  bool get isLocal => wrappedStream.isLocal();
 | 
						|
 | 
						|
  bool get mirrored =>
 | 
						|
      wrappedStream.isLocal() &&
 | 
						|
      wrappedStream.purpose == SDPStreamMetadataPurpose.Usermedia;
 | 
						|
 | 
						|
  bool get audioMuted => wrappedStream.audioMuted;
 | 
						|
 | 
						|
  bool get videoMuted => wrappedStream.videoMuted;
 | 
						|
 | 
						|
  bool get isScreenSharing =>
 | 
						|
      wrappedStream.purpose == SDPStreamMetadataPurpose.Screenshare;
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    return Container(
 | 
						|
      decoration: const BoxDecoration(
 | 
						|
        color: Colors.black54,
 | 
						|
      ),
 | 
						|
      child: Stack(
 | 
						|
        alignment: Alignment.center,
 | 
						|
        children: <Widget>[
 | 
						|
          VideoRenderer(
 | 
						|
            wrappedStream,
 | 
						|
            mirror: mirrored,
 | 
						|
            fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
 | 
						|
          ),
 | 
						|
          if (videoMuted) ...[
 | 
						|
            Container(color: Colors.black54),
 | 
						|
            Positioned(
 | 
						|
              child: Avatar(
 | 
						|
                mxContent: avatarUrl,
 | 
						|
                name: displayName,
 | 
						|
                size: mainView ? 96 : 48,
 | 
						|
                client: matrixClient,
 | 
						|
                // textSize: mainView ? 36 : 24,
 | 
						|
                // matrixClient: matrixClient,
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
          ],
 | 
						|
          if (!isScreenSharing)
 | 
						|
            Positioned(
 | 
						|
              left: 4.0,
 | 
						|
              bottom: 4.0,
 | 
						|
              child: Icon(
 | 
						|
                audioMuted ? Icons.mic_off : Icons.mic,
 | 
						|
                color: Colors.white,
 | 
						|
                size: 18.0,
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class Calling extends StatefulWidget {
 | 
						|
  final VoidCallback? onClear;
 | 
						|
  final BuildContext context;
 | 
						|
  final String callId;
 | 
						|
  final CallSession call;
 | 
						|
  final Client client;
 | 
						|
 | 
						|
  const Calling({
 | 
						|
    required this.context,
 | 
						|
    required this.call,
 | 
						|
    required this.client,
 | 
						|
    required this.callId,
 | 
						|
    this.onClear,
 | 
						|
    super.key,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  MyCallingPage createState() => MyCallingPage();
 | 
						|
}
 | 
						|
 | 
						|
class MyCallingPage extends State<Calling> {
 | 
						|
  Room? get room => call.room;
 | 
						|
 | 
						|
  String get displayName => call.room.getLocalizedDisplayname(
 | 
						|
        MatrixLocals(L10n.of(widget.context)!),
 | 
						|
      );
 | 
						|
 | 
						|
  String get callId => widget.callId;
 | 
						|
 | 
						|
  CallSession get call => widget.call;
 | 
						|
 | 
						|
  MediaStream? get localStream {
 | 
						|
    if (call.localUserMediaStream != null) {
 | 
						|
      return call.localUserMediaStream!.stream!;
 | 
						|
    }
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
 | 
						|
  MediaStream? get remoteStream {
 | 
						|
    if (call.getRemoteStreams.isNotEmpty) {
 | 
						|
      return call.getRemoteStreams[0].stream!;
 | 
						|
    }
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
 | 
						|
  bool get isMicrophoneMuted => call.isMicrophoneMuted;
 | 
						|
 | 
						|
  bool get isLocalVideoMuted => call.isLocalVideoMuted;
 | 
						|
 | 
						|
  bool get isScreensharingEnabled => call.screensharingEnabled;
 | 
						|
 | 
						|
  bool get isRemoteOnHold => call.remoteOnHold;
 | 
						|
 | 
						|
  bool get voiceonly => call.type == CallType.kVoice;
 | 
						|
 | 
						|
  bool get connecting => call.state == CallState.kConnecting;
 | 
						|
 | 
						|
  bool get connected => call.state == CallState.kConnected;
 | 
						|
 | 
						|
  double? _localVideoHeight;
 | 
						|
  double? _localVideoWidth;
 | 
						|
  EdgeInsetsGeometry? _localVideoMargin;
 | 
						|
  CallState? _state;
 | 
						|
 | 
						|
  void _playCallSound() async {
 | 
						|
    const path = 'assets/sounds/call.ogg';
 | 
						|
    if (kIsWeb || PlatformInfos.isMobile || PlatformInfos.isMacOS) {
 | 
						|
      final player = AudioPlayer();
 | 
						|
      await player.setAsset(path);
 | 
						|
      player.play();
 | 
						|
    } else {
 | 
						|
      Logs().w('Playing sound not implemented for this platform!');
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  void initState() {
 | 
						|
    super.initState();
 | 
						|
    initialize();
 | 
						|
    _playCallSound();
 | 
						|
  }
 | 
						|
 | 
						|
  void initialize() async {
 | 
						|
    final call = this.call;
 | 
						|
    call.onCallStateChanged.stream.listen(_handleCallState);
 | 
						|
    call.onCallEventChanged.stream.listen((event) {
 | 
						|
      if (event == CallStateChange.kFeedsChanged) {
 | 
						|
        setState(() {
 | 
						|
          call.tryRemoveStopedStreams();
 | 
						|
        });
 | 
						|
      } else if (event == CallStateChange.kLocalHoldUnhold ||
 | 
						|
          event == CallStateChange.kRemoteHoldUnhold) {
 | 
						|
        setState(() {});
 | 
						|
        Logs().i(
 | 
						|
          'Call hold event: local ${call.localHold}, remote ${call.remoteOnHold}',
 | 
						|
        );
 | 
						|
      }
 | 
						|
    });
 | 
						|
    _state = call.state;
 | 
						|
 | 
						|
    if (call.type == CallType.kVideo) {
 | 
						|
      try {
 | 
						|
        // Enable wakelock (keep screen on)
 | 
						|
        unawaited(WakelockPlus.enable());
 | 
						|
      } catch (_) {}
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  void cleanUp() {
 | 
						|
    Timer(
 | 
						|
      const Duration(seconds: 2),
 | 
						|
      () => widget.onClear?.call(),
 | 
						|
    );
 | 
						|
    if (call.type == CallType.kVideo) {
 | 
						|
      try {
 | 
						|
        unawaited(WakelockPlus.disable());
 | 
						|
      } catch (_) {}
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  void dispose() {
 | 
						|
    super.dispose();
 | 
						|
    call.cleanUp.call();
 | 
						|
  }
 | 
						|
 | 
						|
  void _resizeLocalVideo(Orientation orientation) {
 | 
						|
    final shortSide = min(
 | 
						|
      MediaQuery.of(widget.context).size.width,
 | 
						|
      MediaQuery.of(widget.context).size.height,
 | 
						|
    );
 | 
						|
    _localVideoMargin = remoteStream != null
 | 
						|
        ? const EdgeInsets.only(top: 20.0, right: 20.0)
 | 
						|
        : EdgeInsets.zero;
 | 
						|
    _localVideoWidth = remoteStream != null
 | 
						|
        ? shortSide / 3
 | 
						|
        : MediaQuery.of(widget.context).size.width;
 | 
						|
    _localVideoHeight = remoteStream != null
 | 
						|
        ? shortSide / 4
 | 
						|
        : MediaQuery.of(widget.context).size.height;
 | 
						|
  }
 | 
						|
 | 
						|
  void _handleCallState(CallState state) {
 | 
						|
    Logs().v('CallingPage::handleCallState: ${state.toString()}');
 | 
						|
    if ({CallState.kConnected, CallState.kEnded}.contains(state)) {
 | 
						|
      HapticFeedback.heavyImpact();
 | 
						|
    }
 | 
						|
 | 
						|
    if (mounted) {
 | 
						|
      setState(() {
 | 
						|
        _state = state;
 | 
						|
        if (_state == CallState.kEnded) cleanUp();
 | 
						|
      });
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  void _answerCall() {
 | 
						|
    setState(() {
 | 
						|
      call.answer();
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  void _hangUp() {
 | 
						|
    setState(() {
 | 
						|
      if (call.isRinging) {
 | 
						|
        call.reject();
 | 
						|
      } else {
 | 
						|
        call.hangup(reason: CallErrorCode.userHangup);
 | 
						|
      }
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  void _muteMic() {
 | 
						|
    setState(() {
 | 
						|
      call.setMicrophoneMuted(!call.isMicrophoneMuted);
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  void _screenSharing() async {
 | 
						|
    if (PlatformInfos.isAndroid) {
 | 
						|
      if (!call.screensharingEnabled) {
 | 
						|
        FlutterForegroundTask.init(
 | 
						|
          androidNotificationOptions: AndroidNotificationOptions(
 | 
						|
            channelId: 'notification_channel_id',
 | 
						|
            channelName: 'Foreground Notification',
 | 
						|
            channelDescription:
 | 
						|
                L10n.of(widget.context)!.foregroundServiceRunning,
 | 
						|
          ),
 | 
						|
          iosNotificationOptions: const IOSNotificationOptions(),
 | 
						|
          foregroundTaskOptions: const ForegroundTaskOptions(),
 | 
						|
        );
 | 
						|
        FlutterForegroundTask.startService(
 | 
						|
          notificationTitle: L10n.of(widget.context)!.screenSharingTitle,
 | 
						|
          notificationText: L10n.of(widget.context)!.screenSharingDetail,
 | 
						|
        );
 | 
						|
      } else {
 | 
						|
        FlutterForegroundTask.stopService();
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    setState(() {
 | 
						|
      call.setScreensharingEnabled(!call.screensharingEnabled);
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  void _remoteOnHold() {
 | 
						|
    setState(() {
 | 
						|
      call.setRemoteOnHold(!call.remoteOnHold);
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  void _muteCamera() {
 | 
						|
    setState(() {
 | 
						|
      call.setLocalVideoMuted(!call.isLocalVideoMuted);
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  void _switchCamera() async {
 | 
						|
    if (call.localUserMediaStream != null) {
 | 
						|
      await Helper.switchCamera(
 | 
						|
        call.localUserMediaStream!.stream!.getVideoTracks()[0],
 | 
						|
      );
 | 
						|
    }
 | 
						|
    setState(() {});
 | 
						|
  }
 | 
						|
 | 
						|
  /*
 | 
						|
  void _switchSpeaker() {
 | 
						|
    setState(() {
 | 
						|
      session.setSpeakerOn();
 | 
						|
    });
 | 
						|
  }
 | 
						|
  */
 | 
						|
 | 
						|
  List<Widget> _buildActionButtons(bool isFloating) {
 | 
						|
    if (isFloating) {
 | 
						|
      return [];
 | 
						|
    }
 | 
						|
 | 
						|
    final switchCameraButton = FloatingActionButton(
 | 
						|
      heroTag: 'switchCamera',
 | 
						|
      onPressed: _switchCamera,
 | 
						|
      backgroundColor: Colors.black45,
 | 
						|
      child: const Icon(Icons.switch_camera),
 | 
						|
    );
 | 
						|
    /*
 | 
						|
    var switchSpeakerButton = FloatingActionButton(
 | 
						|
      heroTag: 'switchSpeaker',
 | 
						|
      child: Icon(_speakerOn ? Icons.volume_up : Icons.volume_off),
 | 
						|
      onPressed: _switchSpeaker,
 | 
						|
      foregroundColor: Colors.black54,
 | 
						|
      backgroundColor: Theme.of(widget.context).backgroundColor,
 | 
						|
    );
 | 
						|
    */
 | 
						|
    final hangupButton = FloatingActionButton(
 | 
						|
      heroTag: 'hangup',
 | 
						|
      onPressed: _hangUp,
 | 
						|
      tooltip: 'Hangup',
 | 
						|
      backgroundColor: _state == CallState.kEnded ? Colors.black45 : Colors.red,
 | 
						|
      child: const Icon(Icons.call_end),
 | 
						|
    );
 | 
						|
 | 
						|
    final answerButton = FloatingActionButton(
 | 
						|
      heroTag: 'answer',
 | 
						|
      onPressed: _answerCall,
 | 
						|
      tooltip: 'Answer',
 | 
						|
      backgroundColor: Colors.green,
 | 
						|
      child: const Icon(Icons.phone),
 | 
						|
    );
 | 
						|
 | 
						|
    final muteMicButton = FloatingActionButton(
 | 
						|
      heroTag: 'muteMic',
 | 
						|
      onPressed: _muteMic,
 | 
						|
      foregroundColor: isMicrophoneMuted ? Colors.black26 : Colors.white,
 | 
						|
      backgroundColor: isMicrophoneMuted ? Colors.white : Colors.black45,
 | 
						|
      child: Icon(isMicrophoneMuted ? Icons.mic_off : Icons.mic),
 | 
						|
    );
 | 
						|
 | 
						|
    final screenSharingButton = FloatingActionButton(
 | 
						|
      heroTag: 'screenSharing',
 | 
						|
      onPressed: _screenSharing,
 | 
						|
      foregroundColor: isScreensharingEnabled ? Colors.black26 : Colors.white,
 | 
						|
      backgroundColor: isScreensharingEnabled ? Colors.white : Colors.black45,
 | 
						|
      child: const Icon(Icons.desktop_mac),
 | 
						|
    );
 | 
						|
 | 
						|
    final holdButton = FloatingActionButton(
 | 
						|
      heroTag: 'hold',
 | 
						|
      onPressed: _remoteOnHold,
 | 
						|
      foregroundColor: isRemoteOnHold ? Colors.black26 : Colors.white,
 | 
						|
      backgroundColor: isRemoteOnHold ? Colors.white : Colors.black45,
 | 
						|
      child: const Icon(Icons.pause),
 | 
						|
    );
 | 
						|
 | 
						|
    final muteCameraButton = FloatingActionButton(
 | 
						|
      heroTag: 'muteCam',
 | 
						|
      onPressed: _muteCamera,
 | 
						|
      foregroundColor: isLocalVideoMuted ? Colors.black26 : Colors.white,
 | 
						|
      backgroundColor: isLocalVideoMuted ? Colors.white : Colors.black45,
 | 
						|
      child: Icon(isLocalVideoMuted ? Icons.videocam_off : Icons.videocam),
 | 
						|
    );
 | 
						|
 | 
						|
    switch (_state) {
 | 
						|
      case CallState.kRinging:
 | 
						|
      case CallState.kInviteSent:
 | 
						|
      case CallState.kCreateAnswer:
 | 
						|
      case CallState.kConnecting:
 | 
						|
        return call.isOutgoing
 | 
						|
            ? <Widget>[hangupButton]
 | 
						|
            : <Widget>[answerButton, hangupButton];
 | 
						|
      case CallState.kConnected:
 | 
						|
        return <Widget>[
 | 
						|
          muteMicButton,
 | 
						|
          //switchSpeakerButton,
 | 
						|
          if (!voiceonly && !kIsWeb) switchCameraButton,
 | 
						|
          if (!voiceonly) muteCameraButton,
 | 
						|
          if (PlatformInfos.isMobile || PlatformInfos.isWeb)
 | 
						|
            screenSharingButton,
 | 
						|
          holdButton,
 | 
						|
          hangupButton,
 | 
						|
        ];
 | 
						|
      case CallState.kEnded:
 | 
						|
        return <Widget>[
 | 
						|
          hangupButton,
 | 
						|
        ];
 | 
						|
      case CallState.kFledgling:
 | 
						|
      case CallState.kWaitLocalMedia:
 | 
						|
      case CallState.kCreateOffer:
 | 
						|
      case CallState.kEnding:
 | 
						|
      case null:
 | 
						|
        break;
 | 
						|
    }
 | 
						|
    return <Widget>[];
 | 
						|
  }
 | 
						|
 | 
						|
  List<Widget> _buildContent(Orientation orientation, bool isFloating) {
 | 
						|
    final stackWidgets = <Widget>[];
 | 
						|
 | 
						|
    final call = this.call;
 | 
						|
    if (call.callHasEnded) {
 | 
						|
      return stackWidgets;
 | 
						|
    }
 | 
						|
 | 
						|
    if (call.localHold || call.remoteOnHold) {
 | 
						|
      var title = '';
 | 
						|
      if (call.localHold) {
 | 
						|
        title = '${call.room.getLocalizedDisplayname(
 | 
						|
          MatrixLocals(L10n.of(widget.context)!),
 | 
						|
        )} held the call.';
 | 
						|
      } else if (call.remoteOnHold) {
 | 
						|
        title = 'You held the call.';
 | 
						|
      }
 | 
						|
      stackWidgets.add(
 | 
						|
        Center(
 | 
						|
          child: Column(
 | 
						|
            mainAxisAlignment: MainAxisAlignment.center,
 | 
						|
            children: [
 | 
						|
              const Icon(
 | 
						|
                Icons.pause,
 | 
						|
                size: 48.0,
 | 
						|
                color: Colors.white,
 | 
						|
              ),
 | 
						|
              Text(
 | 
						|
                title,
 | 
						|
                style: const TextStyle(
 | 
						|
                  color: Colors.white,
 | 
						|
                  fontSize: 24.0,
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      );
 | 
						|
      return stackWidgets;
 | 
						|
    }
 | 
						|
 | 
						|
    var primaryStream = call.remoteScreenSharingStream ??
 | 
						|
        call.localScreenSharingStream ??
 | 
						|
        call.remoteUserMediaStream ??
 | 
						|
        call.localUserMediaStream;
 | 
						|
 | 
						|
    if (!connected) {
 | 
						|
      primaryStream = call.localUserMediaStream;
 | 
						|
    }
 | 
						|
 | 
						|
    if (primaryStream != null) {
 | 
						|
      stackWidgets.add(
 | 
						|
        Center(
 | 
						|
          child: _StreamView(
 | 
						|
            primaryStream,
 | 
						|
            mainView: true,
 | 
						|
            matrixClient: widget.client,
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    if (isFloating || !connected) {
 | 
						|
      return stackWidgets;
 | 
						|
    }
 | 
						|
 | 
						|
    _resizeLocalVideo(orientation);
 | 
						|
 | 
						|
    if (call.getRemoteStreams.isEmpty) {
 | 
						|
      return stackWidgets;
 | 
						|
    }
 | 
						|
 | 
						|
    final secondaryStreamViews = <Widget>[];
 | 
						|
 | 
						|
    if (call.remoteScreenSharingStream != null) {
 | 
						|
      final remoteUserMediaStream = call.remoteUserMediaStream;
 | 
						|
      secondaryStreamViews.add(
 | 
						|
        SizedBox(
 | 
						|
          width: _localVideoWidth,
 | 
						|
          height: _localVideoHeight,
 | 
						|
          child:
 | 
						|
              _StreamView(remoteUserMediaStream!, matrixClient: widget.client),
 | 
						|
        ),
 | 
						|
      );
 | 
						|
      secondaryStreamViews.add(const SizedBox(height: 10));
 | 
						|
    }
 | 
						|
 | 
						|
    final localStream =
 | 
						|
        call.localUserMediaStream ?? call.localScreenSharingStream;
 | 
						|
    if (localStream != null && !isFloating) {
 | 
						|
      secondaryStreamViews.add(
 | 
						|
        SizedBox(
 | 
						|
          width: _localVideoWidth,
 | 
						|
          height: _localVideoHeight,
 | 
						|
          child: _StreamView(localStream, matrixClient: widget.client),
 | 
						|
        ),
 | 
						|
      );
 | 
						|
      secondaryStreamViews.add(const SizedBox(height: 10));
 | 
						|
    }
 | 
						|
 | 
						|
    if (call.localScreenSharingStream != null && !isFloating) {
 | 
						|
      secondaryStreamViews.add(
 | 
						|
        SizedBox(
 | 
						|
          width: _localVideoWidth,
 | 
						|
          height: _localVideoHeight,
 | 
						|
          child: _StreamView(
 | 
						|
            call.remoteUserMediaStream!,
 | 
						|
            matrixClient: widget.client,
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      );
 | 
						|
      secondaryStreamViews.add(const SizedBox(height: 10));
 | 
						|
    }
 | 
						|
 | 
						|
    if (secondaryStreamViews.isNotEmpty) {
 | 
						|
      stackWidgets.add(
 | 
						|
        Container(
 | 
						|
          padding: const EdgeInsets.fromLTRB(0, 20, 0, 120),
 | 
						|
          alignment: Alignment.bottomRight,
 | 
						|
          child: Container(
 | 
						|
            width: _localVideoWidth,
 | 
						|
            margin: _localVideoMargin,
 | 
						|
            child: Column(
 | 
						|
              children: secondaryStreamViews,
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    return stackWidgets;
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    return PIPView(
 | 
						|
      builder: (context, isFloating) {
 | 
						|
        return Scaffold(
 | 
						|
          resizeToAvoidBottomInset: !isFloating,
 | 
						|
          floatingActionButtonLocation:
 | 
						|
              FloatingActionButtonLocation.centerFloat,
 | 
						|
          floatingActionButton: SizedBox(
 | 
						|
            width: 320.0,
 | 
						|
            height: 150.0,
 | 
						|
            child: Row(
 | 
						|
              mainAxisAlignment: MainAxisAlignment.spaceAround,
 | 
						|
              children: _buildActionButtons(isFloating),
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
          body: OrientationBuilder(
 | 
						|
            builder: (BuildContext context, Orientation orientation) {
 | 
						|
              return Container(
 | 
						|
                decoration: const BoxDecoration(
 | 
						|
                  color: Colors.black87,
 | 
						|
                ),
 | 
						|
                child: Stack(
 | 
						|
                  children: [
 | 
						|
                    ..._buildContent(orientation, isFloating),
 | 
						|
                    if (!isFloating)
 | 
						|
                      Positioned(
 | 
						|
                        top: 24.0,
 | 
						|
                        left: 24.0,
 | 
						|
                        child: IconButton(
 | 
						|
                          color: Colors.black45,
 | 
						|
                          icon: const Icon(Icons.arrow_back),
 | 
						|
                          onPressed: () {
 | 
						|
                            PIPView.of(context)?.setFloating(true);
 | 
						|
                          },
 | 
						|
                        ),
 | 
						|
                      ),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
              );
 | 
						|
            },
 | 
						|
          ),
 | 
						|
        );
 | 
						|
      },
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |