Flutter – Agora api 이용 Video Call 구현

  • 주요 스택
    • Agora API
    • RTC (Real Time Communication)
    • 버튼 쉐도잉
    • Stack
    • permission_handler plugin (모바일 기기의 모든 퍼미션을 해당 플러그인을 통해 요청하고 받는 것이 가능)

permission_handler 를 이용하여 권한 받기 구현

Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('LIVE'),
      ),
      body: FutureBuilder<bool>(
          future: init(),
          builder: (context, snapshot) {
            if (snapshot.hasError) {
              return Center(
                  child: Text(
                snapshot.error.toString(),
              ));
            }
            if (!snapshot.hasData) {
              return Center(
                child: CircularProgressIndicator(), // 에러가 없고, 데이터가 없으면 아직 승인 대기중
              );
            }

            return Column(
              children: [],
            );
          }),
    );


  Future<bool> init() async {
    final resp = await [Permission.camera, Permission.microphone]
        .request(); // 권한 요청 (permission_handler 사용)
    final cameraPermission = resp[Permission.camera]; // 카메라 권한 요청 결과값
    final microphonePermission = resp[Permission.microphone]; // 마이크 권한 요청 결과값
    /*
    * denied = 아직 권한 요청 전 상태
    * granted = 권한 승인
    * restricted = ios 주로 사용하고, 부분적 권한
    * limited = 사용자가 직접 몇가지 권한만 허용해주는 경우
    * permanentlyDenied = 권한 거절 (이 경우, 다시 허용하려면 사용자가 직접 세팅으로 가서 해당 권한을 열어야함)
    * */
    if (cameraPermission != PermissionStatus.granted ||
        microphonePermission != PermissionStatus.granted) {
      throw '카메라 또는 마이크 권한이 없습니다.';
    }

    return true;
  }

Agora API, RTC engine

import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:video_call_test/const/agora.dart';

class CamScreen extends StatefulWidget {
  const CamScreen({super.key});

  @override
  State<CamScreen> createState() => _CamScreenState();
}

class _CamScreenState extends State<CamScreen> {
  RtcEngine? engine;

  // 내 ID
  int? uid = 0;

  // 상대 ID
  int? otherUid;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('LIVE'),
      ),
      body: FutureBuilder<bool>(
        future: init(),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Center(
                child: Text(
              snapshot.error.toString(),
            ));
          }
          if (!snapshot.hasData) {
            return Center(
              child: CircularProgressIndicator(), // 에러가 없고, 데이터가 없으면 아직 승인 대기중
            );
          }

          return Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Expanded(
                child: Stack(
                  children: [
                    renderMainView(),
                    Align(
                      alignment: Alignment.topLeft,
                      child: Container(
                        color: Colors.grey,
                        height: 160,
                        width: 120,
                        child: renderSubView(),
                      ),
                    ),
                  ],
                ),
              ),
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 8.0),
                child: ElevatedButton(
                  onPressed: () async {
                    if (engine != null) {
                      await engine!.leaveChannel();
                      engine = null;
                    }
                    Navigator.of(context).pop();
                  },
                  child: Text('채널 나가기'),
                ),
              )
            ],
          );
        },
      ),
    );
  }

  void dispose() async {
    if (engine != null) {
      await engine!.leaveChannel(
        options: LeaveChannelOptions(),
      );

      engine!.release();
    }

    super.dispose();
  }

  renderMainView() {
    if (uid == null) {
      return Center(
        child: Text('채널에 참여해주세요.'),
      );
    } else {
      // 채널에 참여하고 있을 때
      return AgoraVideoView(
        controller: VideoViewController(
          rtcEngine: engine!,
          canvas: VideoCanvas(
            uid: 0,
          ),
        ),
      );
    }
  }

  renderSubView() {
    if (otherUid == null) {
      return Center(
        child: Text('채널에 유저가 없습니다.'),
      );
    } else {
      return AgoraVideoView(
        controller: VideoViewController.remote(
          // 상대방 보여줄때
          rtcEngine: engine!,
          canvas: VideoCanvas(uid: otherUid),
          connection: RtcConnection(channelId: CHANNEL_NAME),
        ),
      );
    }
  }

  Future<bool> init() async {
    final resp = await [Permission.camera, Permission.microphone]
        .request(); // 권한 요청 (permission_handler 사용)
    final cameraPermission = resp[Permission.camera]; // 카메라 권한 요청 결과값
    final microphonePermission = resp[Permission.microphone]; // 마이크 권한 요청 결과값
    /*
    * denied = 아직 권한 요청 전 상태
    * granted = 권한 승인
    * restricted = ios 주로 사용하고, 부분적 권한
    * limited = 사용자가 직접 몇가지 권한만 허용해주는 경우
    * permanentlyDenied = 권한 거절 (이 경우, 다시 허용하려면 사용자가 직접 세팅으로 가서 해당 권한을 열어야함)
    * */
    if (cameraPermission != PermissionStatus.granted ||
        microphonePermission != PermissionStatus.granted) {
      throw '카메라 또는 마이크 권한이 없습니다.';
    }

    if (engine == null) {
      engine = createAgoraRtcEngine();

      await engine!.initialize(
        RtcEngineContext(
          appId: APP_ID,
        ),
      );

      engine!.registerEventHandler(
        RtcEngineEventHandler(
          // connection => 연결 정보
          // elapsed => 연결된 시간 (연결된지 얼마나 됐는지)

          // 내가 채널에 입장했을때
          onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
            print('채널에 입장했습니다. uid: ${connection.localUid}');
            setState(() {
              uid = connection.localUid;
            });
          },
          // 내가 채널을 나갔을때
          onLeaveChannel: (RtcConnection connection, RtcStats stats) {
            print('채널 퇴장');
            setState(() {
              uid = null;
            });
          },
          // 상대방 유저가 들어왔을때
          onUserJoined: (RtcConnection connection, int remoteUid, int elapsed) {
            print('상대가 채널에 입장했습니다. otherUid: ${remoteUid}');
            setState(() {
              otherUid = remoteUid;
            });
          },

          // 상대가 나갔을때
          onUserOffline: (RtcConnection connection, int remoteUid,
              UserOfflineReasonType reason) {
            print('상대가 채널에서 나갔습니다. otherUid : $remoteUid');
            setState(() {
              otherUid = null;
            });
          },
        ),
      );

      await engine!.enableVideo(); // 카메라 활성화

      await engine!.startPreview(); // 카메라로 찍고 있는 모습을 휴대폰으로 송출

      ChannelMediaOptions options = ChannelMediaOptions();

      await engine!.joinChannel(
        token: TEMP_TOKEN,
        channelId: CHANNEL_NAME,
        uid: 0,
        options: options,
      );
    }

    return true;
  }
}