12장 영상통화

송기영·2023년 12월 19일
0

플러터

목록 보기
14/25

아고라 API를 이용한 영상통화기능 구현

12.1. 사전지식

12.1.1. 카메라 플러그인

camera플러그인을 설치한다.

// lib/main.dart

import "package:camera/camera.dart";
import "package:flutter/material.dart";

late List<CameraDescription> _cameras;

Future<void> main() async {
  // flutter 앱이 실행될 준비가 됐는지 확인
  WidgetsFlutterBinding.ensureInitialized();

  // 핸드폰에 있는 카메라들 가져오기
  _cameras = await availableCameras();
  runApp(const CameraApp());
}

class CameraApp extends StatefulWidget {
  const CameraApp({super.key});
  
  State<CameraApp> createState() => _CameraAppState();
}

class _CameraAppState extends State<CameraApp> {
  // 카메라를 제어할 수 있는 컨트롤러 선언
  late CameraController controller;

  
  void initState() {
    super.initState();
  }

  initializeCamera() async {
    try {
      controller = CameraController(_cameras[0], ResolutionPreset.max);
      // 카메라 초기화
      await controller.initialize();
      setState(() {});
    } catch (e) {
      if (e is CameraException) {
        switch (e.code) {
          case "CameraAccessDenied":
            print("User denied camera access");
            break;
          default:
            print("Handle other errors");
            break;
        }
      }
    }
  }

  
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    if (!controller.value.isInitialized) {
      return Container();
    }
    return MaterialApp(
      home: CameraPreview(controller),
    );
  }
}

main() 함수의 첫 실행값이 runApp()이면 WidgetsFlutterBinding.ensureInitialized()가 불필요하지만 위의 코드처럼 다른 코드가 먼저 실행돼야 하면 꼭 제일 먼저 실행해야한다.

ResolutionPreset 정보

ResolutionPreset값해상도
ResolutionPreset.low안드로이드, 웹 240p, 아이폰 352x288
ResolutionPreset.medium안드로이드, 웹, 아이폰 480p
ResolutionPreset.high안드로이드, 웹, 아이폰 720p
ResolutionPreset.veryHigh안드로이드, 웹, 아이폰 1080p
ResolutionPreset.ultraHigh안드로이드, 웹 2160p, 아이폰 2096x2160
ResolutionPreset.max최대 해상도

12.1.2. WebRTC

WebRTC를 사용하기 위해서는 중계용 서버가 필요하다. 이를 시그널링 서버라고 이야기하는데 직접구현하기보다 아고라 서비스를 이용한다.

12.1.3. 내비게이션

플러터에서 화면을 이동할 때 사용하는 클래스로, 스택이라는 데이터 구조로 설계되어 있다.

Navigator클래스에서 제공하는 함수

메서드설명
push()새로운 스크린을 추가합니다.
pushReplacement()새로운 스크린 추가하고 바로 아래 스크린을 삭제한다. 현재 스크린을 대체하는 것과 같으며 애니메이션은 push()와 동일하게 실행된다.
pushAndRemoveUntil()새로운 스크린 추가하고 기존 내비게이션 스택에 존재하던 스크린들을 삭제할지 유지할지 정할 수 있다.
pop()현재 스크린을 삭제한다.
maybePop()내비게이션 스택에 마지막으로 남은 스크린이 아닐 때만 pop()함수를 실행한다. 마지막 남은 스크린이라면 아무것도 실행하지 않는다.
popUntil()모든 스크린을 대상으로 스크린을 삭제할지 유지할지 경정할 수 있다.

12.2. 준비하기

12.2.1. 아고라 토큰 가져오기

  1. 첫번째로 아고라에 접속해서 회원가입 후 로그인한다.
  2. 왼쪽 메뉴에서 Project Management를 선택한다.
  3. Create a Project를 클릭한다.
  4. 아래와 같이 입력 후 Submit한다.

  1. 만들어진 프로젝트의 Configure를 클릭한다.
  2. Generate temp RTC Token을 클릭한다.
  3. 채널이름을 입력하고 Generate를 클릭한다. 여기서 채널이름은 아무거나 해도 상관없다.
  4. App ID, Channel Name, Token 값을 플러터 프로젝트에 넣어준다.
// lib/const/agora.dart

const APP_ID = "앱 아이디";
const CHANNEL_NAME = "채널이름";
const TEMP_TOKEN = "발급된 토큰값";

💡Tips : 여기서 생성된 토큰은 24시간 뒤에 만료가 된다.

12.2.2. 네이티브 설정하기

  • 안드로이드

네트워크 상태를 읽는 READ_PHONE_STATE와 ACCESS_NETWORK_STATE 권한을 추가한다.

녹음과 녹화 기능과 관련된 RECORD_AUDIO, MODIFY_AUDIO_SETTINGS, CAMERA 권한도 추가한다.

블루투스 이용한 녹음 및 녹화 기능과 관련된 BLUETOOTH_CONNECT 권한도 추가한다.

마지막으로 build.gradle파일도 변경해준다.

책에서는 위의 6가지를 추가하라고 했는데 실제 코드에는 더 추가가되어있다..

<manifest xmlns:android="http://schemas.android.com/apk/res/android" **xmlns:tools="**http://schemas.android.com/tools**"**>
    <uses-permission android:name="android.permission.READ_PHONE_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" tools:ignore="ProtectedPermissions" />
</manifest>
  • IOS

카메라 권한 NSCameraUsageDescription과 NSMicrophoneUsageDescription만 추가해주면 된다.

<dict>
	<key>NSCameraUsageDesription</key>
	<string>카메라 사용을 허가해주세요.</string>
	<key>NSMicrophoneUsageDesription</key>
	<string>마이크 사용을 허가해주세요.</string>
</dict>

12.2.3. 플러테에서 권한 관리

permission_handler 패키지를 사용하여 쉽게 권한을 관리할 수 있다.

// 카메라 권한 요청
final permission = await Permission.camera.request();

// 권한 상태 확인
if (permission == PermissionStatus.granted) {
	print("권한 허가 완료");
} else {
	print("권한 없음");
}

리스트를 이용해 여러 권한을 한번에 요청할 수 있다.

final resp = await [Permission.camera, Permission.microphone].request();

final cameraPermission = resp[Permission.camera];
final microphone= resp[Permission.microphone];

PermissionStatus 클래스

설명
denied권한이 거절된 상태로 request()를 이용해 권한을 재요청할 수 있다.
granted권한이 허가된 상태
restrictedIOS에서만 해당되는 상태로 권한이 제한되었을때 사용, 청소년, 자녀 보호 기능이 해당된다.
limitedIOS에서만 해다되는 상태로 제한적인 권한이 있을 때 해당된다.
permanentlyDenied권한이 거절된 상태이다. request()를 이용해 권한 재용청이 불가능하고 설정 앱으로 이동해서 사용자가 직접 권한을 허가해줘야한다.

12.3. 구현하기

12.3.1. 홈 스크린 위젯 구현하기

코드와 책에 결과물이 다르게 나와서 결과물에 맞춰서 ElevatedButton에 border를 따로 추가해주었다.

import 'package:flutter/material.dart';
import 'package:video_call/screen/cam_screen.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.blue[100]!,
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: Column(
            children: [
              const Expanded(child: _Logo()),
              Expanded(child: _Image()),
              Expanded(child: _EntryButton()),
            ],
          ),
        ),
      ),
    );
  }
}

class _Logo extends StatelessWidget {
  const _Logo({super.key});

  
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        decoration: BoxDecoration(
            color: Colors.blue,
            borderRadius: BorderRadius.circular(16.0), // 모서리 둥글게
            boxShadow: [
              // 그림자 추가
              BoxShadow(
                  color: Colors.blue[300]!, blurRadius: 12.0, spreadRadius: 2.0)
            ]),
        child: const Padding(
          padding: EdgeInsets.all(16.0),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(Icons.videocam, color: Colors.white, size: 40.0),
              SizedBox(
                width: 12.0,
              ),
              Text(
                "LIVE",
                style: TextStyle(
                    color: Colors.white, fontSize: 30.0, letterSpacing: 4.0),
              )
            ],
          ),
        ),
      ),
    );
  }
}

class _Image extends StatelessWidget {
  const _Image({super.key});

  
  Widget build(BuildContext context) {
    return Center(
      child: Image.asset("asset/img/home_img.png"),
    );
  }
}

class _EntryButton extends StatelessWidget {
  const _EntryButton({super.key});

  
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.end,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        ElevatedButton(
          onPressed: () {
            // CamScreen으로 화면 변경
            Navigator.of(context)
                .push(MaterialPageRoute(builder: (_) => CamScreen()));
          },
          child: Text(
            "입장하기",
            style: TextStyle(color: Colors.white),
          ),
          style: ElevatedButton.styleFrom(
              backgroundColor: Colors.blue,
              shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(4.0))),
        )
      ],
    );
  }
}

12.3.2. 캠 스크린 위젯 구현하기

import 'dart:developer';

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/const/agora.dart';

class CamScreen extends StatefulWidget {
  const CamScreen({super.key});
  
  State<CamScreen> createState() => _CamScreenState();
}

class _CamScreenState extends State<CamScreen> {
  RtcEngine? engine; // 아고라 엔진 저장 변수
  int? uid; // 내 ID
  int? otherUid; // 상대방 ID

  Future<bool> init() async {
    final resp = await [Permission.camera, Permission.microphone].request();
    final cameraPermission = resp[Permission.camera];
    final micPermission = resp[Permission.microphone];

    if (cameraPermission != PermissionStatus.granted ||
        micPermission != PermissionStatus.granted) {
      throw "카메라 또는 마이크 권한이 없습니다.";
    }

    if (engine == null) {
      engine = createAgoraRtcEngine();
      await engine!.initialize(
        const RtcEngineContext(
            appId: APP_ID,
            // 라이브 동영상 송출에 최적화한다.
            channelProfile: ChannelProfileType.channelProfileLiveBroadcasting),
      );

      engine!.registerEventHandler(RtcEngineEventHandler(
          onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
        // 채널 접속성공시 실행
        log("채널에 입장했습니다. uid : ${connection.localUid}");
        setState(() {
          uid = connection.localUid;
        });
      }, onLeaveChannel: (RtcConnection connection, RtcStats stats) {
        log("채널 퇴장");
        setState(() {
          uid = null;
        });
      }, onUserJoined: (RtcConnection connection, int remoteUid, int elapsed) {
        // 다른 사용자가 접속했을 때 실행
        log("상대가 채널에 입장했습니다. uid: $remoteUid");
        setState(() {
          otherUid = remoteUid;
        });
      }, onUserOffline: (RtcConnection connection, int remoteUid,
              UserOfflineReasonType reason) {
        // 다른 사용자가 채널을 나갔을 때
        log("상대가 채널에서 나갔습니다. uid : $remoteUid");
        setState(() {
          otherUid = null;
        });
      }));

      // 엔진으로 영상을 송출하겠다고 설정합니다.
      await engine!.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
      // 동영상 기능을 활성화
      await engine!.enableVideo();
      // 카메라를 이용해 동영상을 화면에 실행
      await engine!.startPreview();
      await engine!.joinChannel(
        token: TEMP_TOKEN,
        channelId: CHANNEL_NAME,
        // 영상과 관련된 여러 가지 설정
        options: ChannelMediaOptions(), uid: 0,
      );
    }

    return true;
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("LIVE")),
      body: FutureBuilder(
        future: init(),
        builder: (BuildContext context, AsyncSnapshot 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: EdgeInsets.symmetric(horizontal: 8.0),
                child: ElevatedButton(
                  onPressed: () async {
                    if (engine != null) {
                      await engine!.leaveChannel();
                    }
                    Navigator.of(context).pop();
                  },
                  child: Text("채널 나가기"),
                ),
              )
            ],
          );
        },
      ),
    );
  }

  // 내 핸드폰이 찍는 화면 렌더링
  Widget renderSubView() {
    if (uid != null) {
      return AgoraVideoView(
          controller: VideoViewController(
              rtcEngine: engine!, canvas: const VideoCanvas(uid: 0)));
    } else {
      return CircularProgressIndicator();
    }
  }

  // 상대핸드폰이 찍는 화면 렌더링
  Widget renderMainView() {
    if (otherUid != null) {
      return AgoraVideoView(
          controller: VideoViewController.remote(
              rtcEngine: engine!,
              canvas: VideoCanvas(uid: otherUid),
              connection: const RtcConnection(channelId: CHANNEL_NAME)));
    } else {
      return Center(
        child: Text(
          "다른 사용자가 입장할 때까지 대기해주세요.",
          textAlign: TextAlign.center,
        ),
      );
    }
  }
}

기기가 1개 뿐이라서 테스트를 하지 못한다면 https://webdemo.agora.io/basicVideoCall/index.html에 접속해서 테스트를 할 수 있으니 테스트 해보시기 바랍니다.

단순하게 따라하기만 했는데 영상통화 앱을 만들었다는게 정말 신기했음.. 아고라 서버를 사용했지만 실제로 영상처리하는 서버를 구현해서 진행한다면 얼마나 많은 작업이 필요할지 궁금해지긴 했음.. 언젠가 bun.js 혹은 express로 서버를 만들어보는것도 재밌을거같음!

profile
업무하면서 쌓인 노하우를 정리하는 블로그🚀 풀스택 개발자를 지향하고 있습니다👻

0개의 댓글