本文にスキップする
Version: 1.2

グループ通話画面共有

グループ通話で画面共有を実装するサンプルコードです。

Note
  • バージョン1.2以降、画面共有のキャプチャーと送信はAndroidとiOSの両方でサポートされていますが、仕組みはプラットフォームごとに異なります。
    • Androidでは、PlanetKitが画面を直接キャプチャーします。アプリケーションがstartMyScreenShare()を呼び出すと、PlanetKitがシステムの画面キャプチャー同意を要求し、メディアプロジェクションのフォアグラウンドサービスでキャプチャーを実行します。
    • iOSでは、アプリケーションがBroadcast Upload Extensionを通じて画面をキャプチャーし、カンファレンス開始時に設定したScreenShareKeyを使ってNWConnection経由でPlanetKitにストリームを送信します。
  • 相手の画面共有の受信は両プラットフォームでサポートされ、コードは同じです。

前提条件

画面共有を実装する前に、次の作業が必要です。

Android

画面キャプチャーはPlanetKitが開始するフォアグラウンドサービス上で実行されますが、そのサービスと権限はアプリケーション側で宣言する必要があります。アプリのAndroidManifest.xmlに次を追加してください。

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />

<application ...>
<service
android:name="com.example.planet_kit_flutter.screenshare.PlanetKitScreenShareService"
android:foregroundServiceType="mediaProjection"
android:exported="false" />
</application>

PlanetKitが通知チャネルを作成し、進行中の「Screen sharing」通知を自動で表示するため、追加の通知設定は不要です。

Note

ポート番号やトークンを定義する必要はなく、setScreenShareKey()を呼び出す必要もありません。これらはiOSのBroadcast Extensionフローでのみ使用されます。

iOS

  • アプリケーションは、画面キャプチャーのためにBroadcast Upload Extensionまたは同等機能を実装する必要があります。
    • Broadcast Upload Extensionを実装するには、Xcodeでプロジェクトに新しいTargetを追加し、「Broadcast Upload Extension」テンプレートを選択した後、この拡張機能を有効にしてください。
  • 画面共有ストリームの転送に使用するポート番号、受信トークン、および送信トークンを定義してください。

グループ通話の画面共有フロー

Android

  1. ユーザーが画面共有をリクエストすると、アプリクライアントがPlanetKitConferencestartMyScreenShare()を呼び出します。
  2. PlanetKitがシステムの画面キャプチャー同意ダイアログを表示します。
  3. ユーザーが許可すると、PlanetKitはメディアプロジェクションのフォアグラウンドサービスを開始し、画面のキャプチャーと送信を始めます。startMyScreenShare()trueを返します。
  4. 受信側のアプリクライアントでstate=enabledonScreenShareUpdateイベントを受信して画面共有が開始されたことがわかったら、ビューインスタンスを作成し、PlanetKitPeerControlstartScreenShare()を呼び出してPlanetKitに画面共有ビデオをレンダリングします。
  5. 送信側の画面共有状態はPlanetKitMyMediaStatusHandler.onScreenShareStateUpdateを通じて報告されます。

startMyScreenShare()は、ユーザーが同意ダイアログを拒否した場合、別の画面共有リクエストが処理中の場合、またはPlanetKitが開始を拒否した場合にfalseを返します。iOSでは常にfalseを返します。以下で説明するBroadcast Extensionフローを使用してください。

iOS

グループ通話における画面共有のフローは、次のとおりです。

  1. 送信側のアプリクライアントであらかじめ定義したポート番号、受信トークン、および送信トークンで構成されたScreenShareKeyを作成し、PlanetKitJoinConferenceParamBuildersetScreenShareKey(key)に設定します。
  2. ユーザーが画面共有をリクエストすると、送信側のアプリクライアントからNWConnectionまたはこれに相当する機能を使用してアプリとSDK間の接続を設定し、トークンと共に定義されたポートに画面共有ストリームを送ります。
  3. ScreenShareKeyの情報がNWConnectionで受信した情報と一致すると、Flutter向けPlanetKitが自動的に画面共有を開始します。
  4. 受信側のアプリクライアントでonScreenShareUpdateイベントを受信して画面共有が開始されたことがわかったら、ビューインスタンスを作成し、startScreenShare()を呼び出してPlanetKitに画面共有ビデオをレンダリングします。

画面の送信(送信側、Android)

Androidでは、PlanetKitが画面のキャプチャーと送信を行います。startMyScreenShare()で開始し、stopMyScreenShare()で停止します。ScreenShareKeyやBroadcast Extensionを実装する必要はありません。

画面共有はアプリケーションが要求していなくても停止する可能性があるため、startMyScreenShare()の戻り値だけでなくPlanetKitMyMediaStatusHandler.onScreenShareStateUpdateを通じて現在の状態を追跡してください。

class ConferenceScreenShareController {
ConferenceScreenShareController({required this.conference}) {
conference.myMediaStatus.setHandler(PlanetKitMyMediaStatusHandler(
onMicMute: null,
onMicUnmute: null,
onAudioDescriptionUpdate: null,
onVideoStatusUpdate: null,
onScreenShareStateUpdate: (status, screenShareState) {
isScreenSharing = screenShareState == PlanetKitScreenShareState.enabled;
// update your UI here
},
));
}

final PlanetKitConference conference;
bool isScreenSharing = false;

Future<void> toggleScreenShare() async {
if (isScreenSharing) {
await conference.stopMyScreenShare();
return;
}

final started = await conference.startMyScreenShare();
if (!started) {
// The user declined the consent dialog, another request was still pending,
// or PlanetKit rejected the start.
}
}
}

PlanetKitに画面共有キー設定(送信側、iOS)

PlanetKitJoinConferenceParamBuildersetScreenShareKey()で画面共有キーを設定します。あらかじめ定義されたポート番号、送信トークン、受信トークンをsetScreenShareKey()に渡す必要があります。

var builder = PlanetKitJoinConferenceParamBuilder()
.setMyUserId(myUserId)
.setMyServiceId(serviceId)
.setRoomServiceId(_serviceId)
.setRoomId(roomId)
.setAccessToken(accessToken)
.setScreenShareKey(ScreenShareKey(broadcastPort: PORT_NUMBER, broadcastPeerToken: "USER_DEFINED_TOKEN_EXT", broadcastMyToken: "USER_DEFINED_TOKEN_APP"));

Swiftで画面キャプチャーおよび送信モジュール実装(送信側、iOS)

NWConnectionを通じてアプリとSDK間の接続を設定する画面キャプチャーモジュールを実装します。

class BroadcastSender {
private enum State {
case started
case handshaking
case connected
case failed
}

weak var delegate: BroadcastSenderDelegate?

private let broadcastPort: UInt16
private let rxToken: String
private let txToken: String

private let connection: NWConnection
private let queue = DispatchQueue(label: "BroadcastSender.Queue")

static func connection(broadcastPort : UInt16) throws -> NWConnection {
guard let port = NWEndpoint.Port(rawValue: broadcastPort) else {
throw Error.invalidPort
}

let options = NWProtocolTCP.Options()
options.noDelay = true

return NWConnection(host: .ipv4(.loopback), port: port, using: .init(tls: nil, tcp: options))
}

init(delegate: BroadcastSenderDelegate?, broadcastPort: UInt16, rxToken : String, txToken : String) throws {
self.delegate = delegate
self.rxToken = rxToken
self.txToken = txToken

connection = try BroadcastSender.connection(broadcastPort: broadcastPort)
connection.stateUpdateHandler = { [weak self] in
self?.handleConnection(newState: $0)
}
connection.start(queue: DispatchQueue(label: "BroadcastSender.NetworkQueue"))
}

func handShake() {
guard state == .started, let data = txToken.data(using: .utf8) else {
return
}

state = .handshaking

connection.send(content: data, completion: .contentProcessed({ (error) in
self.handShakeProcessed(error: error)
}))
}

func sendVideo(sampleBuffer: CMSampleBuffer) throws {
guard state == .connected,
!sending else {
return
}

try queue.sync {
let data: Data?
// create data with CMSampleBuffer
connection.send(data, completion: .contentProcessed({(error) in NSLog("data processed \(error)")}))
}
}

func handShakeProcessed(error: NWError?) {
queue.sync {
if let error = error {
didFail(error: error)
} else {
handShakeAck()
}
}
}

func handShakeAck() {
guard state == .handshaking, let token = rxToken.data(using: .utf8) else {
return
}

connection.receive(minimumIncompleteLength: token.count, maximumLength: token.count) { (data, context, final, error) in
self.handShakeAckProcessed(data: data, error: error)
}
}

func handShakeAckProcessed(data: Data?, error: NWError?) {
queue.sync {
guard state == .handshaking, let token = rxToken.data(using: .utf8) else {
return
}
if data == token {
state = .connected

} else {

didFail(error: .rejected)
}
}
}

func handleConnection(newState: NWConnection.State) {
queue.sync {
switch newState {
case .ready:
handShake()
case .waiting(let error), .failed(let error):
didFail(error: error)
default:
break
}
}
}
}

接続が正常に設定されると、作成されたNWConnectionに画面共有ストリームを送る必要があります。キャプチャーした画面共有ストリームを送るためのSampleHandlerクラスを実装します。

class SampleHandler: RPBroadcastSampleHandler {
private var sender: BroadcastSender?
...
override func broadcastFinished() {
// User has requested to finish the broadcast.
sender?.cancel()
sender = nil
}

let rxToken : String = "USER_DEFINED_TOKEN_APP"
let txToken : String = "USER_DEFINED_TOKEN_EXT"
let broadcastPort : UInt16 = PORT_NUMBER

override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
switch sampleBufferType {
case RPSampleBufferType.video:
do {
if let sender = sender {
try autoreleasepool {
try sender.sendVideo(sampleBuffer: sampleBuffer)
}
} else {
sender = try BroadcastSender(delegate: self, broadcastPort: broadcastPort, rxToken: rxToken, txToken: txToken)
}
} catch {
finish(error: error)
}
break
...
}
}

private func finish(error: Error) {
sender?.cancel()
sender = nil

if let description = (error as? LocalizedError)?.errorDescription {
self.finishBroadcastWithError(NSError(domain: "BroadcastSender.ErrorDomain", code: 0, userInfo: [NSLocalizedFailureReasonErrorKey: description]))
} else {
self.finishBroadcastWithError(error)
}
}
}

画面共有の視聴(受信側)

PlanetKitPeerControlHandler.onScreenShareUpdateイベントの変更を検出し、イベントが発生したときにピアの画面共有ビューを追加するためのコードを実装します。

class Peer {
final PlanetKitPeerControl control;
bool screenShareAvailable = false;

Peer({required this.control});

void register() async {
final handler = PlanetKitPeerControlHandler(
onScreenShareUpdate: (control, screenShareState) {
screenShareAvailable =
screenShareState == PlanetKitScreenShareState.enabled ? true : false;
});
await control.register(handler);
}

void unregister() async {
await control.unregister();
}

void startScreenShare(String viewId) async {
await control.startScreenShare(viewId);
}

void stopScreenShare(String viewId) async {
await control.stopScreenShare(viewId);
}
}

ピアの画面共有をレンダリングするには、PlanetKitVideoViewBuilderを使用してPlanetKitVideoViewを作成し、PlanetKitPeerControlに追加する必要があります。

ピアの画面共有に対するPlanetKitVideoViewを作成した後、startScreenShare(viewId)を呼び出してピアの画面共有ビューをPlanetKitPeerControlに追加します。

class PeerView extends StatelessWidget {
final Peer peer;
PeerView({required this.peer});


Widget build(BuildContext context) {
if (peer.screenShareAvailable) {
return ScreenShareView(peer: peer);
} else {
return Text("screen share not available");
}
}
}

class ScreenShareView extends StatelessWidget {
const ScreenShareView({super.key, required this.peer});
final Peer peer;


Widget build(BuildContext context) {
final screenShareView = PlanetKitVideoViewBuilder.instance
.create(PlanetKitViewScaleType.fitCenter);

screenShareView.onCreate.listen((id) {
peer.startScreenShare(id);
});

screenShareView.onDispose.listen((id) {
peer.stopScreenShare(id);
});

return screenShareView;
}
}

関連サンプルコード

関連ドキュメント