Upgrade to Pro — share decks privately, control downloads, hide ads and more …

FlutterアプリでChromecastに接続する

 FlutterアプリでChromecastに接続する

2021/08/06
Flutter × Kotlin Multiplatform by CyberAgent #4

95f489663c615daf83032b32b8f9b5a9?s=128

Kazuki Nara

August 06, 2021
Tweet

Transcript

  1. Flutterアプリで
 Chromecastに接続する
 Kazuki Nara @ CyberFight
 2021/08/06 Flutter × Kotlin

    Multiplatform #4

  2. Flutter × Castの状況
 - 公式plugin不在
 - issueは立っているので、そのうち対応されるかも
 - 非公式のpackage /

    pluginはいくつかある
 - dart_chromecast、flutter_video_cast、etc.
 - 開発中だったり機能が限定的だったり
 - 現時点ではこれといった手段が確立されていない

  3. Flutter × Castの状況
 - 公式plugin不在
 - issueは立っているので、そのうち対応されるかも
 - 非公式のpackage /

    pluginはいくつかある
 - dart_chromecast、flutter_video_cast、etc.
 - 開発中だったり機能が限定的だったり
 - 現時点ではこれといった手段が確立されていない
 → Cast対応機能をFlutter pluginとして実装してみる

  4. Cast対応で必要になる主な機能
 - MediaRouteボタン
 - Cast端末の有無によって表示の出し分け
 - タップ時に接続・切断・操作のダイアログを表示
 - Castを操作する
 -

    コンテンツを指定して再生を開始する
 - play/pause/seek etc.
 - Castの状態に応じてUIを変更する
 - 再生コンテンツが変更された
 - プレイヤーの状態(play/pause/buffering)が変更された

  5. MediaRouteボタン
 - Flutter側でMediaRouteボタンを表示する案
 - Cast端末の状態を取得する
 - 状態に応じてボタンの表示/非表示の切り替え
 - 状態に応じてタップ時の処理をコール


  6. MediaRouteボタン
 - Flutter側でMediaRouteボタンを表示する案
 - Cast端末の状態を取得する
 - 状態に応じてボタンの表示/非表示の切り替え
 - 状態に応じてタップ時の処理をコール
 -

    PlatformのMediaRouteボタンをそのまま表示する案 ✅
 - 表示の判断はPlatformのCast SDKに任せる
 - タップ時の処理もCast SDKに任せる

  7. PlatformView
 Native viewをFlutterアプリで表示できる仕組み
 - Virtual Display
 - AndroidView(Android)
 - Hybrid

    Composition
 - UiKitView(iOS)
 - PlatformViewLink(Android)
 - Performance issue

  8. Flutter
 @override Widget build(BuildContext context) { if (defaultTargetPlatform == TargetPlatform.iOS)

    { return UiKitView( viewType: 'CastButton', onPlatformViewCreated: _onPlatformViewCreated, creationParams: _args, creationParamsCodec: const StandardMessageCodec(), ); } if (defaultTargetPlatform == TargetPlatform.android) { return AndroidView( viewType: 'CastButton', onPlatformViewCreated: _onPlatformViewCreated, creationParams: _args, creationParamsCodec: const StandardMessageCodec(), ); } return Text('$defaultTargetPlatform is not supported by ChromeCast plugin'); }
  9. Flutter / PlatformViewLink for Android
 return PlatformViewLink( viewType: 'CastButton', surfaceFactory:

    (BuildContext context, PlatformViewController controller) { return AndroidViewSurface( controller: controller as AndroidViewController, gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{}, hitTestBehavior: PlatformViewHitTestBehavior.opaque, ); }, onCreatePlatformView: (PlatformViewCreationParams params) { return PlatformViewsService.initSurfaceAndroidView( id: params.id, viewType: viewType, layoutDirection: TextDirection.ltr, creationParams: args, creationParamsCodec: const StandardMessageCodec(), ) ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) ..create(); }, );
  10. iOS / FlutterPlatformView
 import Flutter import GoogleCast class CastButtonView: NSObject,

    FlutterPlatformView { private let castButton: GCKUICastButton init( withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any? ) { self.castButton = GCKUICastButton(frame: frame) super.init() } func view() -> UIView { return castButton } }
  11. iOS / FlutterPlatformViewFactory
 import Flutter public class CastButtonViewFactory: NSObject, FlutterPlatformViewFactory

    { public func create( withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any? ) -> FlutterPlatformView { return CastButtonView(withFrame: frame, viewIdentifier: viewId, arguments: args) } }
  12. iOS / FlutterPlugin
 import Flutter public class CastPlugin: NSObject, FlutterPlugin

    { public static func register(with registrar: FlutterPluginRegistrar) { let castButtonFactory = CastButtonViewFactory() registrar.register(castButtonFactory, withId: "CastButton") } }
  13. Android / PlatformView
 import androidx.appcompat.view.ContextThemeWrapper import androidx.mediarouter.app.MediaRouteButton import com.google.android.gms.cast.framework.CastButtonFactory import

    io.flutter.plugin.platform.PlatformView class CastButtonView(context: Context) : PlatformView { // To use MediaRouteButton, the activity must be a subclass of FragmentActivity. private val mediaRouteButton = MediaRouteButton(ContextThemeWrapper(context, R.style.Theme_AppCompat_NoActionBar)) .also { CastButtonFactory.setUpMediaRouteButton(context, it) } override fun getView(): View = mediaRouteButton override fun dispose() {} }
  14. Android / PlatformViewFactory
 import android.app.Activity import android.content.Context import io.flutter.plugin.common.StandardMessageCodec import

    io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformViewFactory class CastButtonViewFactory: PlatformViewFactory(StandardMessageCodec.INSTANCE) { // Keep activity instance for AndroidView. var activity: Activity? = null // AndroidView : context is SingleViewPresentation#PresentationContext. // PlatformViewLink : context is MainActivity. override fun create(context: Context?, viewId: Int, args: Any?): PlatformView = context?.let { CastButtonView(it) } ?: throw IllegalStateException("Flutter engine is not attached to an Activity yet.") }
  15. Android / FlutterPlugin
 class CastPlugin : FlutterPlugin, ActivityAware { private

    val castButtonViewFactory = CastButtonViewFactory() override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { flutterPluginBinding.platformViewRegistry .registerViewFactory( "CastButton", castButtonViewFactory ) } // Keep activity instance for AndroidView. override fun onAttachedToActivity(binding: ActivityPluginBinding) { castButtonViewFactory.activity = binding.activity } override fun onDetachedFromActivity() { castButtonViewFactory.activity = null } }
  16. Castを操作する
 - Flutter → Platformにメッセージを送信
 - Platformでメッセージに従い処理を実行する
 - 必要に応じて処理結果をFlutterに返却する
 -

    アプリの要件によってはメッセージの形式が複雑になる
 - 再生キュー
 - DRM
 - 複雑なメッセージの送受信をどう解決するか
 - シリアライズする、pigenを使う、etc.

  17. MethodChannel / BasicMessageChannel
 Flutter - Platform間で双方向のメッセージを非同期でやりとりする
 - MethodChannel
 - Pluginを作成するとテンプレートについてくるやつ


    - メソッド名、引数を指定してFlutter/Platformをコールする
 - 使用できるデータ型に制限あり(StandardMessageCodec)
 - BasicMessageChannel
 - 自分でカスタマイズして使用する
 - Codecを指定できる(StringCodec、BinaryCodec, etc.)
 - JSON + StringCodec、protobuf + BinaryCodec

  18. pigeon
 Flutter - Platform間のコミュニケーションを型安全・簡潔に実装するためのコー ドジェネレータ
 - 定義ファイルに送受信データの型およびAPIの定義を記述
 - MessageChannel周りの実装、および呼び出される側で実装が必要な機能 を

    interface / protocol として出力
 - 使用できる型はStandardMessageCodecに準拠
 - enumサポートが追加された(0.2.2)
 - 現時点(0.3.0)ではGenericsに対応していない

  19. pigeon OK
 class LoadMediaQueueItemRequest { MediaQueueItem item; // Nested value

    is supported. } class LoadMediaQueueItemResponse { bool isSuceess; } class MediaQueueItem { String contentUrl; String contentType; } @HostApi() abstract class PigeonHostApi { @async LoadMediaQueueItemResponse loadMediaQueueItem(LoadMediaQueueItemRequest request); }
  20. pigeon NG
 class LoadMediaQueueRequest { List<MediaQueueItem> items; // Generics is

    not supported. } class LoadMediaQueueResponse { bool isSuceess; } class MediaQueueItem { String contentUrl; String contentType; } @HostApi() abstract class PigeonHostApi { @async LoadMediaQueueResponse loadMediaQueue(LoadMediaQueueRequest request); }
  21. protobuf + BinaryCodecでメッセージを送信する
 - proto定義ファイルから各言語のコードを出力
 - 開発者が整合性をとる必要がない
 - enumが使える
 -

    BinaryCodecで型キャストを使用しないコード

  22. proto定義(再生キュー情報)
 message QueueLoadRequest { repeated MediaQueueItem items = 1; int32

    startIndex = 2; int64 playPosition = 3; } message QueueLoadResponse { bool isSuccess = 1; } message MediaQueueItem { string contentUrl = 1; string contentType = 2; StreamType streamType = 3; MediaMetadata metadata = 4; string customData = 5; } message MediaMetadata { string title = 1; string subTitle = 2; string imageUrl = 3; } enum StreamType { STREAM_TYPE_UNDEFINED = 0; BUFFERED = 1; LIVE = 2; NONE = 3; INVALID = 4; }
  23. Flutter
 Future<LoadQueueResponse> queueLoad({required LoadQueueRequest request}) async { final channel =

    BasicMessageChannel<ByteData>( 'cast_message_queue_load', const BinaryCodec(), ); final data = ByteData.sublistView(request.writeToBuffer()); final response = await channel.send(data); if (response == null) { throw PlatformException( code: 'message-channel-error', message: 'Unable to establish connection on message channel.', ); } return LoadQueueResponse.fromBuffer(Uint8List.sublistView(response)); }
  24. iOS / FlutterPlugin
 public class CastPlugin: NSObject, FlutterPlugin { private

    let channel: BasicMessageChannel init(registrar: FlutterPluginRegistrar) { channel = FlutterBasicMessageChannel( name: "cast_message_queue_load", binaryMessenger: registrar.messenger(), codec: FlutterBinaryCodec.sharedInstance() ) channel.setMessageHandler { message, reply in let request = try QueueLoadRequest(serializedData: message) let response = queueLoad(request: request) reply(response.serializedData()) } } public static func register(with registrar: FlutterPluginRegistrar) { let instance = CastPlugin(registrar: registrar) registrar.publish(instance) } }
  25. iOS / Handler function
 func queueLoad(with request: QueueLoadRequest) throws ->

    QueueLoadResponse { let queue: Array<GCKMediaQueueItem> = request.items.map { let queueItemBuilder = GCKMediaQueueItemBuilder() // convert proto into GCKMediaQueueItem return queueItemBuilder.build() } let options = GCKMediaQueueLoadOptions() options.startIndex = UInt.init(request.startIndex ?? 0) options.playPosition = Double(request.playPosition ?? 0) GCKCastContext.sharedInstance().sessionManager.currentSession ?.remoteMediaClient ?.queueLoad(queue, with: options) return QueueLoadResponse(isSuccess: true) }
  26. Android / FlutterPlugin
 class CastPlugin : FlutterPlugin { private var

    channel: BasicMessageChannel? = null override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = BasicMessageChannel( flutterPluginBinding.binaryMessenger, "cast_message_queue_load", BinaryCodec.INSTANCE ) channel.setMessageHander { message, reply -> val request = message?.let { val byteArray = ByteArray(it.capacity()) it.get(byteArray) QueueLoadRequest.ADAPTER.decode(byteArray) } val byteArray = queueLoad(request).encode() // val buffer = ByteBuffer.wrap(byteArray) ← not work. val buffer = ByteBuffer.allocateDirect(byteArray.size).also { it.put(byteArray) } reply.reply(buffer) } } }
  27. Android / Handler function
 fun queueLoad(message: QueueLoadRequest): QueueLoadResponse { CastContext.getSharedInstance()?.sessionManager?.currentCastSession?.remoteMediaClient?.also

    { client -> val mediaQueue = request.items.map { item -> // convert proto into MediaQueueItem val mediaInfo = MediaInfo.Builder(item.contentUrl) ... .build() MediaQueueItem.Builder(mediaInfo).build() } client.queueLoad( mediaQueue.toTypedArray(), request.startIndex ?: 0, MediaStatus.REPEAT_MODE_REPEAT_OFF, request.playPosition ?: 0, null ) } return QueueLoadResponse(isSuccess = true) }
  28. Castの状態に応じてUIを変更する
 - PlatformでCastの状態を監視 → Flutterにメッセージを送信
 - Flutterでメッセージに従いUIを変更する
 - Flutter →

    Platformに処理結果を返す必要はない

  29. EventChannel
 Platform → Flutterの単方向のメッセージ。
 - Flutter
 - EventChannelのBroadcastStreamをセットアップ
 - listen

    / cancel で購読開始・終了
 - Platform
 - StreamHandlerをEventChannelに設定
 - onListen / onCancel でEventSinkを保存・破棄
 - EventSink#success でイベントを送信

  30. Flutter / Platform interface
 class CastPlatform { Stream<MediaQueueItem> currentMediaQueueItemChanged() {

    return EventChannel('cast_event_current_media_changed') .receiveBroadcastStream() .map((dynamic event) { final buffer = event as Uint8List?; if (buffer == null) { throw PlatformException( code: 'event-channel-error', message: 'Unable to establish connection on message channel.', ); } final event = MediaQueueItem.fromBuffer(buffer); return event; } } }
  31. Flutter / StateNotifier
 class CastController extends StateNotifier<CastState> { CastController() :

    super(const CastState()); StreamSubscription? _streamSubscription; void initialize() { _streamSubscription = CastPlatform.instance.currentMediaQueueItemChanged() .listen((event) { state = state.copyWith( // update mediaQueueItem. ); }); } @override void dispose() { _streamSubscription?.cancel(); _streamSubscription = null; super.dispose() } }
  32. iOS / FlutterStreamHandler
 class CastMediaEventDispatcher: FlutterStreamHandler, GCKRemoteMediaClientListener { private var

    eventSink: FlutterEventSink? func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { self.eventSink = events return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { self.eventSink = nil return nil } func remoteMediaClientDidUpdateQueue(_ client: GCKRemoteMediaClient) { guard let queueItem = client.mediaStatus?.currentQueueItem else { return } let event: MediaQueueItem = ... // convert queueItem into MediaQueueItem self.eventSink?(FlutterStandardTypedData(bytes: event.serializedData())) } }
  33. iOS / FlutterPlugin
 public class CastPlugin: NSObject, FlutterPlugin { private

    let eventChannel: FlutterEventChannel init(registrar: FlutterPluginRegistrar) { eventChannel = FlutterEventChannel( name: "cast_message_queue_load", binaryMessenger: registrar.messenger() ) eventChannel.setStreamHandler(CastMediaEventDispatcher()) } deinit { eventChannel.setStreamHandler(nil) } public static func register(with registrar: FlutterPluginRegistrar) { let instance = CastPlugin(registrar: registrar) registrar.publish(instance) } }
  34. Android / StreamHandler
 class CastMediaEventDispatcher : RemoteMediaClient.Callback(), EventChannel.StreamHandler { private

    var eventSink: EventChannel.EventSink? = null private var remoteMediaClient: RemoteMediaClient? = null override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { eventSink = events } override fun onCancel(arguments: Any?) { eventSink = null } override fun onQueueStatusUpdated() { val currentItem = remoteMediaClient?.currentItem ?: return val proto: MediaQueueItem = ... // convert currentItem into MediaQueueItem eventSink?.success(proto.encode()) } }
  35. Android / FlutterPlugin
 class CastPlugin : FlutterPlugin { private var

    eventChannel: EventChannel? = null override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { eventChannel = EventChannel( flutterPluginBinding.binaryMessenger, "cast_message_queue_load" ) eventChannel.setStreamHandler(CastMediaEventDispatcher()) } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { eventChannel?.setStreamHandler(null) eventChannel = null } }
  36. iOS/Androidの違いを意識する
 - iOS/Androidで同じような実装ができない場合もある
 - Flutter - Platform間のインターフェース定義はひとつ
 - どこかで挙動の違いを吸収する必要がある


  37. 例:再生位置を定期的に取得したい
 Android // 任意の間隔を指定して取得可能 remoteMediaClient?.addProgressListener({ poisition, duration -> // do

    something. ... }, 500L) iOS // 再生状態に変更がない場合は 10秒間隔でコール → 取得頻度をあげたい場合は他の手段が必要 func remoteMediaClient(_ client: GCKRemoteMediaClient, didUpdate mediaStatus: GCKMediaStatus?) { // do something. ... }
  38. まとめ
 - FlutterでCast対応する方法はデファクトがまだなさそう
 - いくつかパッケージの選択肢はあるが安定しない
 - 要件によって既存パッケージを利用するか自作するか検討
 - PlatformChannel、PlatformView等を利用して自作は可能
 -

    複雑なデータ構造のやりとりがやや面倒
 - iOS/Androidの差分を吸収するI/F設計が必要
 - 公式に期待

  39. ありがとうございました