Firebase 교차 기기 Codelab

1. 소개

최종 업데이트: 2022년 3월 14일

교차 기기 통신을 위한 FlutterFire

홈 자동화, 웨어러블, 개인 건강 기술 기기가 온라인으로 연결되는 수가 많아짐에 따라 교차 기기 통신이 모바일 애플리케이션 빌드의 중요한 부분이 되고 있습니다. 휴대전화 앱에서 브라우저를 제어하거나 휴대전화에서 TV에서 재생되는 콘텐츠를 제어하는 등 교차 기기 통신을 설정하는 것은 일반적으로 일반적인 모바일 앱을 빌드하는 것보다 더 복잡합니다 .

Firebase의 실시간 데이터베이스는 사용자가 기기의 온라인/오프라인 상태를 확인할 수 있는 Presence API 를 제공합니다. 이 API를 Firebase 설치 서비스와 함께 사용하여 동일한 사용자가 로그인한 모든 기기를 추적하고 연결합니다. Flutter를 사용하여 여러 플랫폼용 애플리케이션을 빠르게 만든 다음, 한 기기에서 음악을 재생하고 다른 기기에서 음악을 제어하는 교차 기기 프로토타입을 빌드합니다.

빌드할 항목

이 Codelab에서는 간단한 음악 플레이어 리모컨을 빌드합니다. 이 앱에는 아래의 기능이 있습니다.

  • Flutter로 빌드된 Android, iOS, 웹의 간단한 음악 플레이어가 있습니다.
  • 사용자가 로그인하도록 허용합니다.
  • 동일한 사용자가 여러 기기에 로그인한 경우 기기를 연결합니다.
  • 사용자가 한 기기에서 다른 기기의 음악 재생을 제어할 수 있도록 허용

7f0279938e1d3ab5.gif

학습 내용

  • Flutter 음악 플레이어 앱을 빌드하고 실행하는 방법
  • 사용자가 Firebase 인증으로 로그인하도록 허용하는 방법
  • Firebase RTDB Presence API 및 Firebase 설치 서비스를 사용하여 기기를 연결하는 방법

필요한 사항

  • Flutter 개발 환경 Flutter 설치 가이드의 안내에 따라 설정합니다.
  • 최소 Flutter 버전 2.10 이상이 필요합니다. 낮은 버전이 있는 경우 flutter upgrade.를 실행합니다.
  • Firebase 계정

2. 설정

시작 코드 가져오기

Flutter로 음악 플레이어 앱을 만들었습니다. 시작 코드는 Git 저장소에 있습니다. 시작하려면 명령줄에서 저장소를 클론하고 시작 상태가 있는 폴더로 이동한 후 종속 항목을 설치합니다.

git clone https://github.com/FirebaseExtended/cross-device-controller.git

cd cross-device-controller/starter_code

flutter pub get

앱 빌드

선호하는 IDE를 사용하여 앱을 빌드하거나 명령줄을 사용할 수 있습니다.

앱 디렉터리에서 flutter run -d web-server. 명령어를 사용하여 웹용 앱을 빌드합니다. 다음 프롬프트가 표시됩니다.

lib/main.dart is being served at http://localhost:<port>

http://localhost:<port>로 이동하여 음악 플레이어를 확인합니다.

Android 에뮬레이터 또는 iOS 시뮬레이터에 익숙하다면 해당 플랫폼용 앱을 빌드하고 flutter run -d <device_name> 명령어로 설치할 수 있습니다.

웹 앱에 기본 독립형 음악 플레이어가 표시되어야 합니다. 플레이어 기능이 의도한 대로 작동하는지 확인합니다. 이 앱은 이 Codelab을 위해 설계된 간단한 음악 플레이어 앱입니다. Firebase 노래인 Better Together만 재생할 수 있습니다.

Android Emulator 또는 iOS 시뮬레이터 설정

개발용 Android 기기 또는 iOS 기기가 이미 있는 경우 이 단계를 건너뛸 수 있습니다.

Android 에뮬레이터를 만들려면 Flutter 개발도 지원하는 Android 스튜디오를 다운로드하고 가상 기기 만들기 및 관리의 안내를 따르세요.

iOS 시뮬레이터를 만들려면 Mac 환경이 필요합니다. XCode를 다운로드하고 시뮬레이터 개요 > 시뮬레이터 사용 > 시뮬레이터 열기 및 닫기의 안내를 따릅니다.

3. Firebase 설정하기

Firebase 프로젝트 만들기

  1. Google 계정을 사용하여 Firebase Console에 로그인합니다.
  2. 버튼을 클릭하여 새 프로젝트를 만든 다음 프로젝트 이름 (예: Firebase-Cross-Device-Codelab)을 입력합니다.
  3. 계속을 클릭합니다.
  4. 메시지가 표시되면 Firebase 약관을 검토하고 이에 동의한 다음 계속을 클릭합니다.
  5. (선택사항) Firebase Console에서 AI 지원('Firebase의 Gemini'라고 함)을 사용 설정합니다.
  6. 이 Codelab에서는 Google 애널리틱스가 필요하지 않으므로 Google 애널리틱스 옵션을 사용 중지합니다.
  7. 프로젝트 만들기를 클릭하고 프로젝트가 프로비저닝될 때까지 기다린 다음 계속을 클릭합니다.

Firebase SDK 설치

명령줄의 프로젝트 디렉터리에서 다음 명령어를 실행하여 Firebase를 설치합니다.

flutter pub add firebase_core

pubspec.yaml 파일에서 firebase_core의 버전을 1.13.1 이상으로 수정하거나 flutter upgrade를 실행합니다.

FlutterFire 초기화

  1. Firebase CLI가 설치되어 있지 않으면 curl -sL https://firebase.tools | bash를 실행하여 설치할 수 있습니다.
  2. firebase login을 실행하고 안내를 따라 로그인합니다.
  3. dart pub global activate flutterfire_cli을 실행하여 FlutterFire CLI를 설치합니다.
  4. flutterfire configure를 실행하여 FlutterFire CLI를 구성합니다.
  5. 프롬프트에서 이 Codelab을 위해 방금 만든 프로젝트(예: Firebase-Cross-Device-Codelab)를 선택합니다.
  6. 구성 지원을 선택하라는 메시지가 표시되면 iOS, Android, Web을 선택합니다.
  7. Apple 번들 ID를 묻는 메시지가 표시되면 고유한 도메인을 입력하거나 이 Codelab의 목적에 적합한 com.example.appname를 입력합니다.

구성되면 초기화에 필요한 모든 옵션이 포함된 firebase_options.dart 파일이 생성됩니다.

편집기에서 main.dart 파일에 다음 코드를 추가하여 Flutter와 Firebase를 초기화합니다.

lib/main.dart

import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
 
void main() async {
 WidgetsFlutterBinding.ensureInitialized();
 await Firebase.initializeApp(
   options: DefaultFirebaseOptions.currentPlatform,
 );
 runApp(const MyMusicBoxApp());
}

다음 명령어를 사용하여 앱을 컴파일합니다.

flutter run

아직 UI 요소를 변경하지 않았으므로 앱의 모양과 동작은 변경되지 않았습니다. 이제 Firebase 앱이 있으므로 다음을 비롯한 Firebase 제품을 사용할 수 있습니다.

  • Firebase 인증: 사용자가 앱에 로그인할 수 있습니다.
  • Firebase 실시간 데이터베이스(RTDB): 출석 API를 사용하여 기기 온라인/오프라인 상태를 추적합니다.
  • Firebase 보안 규칙을 사용하면 데이터베이스를 보호할 수 있습니다.
  • Firebase 설치 서비스를 사용하여 단일 사용자가 로그인한 기기를 식별합니다.

4. Firebase 인증 추가

Firebase 인증에 이메일 로그인 사용 설정

사용자가 웹 앱에 로그인할 수 있도록 이메일/비밀번호 로그인 방법을 사용합니다.

  1. Firebase Console의 왼쪽 패널에서 빌드 메뉴를 펼칩니다.
  2. 인증을 클릭한 다음 시작하기 버튼과 로그인 방법 탭을 차례로 클릭합니다.
  3. 로그인 제공업체 목록에서 이메일/비밀번호를 클릭하고 사용 설정 스위치를 켬 위치로 설정한 다음 저장을 클릭합니다. 58e3e3e23c2f16a4.png

Flutter에서 Firebase 인증 구성

명령줄에서 다음 명령어를 실행하여 필요한 Flutter 패키지를 설치합니다.

flutter pub add firebase_auth

flutter pub add provider

이제 이 구성을 사용하여 로그인 및 로그아웃 흐름을 만들 수 있습니다. 인증 상태는 화면 간에 변경되지 않으므로 로그인 및 로그아웃과 같은 앱 수준 상태 변경사항을 추적하는 application_state.dart 클래스를 만듭니다. 자세한 내용은 Flutter 상태 관리 문서를 참고하세요.

다음을 새 application_state.dart 파일에 붙여넣습니다.

lib/src/application_state.dart

import 'package:firebase_auth/firebase_auth.dart'; // new
import 'package:firebase_core/firebase_core.dart'; // new
import 'package:flutter/material.dart';

import '../firebase_options.dart';
import 'authentication.dart';

class ApplicationState extends ChangeNotifier {
  ApplicationState() {
    init();
  }

  Future<void> init() async {
    await Firebase.initializeApp(
      options: DefaultFirebaseOptions.currentPlatform,
    );

    FirebaseAuth.instance.userChanges().listen((user) {
      if (user != null) {
        _loginState = ApplicationLoginState.loggedIn;
      } else {
        _loginState = ApplicationLoginState.loggedOut;
      }
      notifyListeners();
    });
  }

  ApplicationLoginState _loginState = ApplicationLoginState.loggedOut;
  ApplicationLoginState get loginState => _loginState;

  String? _email;
  String? get email => _email;

  void startLoginFlow() {
    _loginState = ApplicationLoginState.emailAddress;
    notifyListeners();
  }

  Future<void> verifyEmail(
    String email,
    void Function(FirebaseAuthException e) errorCallback,
  ) async {
    try {
      var methods =
          await FirebaseAuth.instance.fetchSignInMethodsForEmail(email);
      if (methods.contains('password')) {
        _loginState = ApplicationLoginState.password;
      } else {
        _loginState = ApplicationLoginState.register;
      }
      _email = email;
      notifyListeners();
    } on FirebaseAuthException catch (e) {
      errorCallback(e);
    }
  }

  Future<void> signInWithEmailAndPassword(
    String email,
    String password,
    void Function(FirebaseAuthException e) errorCallback,
  ) async {
    try {
      await FirebaseAuth.instance.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
    } on FirebaseAuthException catch (e) {
      errorCallback(e);
    }
  }

  void cancelRegistration() {
    _loginState = ApplicationLoginState.emailAddress;
    notifyListeners();
  }

  Future<void> registerAccount(
      String email,
      String displayName,
      String password,
      void Function(FirebaseAuthException e) errorCallback) async {
    try {
      var credential = await FirebaseAuth.instance
          .createUserWithEmailAndPassword(email: email, password: password);
      await credential.user!.updateDisplayName(displayName);
    } on FirebaseAuthException catch (e) {
      errorCallback(e);
    }
  }

  void signOut() {
    FirebaseAuth.instance.signOut();
  }
}

앱이 시작될 때 ApplicationState가 초기화되도록 하려면 main.dart에 초기화 단계를 추가합니다.

lib/main.dart

import 'src/application_state.dart'; 
import 'package:provider/provider.dart';

void main() async {
  ... 
  runApp(ChangeNotifierProvider(
    create: (context) => ApplicationState(),
    builder: (context, _) => const MyMusicBoxApp(),
  ));
}

애플리케이션 UI는 동일하게 유지되어야 하지만 이제 사용자가 로그인하고 앱 상태를 저장할 수 있습니다.

로그인 흐름 만들기

이 단계에서는 로그인 및 로그아웃 흐름을 작업합니다. 흐름은 다음과 같습니다.

  1. 로그아웃한 사용자는 앱 바의 오른쪽에 있는 컨텍스트 메뉴 71fcc1030a336423.png를 클릭하여 로그인 절차를 시작합니다.
  2. 로그인 흐름이 대화상자에 표시됩니다.
  3. 사용자가 이전에 로그인한 적이 없는 경우 유효한 이메일 주소와 비밀번호를 사용하여 계정을 만들라는 메시지가 표시됩니다.
  4. 사용자가 이전에 로그인한 적이 있는 경우 비밀번호를 입력하라는 메시지가 표시됩니다.
  5. 사용자가 로그인하면 컨텍스트 메뉴를 클릭할 때 로그아웃 옵션이 표시됩니다.

c295f6fa2e1d40f3.png

로그인 흐름을 추가하려면 세 단계를 거쳐야 합니다.

먼저 AppBarMenuButton 위젯을 만듭니다. 이 위젯은 사용자의 loginState에 따라 컨텍스트 메뉴 팝업을 제어합니다. 가져오기 추가

lib/src/widgets.dart

import 'application_state.dart';
import 'package:provider/provider.dart';
import 'authentication.dart';

다음 코드를 widgets.dart.에 추가합니다.

lib/src/widgets.dart

class AppBarMenuButton extends StatelessWidget {
  const AppBarMenuButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Consumer<ApplicationState>(
      builder: (context, appState, child) {
        if (appState.loginState == ApplicationLoginState.loggedIn) {
          return SignedInMenuButton(buildContext: context);
        }
        return SignInMenuButton(buildContext: context);
      },
    );
  }
}

class SignedInMenuButton extends StatelessWidget {
  const SignedInMenuButton({Key? key, required this.buildContext})
      : super(key: key);
  final BuildContext buildContext;

  @override
  Widget build(BuildContext context) {
    return PopupMenuButton<String>(
      onSelected: _handleSignedInMenu,
      color: Colors.deepPurple.shade300,
      itemBuilder: (context) => _getMenuItemBuilder(),
    );
  }

  List<PopupMenuEntry<String>> _getMenuItemBuilder() {
    return [
      const PopupMenuItem<String>(
        value: 'Sign out',
        child: Text(
          'Sign out',
          style: TextStyle(color: Colors.white),
        ),
      )
    ];
  }

  Future<void> _handleSignedInMenu(String value) async {
    switch (value) {
      case 'Sign out':
        Provider.of<ApplicationState>(buildContext, listen: false).signOut();
        break;
    }
  }
}

class SignInMenuButton extends StatelessWidget {
  const SignInMenuButton({Key? key, required this.buildContext})
      : super(key: key);
  final BuildContext buildContext;

  @override
  Widget build(BuildContext context) {
    return PopupMenuButton<String>(
      onSelected: _signIn,
      color: Colors.deepPurple.shade300,
      itemBuilder: (context) => _getMenuItemBuilder(context),
    );
  }

  Future<void> _signIn(String value) async {
    return showDialog<void>(
      context: buildContext,
      builder: (context) => const SignInDialog(),
    );
  }

  List<PopupMenuEntry<String>> _getMenuItemBuilder(BuildContext context) {
    return [
      const PopupMenuItem<String>(
        value: 'Sign in',
        child: Text(
          'Sign in',
          style: TextStyle(color: Colors.white),
        ),
      ),
    ];
  }
}

두 번째로 동일한 widgets.dart 클래스에서 SignInDialog 위젯을 만듭니다.

lib/src/widgets.dart

class SignInDialog extends AlertDialog {
  const SignInDialog({Key? key}) : super(key: key);

  @override
  AlertDialog build(BuildContext context) {
    return AlertDialog(
      content: Column(mainAxisSize: MainAxisSize.min, children: [
        Consumer<ApplicationState>(
          builder: (context, appState, _) => Authentication(
            email: appState.email,
            loginState: appState.loginState,
            startLoginFlow: appState.startLoginFlow,
            verifyEmail: appState.verifyEmail,
            signInWithEmailAndPassword: appState.signInWithEmailAndPassword,
            cancelRegistration: appState.cancelRegistration,
            registerAccount: appState.registerAccount,
            signOut: appState.signOut,
          ),
        ),
      ]),
    );
  }
}

세 번째로 main.dart.에서 기존 appBar 위젯을 찾아 AppBarMenuButton를 추가하여 로그인 또는 로그아웃 옵션을 표시합니다.

lib/main.dart

import 'src/widgets.dart';
appBar: AppBar(
  title: const Text('Music Box'),
  backgroundColor: Colors.deepPurple.shade400,
  actions: const <Widget>[
    AppBarMenuButton(),
  ],
),

flutter run 명령어를 실행하여 이러한 변경사항을 적용한 상태로 앱을 다시 시작합니다. 앱 바 오른쪽에 컨텍스트 메뉴 71fcc1030a336423.png가 표시됩니다. 이 버튼을 클릭하면 로그인 대화상자가 표시됩니다.

유효한 이메일 주소와 비밀번호로 로그인하면 컨텍스트 메뉴에 로그아웃 옵션이 표시됩니다.

Firebase Console의 인증에서 이메일 주소가 신규 사용자로 표시되는지 확인합니다.

888506c86a28a72c.png

수고하셨습니다. 이제 사용자가 앱에 로그인할 수 있습니다.

5. 데이터베이스 연결 추가

이제 Firebase Presence API를 사용하여 기기를 등록할 준비가 되었습니다.

명령줄에서 다음 명령어를 실행하여 필요한 종속 항목을 추가합니다.

flutter pub add firebase_app_installations

flutter pub add firebase_database

데이터베이스 만들기

Firebase Console에서

  1. Firebase Console실시간 데이터베이스 섹션으로 이동합니다. 데이터베이스 만들기를 클릭합니다.
  2. 보안 규칙의 시작 모드를 선택하라는 메시지가 표시되면 지금은 테스트 모드를 선택합니다**.** (테스트 모드에서는 모든 요청을 허용하는 보안 규칙이 생성됩니다. 보안 규칙은 나중에 추가합니다. 보안 규칙이 테스트 모드인 상태로 프로덕션에 배포해서는 안 됩니다.)

현재 데이터베이스가 비어 있습니다. 프로젝트 설정일반 탭에서 databaseURL을 찾습니다. 웹 앱 섹션까지 아래로 스크롤합니다.

1b6076f60a36263b.png

firebase_options.dart 파일에 databaseURL 추가:

lib/firebase_options.dart

 static const FirebaseOptions web = FirebaseOptions(
    apiKey: yourApiKey,
    ...
    databaseURL: 'https://<YOUR_DATABASE_URL>,
    ...
  );

RTDB Presence API를 사용하여 기기 등록

사용자가 온라인 상태가 되면 사용자의 기기를 등록하려고 합니다. 이를 위해 Firebase Installations 및 Firebase RTDB Presence API를 활용하여 단일 사용자의 온라인 기기 목록을 추적합니다. 다음 코드를 사용하면 이 목표를 달성할 수 있습니다.

lib/src/application_state.dart

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:firebase_app_installations/firebase_app_installations.dart'; 

class ApplicationState extends ChangeNotifier {

  String? _deviceId;
  String? _uid;

  Future<void> init() async {
    ...
    FirebaseAuth.instance.userChanges().listen((user) {
      if (user != null) {
        _loginState = ApplicationLoginState.loggedIn;
        _uid = user.uid;
        _addUserDevice();
      }
      ...
    });
  }

  Future<void> _addUserDevice() async {
    _uid = FirebaseAuth.instance.currentUser?.uid;

    String deviceType = _getDevicePlatform();
    // Create two objects which we will write to the
    // Realtime database when this device is offline or online
    var isOfflineForDatabase = {
      'type': deviceType,
      'state': 'offline',
      'last_changed': ServerValue.timestamp,
    };
    var isOnlineForDatabase = {
      'type': deviceType,
      'state': 'online',
      'last_changed': ServerValue.timestamp,
    };

    var devicesRef =
        FirebaseDatabase.instance.ref().child('/users/$_uid/devices');

    FirebaseInstallations.instance
        .getId()
        .then((id) => _deviceId = id)
        .then((_) {
      // Use the semi-persistent Firebase Installation Id to key devices
      var deviceStatusRef = devicesRef.child('$_deviceId');

      // RTDB Presence API
      FirebaseDatabase.instance
          .ref()
          .child('.info/connected')
          .onValue
          .listen((data) {
        if (data.snapshot.value == false) {
          return;
        }

        deviceStatusRef.onDisconnect().set(isOfflineForDatabase).then((_) {
          deviceStatusRef.set(isOnlineForDatabase);
        });
      });
    });
  }

  String _getDevicePlatform() {
    if (kIsWeb) {
      return 'Web';
    } else if (Platform.isIOS) {
      return 'iOS';
    } else if (Platform.isAndroid) {
      return 'Android';
    }
    return 'Unknown';
  }

명령줄로 돌아가서 flutter run.를 사용하여 기기 또는 브라우저에서 앱을 빌드하고 실행합니다.

앱에서 사용자로 로그인합니다. 다른 플랫폼에서 동일한 사용자로 로그인해야 합니다.

Firebase Console에서 데이터베이스의 한 사용자 ID 아래에 기기가 표시됩니다.

5bef49cea3564248.png

6. 기기 상태 동기화

리드 기기 선택

기기 간에 상태를 동기화하려면 한 기기를 리더 또는 컨트롤러로 지정하세요. 리드 기기가 팔로워 기기의 상태를 결정합니다.

application_state.dart에서 setLeadDevice 메서드를 만들고 RTDB에서 active_device 키로 이 기기를 추적합니다.

lib/src/application_state.dart

  bool _isLeadDevice = false;
  String? leadDeviceType;

  Future<void> setLeadDevice() async {
    if (_uid != null && _deviceId != null) {
      var playerRef =
          FirebaseDatabase.instance.ref().child('/users/$_uid/active_device');
      await playerRef
          .update({'id': _deviceId, 'type': _getDevicePlatform()}).then((_) {
        _isLeadDevice = true;
      });
    }
  }

앱 바 컨텍스트 메뉴에 이 기능을 추가하려면 SignedInMenuButton 위젯을 수정하여 Controller이라는 PopupMenuItem을 만드세요. 이 메뉴를 통해 사용자는 리드 기기를 설정할 수 있습니다.

lib/src/widgets.dart

class SignedInMenuButton extends StatelessWidget {
  const SignedInMenuButton({Key? key, required this.buildContext})
      : super(key: key);
  final BuildContext buildContext;

  List<PopupMenuEntry<String>> _getMenuItemBuilder() {
    return [
      const PopupMenuItem<String>(
        value: 'Sign out',
        child: Text(
          'Sign out',
          style: TextStyle(color: Colors.white),
        ),
      ),
      const PopupMenuItem<String>(
        value: 'Controller',
        child: Text(
          'Set as controller',
          style: TextStyle(color: Colors.white),
        ),
      )
    ];
  }

  void _handleSignedInMenu(String value) async {
    switch (value) {
      ...
      case 'Controller':
        Provider.of<ApplicationState>(buildContext, listen: false)
            .setLeadDevice();
    }
  }
}

리드 기기의 상태를 데이터베이스에 쓰기

리드 기기를 설정한 후에는 다음 코드를 사용하여 리드 기기의 상태를 RTDB에 동기화할 수 있습니다. 다음 코드를 application_state.dart. 끝에 추가합니다. 이렇게 하면 플레이어 상태 (재생 또는 일시중지)와 슬라이더 위치라는 두 가지 속성이 저장되기 시작합니다.

lib/src/application_state.dart

  Future<void> setLeadDeviceState(
      int playerState, double sliderPosition) async {
    if (_isLeadDevice && _uid != null && _deviceId != null) {
      var leadDeviceStateRef =
          FirebaseDatabase.instance.ref().child('/users/$_uid/active_device');
      try {
        var playerSnapshot = {
          'id': _deviceId,
          'state': playerState,
          'type': _getDevicePlatform(),
          'slider_position': sliderPosition
        };
        await leadDeviceStateRef.set(playerSnapshot);
      } catch (e) {
        throw Exception('updated playerState with error');
      }
    }
  }

마지막으로 컨트롤러의 플레이어 상태가 업데이트될 때마다 setActiveDeviceState를 호출해야 합니다. 기존 player_widget.dart 파일을 다음과 같이 변경합니다.

lib/player_widget.dart

import 'package:provider/provider.dart';
import 'application_state.dart';

 void _onSliderChangeHandler(v) {
    ...
    // update player state in RTDB if device is active
    Provider.of<ApplicationState>(context, listen: false)
        .setLeadDeviceState(_playerState.index, _sliderPosition);
 }

 Future<int> _pause() async {
    ...
    // update DB if device is active
    Provider.of<ApplicationState>(context, listen: false)
        .setLeadDeviceState(_playerState.index, _sliderPosition);
    return result;
  }

 Future<int> _play() async {
    var result = 0;

    // update DB if device is active
    Provider.of<ApplicationState>(context, listen: false)
        .setLeadDeviceState(PlayerState.PLAYING.index, _sliderPosition);

    if (_playerState == PlayerState.PAUSED) {
      result = await _audioPlayer.resume();
      return result;
    }
    ...
 }

 Future<int> _updatePositionAndSlider(Duration tempPosition) async {
    ...
    // update DB if device is active
    Provider.of<ApplicationState>(context, listen: false)
        .setLeadDeviceState(_playerState.index, _sliderPosition);
    return result;
  }

데이터베이스에서 리드 기기의 상태 읽기

리드 기기의 상태를 읽고 사용하는 작업은 두 부분으로 나뉩니다. 먼저 application_state에서 리드 플레이어 상태의 데이터베이스 리스너를 설정합니다. 이 리스너는 콜백을 통해 팔로어 기기에 화면을 업데이트해야 하는 시점을 알려줍니다. 이 단계에서 인터페이스 OnLeadDeviceChangeCallback를 정의했습니다. 아직 구현되지 않았습니다. 다음 단계에서 player_widget.dart에 이 인터페이스를 구현합니다.

lib/src/application_state.dart

// Interface to be implemented by PlayerWidget
typedef OnLeadDeviceChangeCallback = void Function(
    Map<dynamic, dynamic> snapshot);

class ApplicationState extends ChangeNotifier {
  ...

  OnLeadDeviceChangeCallback? onLeadDeviceChangeCallback;

  Future<void> init() async {
    FirebaseAuth.instance.userChanges().listen((user) {
      if (user != null) {
        _loginState = ApplicationLoginState.loggedIn;
        _uid = user.uid;
        _addUserDevice().then((_) => listenToLeadDeviceChange());
      }
      ...
    });
  }

  Future<void> listenToLeadDeviceChange() async {
    if (_uid != null) {
      var activeDeviceRef =
          FirebaseDatabase.instance.ref().child('/users/$_uid/active_device');
      activeDeviceRef.onValue.listen((event) {
        final activeDeviceState = event.snapshot.value as Map<dynamic, dynamic>;
        String activeDeviceKey = activeDeviceState['id'] as String;
        _isLeadDevice = _deviceId == activeDeviceKey;
        leadDeviceType = activeDeviceState['type'] as String;
        if (!_isLeadDevice) {
          onLeadDeviceChangeCallback?.call(activeDeviceState);
        }
        notifyListeners();
      });
    }
  }

두 번째로 player_widget.dart의 플레이어 초기화 중에 데이터베이스 리스너를 시작합니다. 데이터베이스 값이 변경될 때마다 팔로어 플레이어 상태가 업데이트될 수 있도록 _updatePlayer 함수를 전달합니다.

lib/player_widget.dart

class _PlayerWidgetState extends State<PlayerWidget> {

  @override
  void initState() {
    ...
    Provider.of<ApplicationState>(context, listen: false)
        .onLeadDeviceChangeCallback = updatePlayer;
  }

  void updatePlayer(Map<dynamic, dynamic> snapshot) {
    _updatePlayer(snapshot['state'], snapshot['slider_position']);
  }

  void _updatePlayer(dynamic state, dynamic sliderPosition) {
    if (state is int && sliderPosition is double) {
      try {
        _updateSlider(sliderPosition);
        final PlayerState newState = PlayerState.values[state];
        if (newState != _playerState) {
          switch (newState) {
            case PlayerState.PLAYING:
              _play();
              break;
            case PlayerState.PAUSED:
              _pause();
              break;
            case PlayerState.STOPPED:
            case PlayerState.COMPLETED:
              _stop();
              break;
          }
          _playerState = newState;
        }
      } catch (e) {
        if (kDebugMode) {
          print('sync player failed');
        }
      }
    }
  }

이제 앱을 테스트할 준비가 되었습니다.

  1. 명령줄에서 flutter run -d <device-name>를 사용하여 에뮬레이터 또는 브라우저에서 앱을 실행합니다.
  2. 브라우저, iOS 시뮬레이터 또는 Android 에뮬레이터에서 앱을 엽니다. 컨텍스트 메뉴로 이동하여 리더 기기로 사용할 앱을 선택합니다. 리더 기기가 업데이트되면 팔로어 기기의 플레이어가 변경되는 것을 확인할 수 있습니다.
  3. 이제 리더 기기를 변경하고 음악을 재생하거나 일시중지한 다음 팔로어 기기가 그에 따라 업데이트되는지 확인합니다.

팔로워 기기가 제대로 업데이트되면 교차 기기 컨트롤러를 만드는 데 성공한 것입니다. 이제 중요한 단계 하나만 남았습니다.

7. 보안 규칙 업데이트

더 나은 보안 규칙을 작성하지 않으면 소유하지 않은 기기에 상태를 쓸 수 있습니다. 따라서 완료하기 전에 실시간 데이터베이스 보안 규칙을 업데이트하여 기기를 읽거나 쓸 수 있는 사용자가 해당 기기에 로그인한 사용자뿐인지 확인합니다. Firebase Console에서 실시간 데이터베이스로 이동한 다음 규칙 탭으로 이동합니다. 로그인한 사용자만 자신의 기기 상태를 읽고 쓸 수 있도록 허용하는 다음 규칙을 붙여넣습니다.

{
  "rules": {
    "users": {
           "$uid": {
               ".read": "$uid === auth.uid",
               ".write": "$uid === auth.uid"
           }
    },
  }
}

8. 수고하셨습니다.

bcd986f7106d892b.gif

수고하셨습니다. Flutter를 사용하여 교차 기기 원격 컨트롤러를 빌드했습니다.

크레딧

Better Together, a Firebase Song

  • 음악: 라이언 버넌
  • 가사와 앨범 커버는 마리사 크리스티가 맡았습니다.
  • JP Gomez의 음성

9. 보너스

추가 과제로 Flutter FutureBuilder를 사용하여 현재 리드 기기 유형을 UI에 비동기식으로 추가해 보세요. 도움이 필요한 경우 Codelab의 완료된 상태가 포함된 폴더에 구현되어 있습니다.

참조 문서 및 다음 단계