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);
 | |
|                           },
 | |
|                         ),
 | |
|                       ),
 | |
|                   ],
 | |
|                 ),
 | |
|               );
 | |
|             },
 | |
|           ),
 | |
|         );
 | |
|       },
 | |
|     );
 | |
|   }
 | |
| }
 |