Slide 1

Slide 1 text

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


Slide 2

Slide 2 text

Flutter × Castの状況
 - 公式plugin不在
 - issueは立っているので、そのうち対応されるかも
 - 非公式のpackage / pluginはいくつかある
 - dart_chromecast、flutter_video_cast、etc.
 - 開発中だったり機能が限定的だったり
 - 現時点ではこれといった手段が確立されていない


Slide 3

Slide 3 text

Flutter × Castの状況
 - 公式plugin不在
 - issueは立っているので、そのうち対応されるかも
 - 非公式のpackage / pluginはいくつかある
 - dart_chromecast、flutter_video_cast、etc.
 - 開発中だったり機能が限定的だったり
 - 現時点ではこれといった手段が確立されていない
 → Cast対応機能をFlutter pluginとして実装してみる


Slide 4

Slide 4 text

Cast対応で必要になる主な機能
 - MediaRouteボタン
 - Cast端末の有無によって表示の出し分け
 - タップ時に接続・切断・操作のダイアログを表示
 - Castを操作する
 - コンテンツを指定して再生を開始する
 - play/pause/seek etc.
 - Castの状態に応じてUIを変更する
 - 再生コンテンツが変更された
 - プレイヤーの状態(play/pause/buffering)が変更された


Slide 5

Slide 5 text

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


Slide 6

Slide 6 text

MediaRouteボタン
 - Flutter側でMediaRouteボタンを表示する案
 - Cast端末の状態を取得する
 - 状態に応じてボタンの表示/非表示の切り替え
 - 状態に応じてタップ時の処理をコール
 - PlatformのMediaRouteボタンをそのまま表示する案 ✅
 - 表示の判断はPlatformのCast SDKに任せる
 - タップ時の処理もCast SDKに任せる


Slide 7

Slide 7 text

PlatformView
 Native viewをFlutterアプリで表示できる仕組み
 - Virtual Display
 - AndroidView(Android)
 - Hybrid Composition
 - UiKitView(iOS)
 - PlatformViewLink(Android)
 - Performance issue


Slide 8

Slide 8 text

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'); }

Slide 9

Slide 9 text

Flutter / PlatformViewLink for Android
 return PlatformViewLink( viewType: 'CastButton', surfaceFactory: (BuildContext context, PlatformViewController controller) { return AndroidViewSurface( controller: controller as AndroidViewController, gestureRecognizers: const >{}, 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(); }, );

Slide 10

Slide 10 text

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 } }

Slide 11

Slide 11 text

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) } }

Slide 12

Slide 12 text

iOS / FlutterPlugin
 import Flutter public class CastPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { let castButtonFactory = CastButtonViewFactory() registrar.register(castButtonFactory, withId: "CastButton") } }

Slide 13

Slide 13 text

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() {} }

Slide 14

Slide 14 text

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.") }

Slide 15

Slide 15 text

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 } }

Slide 16

Slide 16 text

Castを操作する
 - Flutter → Platformにメッセージを送信
 - Platformでメッセージに従い処理を実行する
 - 必要に応じて処理結果をFlutterに返却する
 - アプリの要件によってはメッセージの形式が複雑になる
 - 再生キュー
 - DRM
 - 複雑なメッセージの送受信をどう解決するか
 - シリアライズする、pigenを使う、etc.


Slide 17

Slide 17 text

MethodChannel / BasicMessageChannel
 Flutter - Platform間で双方向のメッセージを非同期でやりとりする
 - MethodChannel
 - Pluginを作成するとテンプレートについてくるやつ
 - メソッド名、引数を指定してFlutter/Platformをコールする
 - 使用できるデータ型に制限あり(StandardMessageCodec)
 - BasicMessageChannel
 - 自分でカスタマイズして使用する
 - Codecを指定できる(StringCodec、BinaryCodec, etc.)
 - JSON + StringCodec、protobuf + BinaryCodec


Slide 18

Slide 18 text

pigeon
 Flutter - Platform間のコミュニケーションを型安全・簡潔に実装するためのコー ドジェネレータ
 - 定義ファイルに送受信データの型およびAPIの定義を記述
 - MessageChannel周りの実装、および呼び出される側で実装が必要な機能 を interface / protocol として出力
 - 使用できる型はStandardMessageCodecに準拠
 - enumサポートが追加された(0.2.2)
 - 現時点(0.3.0)ではGenericsに対応していない


Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

pigeon NG
 class LoadMediaQueueRequest { List items; // Generics is not supported. } class LoadMediaQueueResponse { bool isSuceess; } class MediaQueueItem { String contentUrl; String contentType; } @HostApi() abstract class PigeonHostApi { @async LoadMediaQueueResponse loadMediaQueue(LoadMediaQueueRequest request); }

Slide 21

Slide 21 text

protobuf + BinaryCodecでメッセージを送信する
 - proto定義ファイルから各言語のコードを出力
 - 開発者が整合性をとる必要がない
 - enumが使える
 - BinaryCodecで型キャストを使用しないコード


Slide 22

Slide 22 text

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; }

Slide 23

Slide 23 text

Flutter
 Future queueLoad({required LoadQueueRequest request}) async { final channel = BasicMessageChannel( '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)); }

Slide 24

Slide 24 text

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) } }

Slide 25

Slide 25 text

iOS / Handler function
 func queueLoad(with request: QueueLoadRequest) throws -> QueueLoadResponse { let queue: Array = 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) }

Slide 26

Slide 26 text

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) } } }

Slide 27

Slide 27 text

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) }

Slide 28

Slide 28 text

Castの状態に応じてUIを変更する
 - PlatformでCastの状態を監視 → Flutterにメッセージを送信
 - Flutterでメッセージに従いUIを変更する
 - Flutter → Platformに処理結果を返す必要はない


Slide 29

Slide 29 text

EventChannel
 Platform → Flutterの単方向のメッセージ。
 - Flutter
 - EventChannelのBroadcastStreamをセットアップ
 - listen / cancel で購読開始・終了
 - Platform
 - StreamHandlerをEventChannelに設定
 - onListen / onCancel でEventSinkを保存・破棄
 - EventSink#success でイベントを送信


Slide 30

Slide 30 text

Flutter / Platform interface
 class CastPlatform { Stream 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; } } }

Slide 31

Slide 31 text

Flutter / StateNotifier
 class CastController extends StateNotifier { 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() } }

Slide 32

Slide 32 text

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())) } }

Slide 33

Slide 33 text

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) } }

Slide 34

Slide 34 text

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()) } }

Slide 35

Slide 35 text

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 } }

Slide 36

Slide 36 text

iOS/Androidの違いを意識する
 - iOS/Androidで同じような実装ができない場合もある
 - Flutter - Platform間のインターフェース定義はひとつ
 - どこかで挙動の違いを吸収する必要がある


Slide 37

Slide 37 text

例:再生位置を定期的に取得したい
 Android // 任意の間隔を指定して取得可能 remoteMediaClient?.addProgressListener({ poisition, duration -> // do something. ... }, 500L) iOS // 再生状態に変更がない場合は 10秒間隔でコール → 取得頻度をあげたい場合は他の手段が必要 func remoteMediaClient(_ client: GCKRemoteMediaClient, didUpdate mediaStatus: GCKMediaStatus?) { // do something. ... }

Slide 38

Slide 38 text

まとめ
 - FlutterでCast対応する方法はデファクトがまだなさそう
 - いくつかパッケージの選択肢はあるが安定しない
 - 要件によって既存パッケージを利用するか自作するか検討
 - PlatformChannel、PlatformView等を利用して自作は可能
 - 複雑なデータ構造のやりとりがやや面倒
 - iOS/Androidの差分を吸収するI/F設計が必要
 - 公式に期待


Slide 39

Slide 39 text

ありがとうございました