본문으로 건너뛰기
Version: 1.2

1대1 통화 화면 공유

1대1 통화에서 화면 공유를 구현하는 예제 코드입니다.

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 브로드캐스트 확장 흐름에서만 사용됩니다.

iOS

  • 애플리케이션에서 화면을 캡처하려면 Broadcast Upload Extension 또는 이에 상응하는 기능을 구현해야 합니다.
    • Broadcast Upload Extension을 구현하려면 Xcode에서 프로젝트에 새 Target을 추가하고, "Broadcast Upload Extension" 템플릿을 선택한 다음 이 확장을 활성화하세요.
  • 화면 공유 스트림 전송에 사용할 포트 번호, 수신 토큰 및 전송 토큰을 정의하세요.

1대1 통화의 화면 공유 작동 방식

Android

  1. 사용자가 화면 공유를 요청하면 앱 클라이언트에서 PlanetKitCallstartMyScreenShare()를 호출합니다.
  2. PlanetKit이 시스템 화면 캡처 동의 대화상자를 표시합니다.
  3. 사용자가 허용하면 PlanetKit은 미디어 프로젝션 포그라운드 서비스를 시작하고 화면 캡처 및 전송을 시작합니다. startMyScreenShare()true를 반환합니다.
  4. 수신 측 앱 클라이언트에서 onPeerScreenShareStarted 이벤트를 받아 화면 공유가 시작되었음을 알게 되면 뷰 인스턴스를 생성하고 addPeerScreenShareView()를 호출해 PlanetKit이 화면 공유 비디오를 렌더링하게 합니다.
  5. 송신자의 화면 공유 상태는 PlanetKitMyMediaStatusHandler.onScreenShareStateUpdate를 통해 보고됩니다.

startMyScreenShare()는 사용자가 동의 대화상자를 거부하거나, 다른 화면 공유 요청이 처리 중이거나, PlanetKit이 시작을 거부한 경우 false를 반환합니다. iOS에서는 항상 false를 반환합니다. 아래에 설명된 브로드캐스트 확장 흐름을 사용하세요.

iOS

1대1 통화에서 화면 공유가 작동하는 절차는 다음과 같습니다.

  1. 송신 측 앱 클라이언트에서 미리 정의한 포트 번호, 수신 토큰 및 전송 토큰으로 구성된 ScreenShareKey를 생성하고 PlanetKitMakeCallParamBuilder 또는 PlanetKitVerifyCallParamBuildersetScreenShareKey(key)에 설정합니다.
  2. 사용자가 화면 공유를 요청하면 송신 측 앱 클라이언트에서 NWConnection 또는 이에 상응하는 기능을 사용하여 앱과 SDK 간의 연결을 설정하고, 토큰과 함께 정의된 포트로 화면 공유 스트림을 보냅니다.
  3. ScreenShareKey의 정보가 NWConnection에서 수신한 정보와 일치하면 Flutter용 PlanetKit이 자동으로 화면 공유를 시작합니다.
  4. 수신 측 앱 클라이언트에서 onPeerScreenShareStarted 이벤트를 받아 화면 공유가 시작되었음을 알게 되면 뷰 인스턴스를 생성하고 addPeerScreenShareView()를 호출해 PlanetKit이 화면 공유 비디오를 렌더링하게 합니다.

화면 전송(송신 측, Android)

Android에서는 PlanetKit이 직접 화면을 캡처하고 전송합니다. startMyScreenShare()로 시작하고 stopMyScreenShare()로 중지하세요. ScreenShareKey나 브로드캐스트 확장을 구현할 필요가 없습니다.

화면 공유는 애플리케이션이 요청하지 않아도 중지될 수 있으므로, startMyScreenShare()의 반환값만이 아니라 PlanetKitMyMediaStatusHandler.onScreenShareStateUpdate를 통해 현재 상태를 추적하세요.

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

final PlanetKitCall call;
bool isScreenSharing = false;

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

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

PlanetKit에 화면 공유 키 설정(송신 측, iOS)

PlanetKitMakeCallParamBuildersetScreenShareKey()로 화면 공유 키를 설정하세요. 미리 정의된 포트 번호, 전송 토큰, 수신 토큰을 setScreenShareKey()에 전달해야 합니다.

var builder = PlanetKitMakeCallParamBuilder()
.setMyUserId(myUserId)
.setMyServiceId(serviceId)
.setPeerUserId(peerId)
.setPeerServiceId(serviceId)
.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)
}
}
}

화면 공유 보기(수신 측)

onPeerDidStartScreenShare 이벤트 변경을 감지하고, 이벤트가 발생했을 때 피어의 화면 공유 뷰를 추가하기 위한 코드를 구현하세요.

final _eventHandler = PlanetKitCallEventHandler(
onConnected: (_, __, ___) => {},
onWaitConnected: (_) => {},
onDisconnected: (_, __, ___, ____) => {},
onVerified: (_, __) => {},
onPeerScreenShareStarted: (call) => _addPeerScreenShareView,
onPeerScreenShareStopped: (call) => _removePeerScreenShareView);

void _addPeerScreenShareView(PlanetKitCall call) {
// show screen share view
}
void _removePeerScreenShareView(PlanetKitCall call) {
// remove screen share view
}

피어의 화면 공유를 렌더링하려면 PlanetKitVideoViewBuilder를 사용하여 PlanetKitVideoView를 만들고 PlanetKitCall에 추가해야 합니다.

피어의 화면 공유에 대한 PlanetKitVideoView를 생성한 뒤 addPeerScreenShareView(viewId)를 호출하여 피어의 화면 공유 뷰를 PlanetKitCall에 추가하세요.

class ScreenShareView extends StatelessWidget {
const ScreenShareView({super.key, this.call});
final PlanetKitCall? call;


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

screenShareView.onCreate.listen((id) {
call?.addPeerScreenShareView(id);
});

screenShareView.onDispose.listen((id) {
call?.removePeerScreenShareView(id);
});

return screenShareView;
}
}

관련 예제 코드

관련 문서