Skip to content

Commit

Permalink
Properly handle broadcast capture state (#551)
Browse files Browse the repository at this point in the history
When using the broadcast capturer on iOS, the broadcast extension now
drives whether or not screen sharing is enabled, publishing a screen
sharing track when the extension begins broadcasting upon user approval.
Additionally, the new `BroadcastManager` class gives developers control
over the broadcast state and track publication.

Fixes #444 and fixes #472. 

**Minor breaking changes**
- Calling `setScreenShare(enabled: true)` or
`set(source:enabled:captureOptions:publishOptions:)` on
`LocalParticipant` currently returns a `LocalTrackPublication`
representing the newly published screen share track. After this change,
when using the broadcast capturer on iOS, this method will return `nil`,
as the track is published asynchronously pending user approval.
Developers should treat enabling screen share as a request that might
not be fulfilled and should not interpret a `nil` return value from this
method as an error.
- Since track publication is now asynchronous, capture options must be
set as room defaults rather than being passed to
`set(source:enabled:captureOptions:publishOptions:)` when enabling
screen sharing.

---------

Co-authored-by: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com>
  • Loading branch information
ladvoc and hiroshihorie authored Feb 3, 2025
1 parent f5a5958 commit fbec343
Show file tree
Hide file tree
Showing 10 changed files with 527 additions and 68 deletions.
61 changes: 34 additions & 27 deletions Docs/ios-screen-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,37 +82,14 @@ In order for the broadcast extension to communicate with your app, they must be
1. Set `RTCAppGroupIdentifier` in the Info.plist of **both targets** to the group identifier from the previous step.
2. Set `RTCScreenSharingExtension` in the Info.plist of your **primary app target** to the broadcast extension's bundle identifier.

#### 4. Use Broadcast Extension

To use the broadcast extension for screen sharing, create an instance of `ScreenShareCaptureOptions`, setting the `useBroadcastExtension` property to `true`. The following example demonstrates making this the default for a room when connecting:

```swift
let options = RoomOptions(
defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
useBroadcastExtension: true
),
// other options...
)
room.connect(url: wsURL, token: token, roomOptions: options)
```

When connecting to a room declaratively using the `RoomScope` view from the SwiftUI components package, use the initializer's optional `roomOptions` parameter to pass the room options object:
#### 4. Begin Screen Share

With setup of the broadcast extension complete, broadcast capture will be used by default when enabling screen share:
```swift
RoomScope(url: wsURL, token: token, roomOptions: options) {
// your components here
}
try await room.localParticipant.setScreenShare(enabled: true)
```

It is also possible to use the broadcast extension when enabling screen share without making it the default for the room:

```swift
try await room.localParticipant.set(
source: .screenShareVideo,
enabled: true,
captureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)
)
```
<small>Note: When using broadcast capture, custom capture options must be set as room defaults rather than passed when enabling screen share with `set(source:enabled:captureOptions:publishOptions:)`.</small>

### Troubleshooting

Expand All @@ -122,3 +99,33 @@ While running your app in a debug session in Xcode, check the debug console for
2. Select your iOS device from the left sidebar and press "Start Streaming."
3. In the search bar, add a filter for messages with a category of "LKSampleHandler."
4. Initiate a screen share in your app and inspect Console for errors.

### Advanced Usage

When using broadcast capture, a broadcast can be initiated externally (for example, via control center). By default, when a broadcast begins, the local participant automatically publishes a screen share track. In some cases, however, you may want to handle track publication manually. You can achieve this by using `BroadcastManager`:

First, disable automatic track publication:
```swift
BroadcastManager.shared.shouldPublishTrack = false
```

Then, use one of the two methods for detecting changes in the broadcast state:

#### Combine Publisher
```swift
let subscription = BroadcastManager.shared
.isBroadcastingPublisher
.sink { isBroadcasting in
// Manually handle track publication
}
```

#### Delegate
```swift
class MyDelegate: BroadcastManagerDelegate {
func broadcastManager(didChangeState isBroadcasting: Bool) {
// Manually handle track publication
}
}
BroadcastManager.shared.delegate = MyDelegate()
```
52 changes: 52 additions & 0 deletions Sources/LiveKit/Broadcast/BroadcastBundleInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2025 LiveKit
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#if os(iOS)

import Foundation

final class BroadcastBundleInfo {

/// Identifier of the app group shared by the primary app and broadcast extension.
@BundleInfo("RTCAppGroupIdentifier")
static var groupIdentifier: String?

/// Bundle identifier of the broadcast extension.
@BundleInfo("RTCScreenSharingExtension")
static var screenSharingExtension: String?

/// Path to the socket file used for interprocess communication.
static var socketPath: String? {
guard let groupIdentifier else { return nil }
return Self.socketPath(for: groupIdentifier)
}

/// Whether or not a broadcast extension has been configured.
static var hasExtension: Bool {
socketPath != nil && screenSharingExtension != nil
}

private static let socketFileDescriptor = "rtc_SSFD"

private static func socketPath(for groupIdentifier: String) -> String? {
guard let sharedContainer = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)
else { return nil }
return sharedContainer.appendingPathComponent(Self.socketFileDescriptor).path
}
}

#endif
119 changes: 119 additions & 0 deletions Sources/LiveKit/Broadcast/BroadcastManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright 2025 LiveKit
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#if os(iOS)

import Combine
import Foundation

#if canImport(ReplayKit)
import ReplayKit
#endif

/// Manages the broadcast state and track publication for screen sharing on iOS.
public final class BroadcastManager: Sendable {
/// Shared broadcast manager instance.
public static let shared = BroadcastManager()

private struct State {
var shouldPublishTrack = true

var cancellable = Set<AnyCancellable>()
let isBroadcastingSubject: CurrentValueSubject<Bool, Never>

weak var delegate: BroadcastManagerDelegate?
}

private let _state: StateSync<State>

/// A delegate for handling broadcast state changes.
public var delegate: BroadcastManagerDelegate? {
get { _state.delegate }
set { _state.mutate { $0.delegate = newValue } }
}

/// Indicates whether a broadcast is currently in progress.
public var isBroadcasting: Bool {
_state.isBroadcastingSubject.value
}

/// A publisher that emits the current broadcast state as a Boolean value.
public var isBroadcastingPublisher: AnyPublisher<Bool, Never> {
_state.isBroadcastingSubject.eraseToAnyPublisher()
}

/// Determines whether a screen share track should be automatically published when broadcasting starts.
///
/// Set this to `false` to manually manage track publication when the broadcast starts.
///
public var shouldPublishTrack: Bool {
get { _state.shouldPublishTrack }
set { _state.mutate { $0.shouldPublishTrack = newValue } }
}

/// Displays the system broadcast picker, allowing the user to start the broadcast.
///
/// - Note: This is merely a request and does not guarantee the user will choose to start the broadcast.
///
public func requestActivation() {
Task {
await RPSystemBroadcastPickerView.show(
for: BroadcastBundleInfo.screenSharingExtension,
showsMicrophoneButton: false
)
}
}

/// Requests to stop the broadcast.
///
/// If a screen share track is published, it will also be unpublished once the broadcast ends.
/// This method has no effect if no broadcast is currently in progress.
///
public func requestStop() {
DarwinNotificationCenter.shared.postNotification(.broadcastRequestStop)
}

init() {
var cancellable = Set<AnyCancellable>()
defer { _state.mutate { $0.cancellable = cancellable } }

let subject = CurrentValueSubject<Bool, Never>(false)

Publishers.Merge(
DarwinNotificationCenter.shared.publisher(for: .broadcastStarted).map { _ in true },
DarwinNotificationCenter.shared.publisher(for: .broadcastStopped).map { _ in false }
)
.subscribe(subject)
.store(in: &cancellable)

_state = StateSync(State(isBroadcastingSubject: subject))

subject.sink { [weak self] in
self?._state.delegate?.broadcastManager(didChangeState: $0)
}
.store(in: &cancellable)
}
}

/// A delegate protocol for receiving updates about the broadcast state.
@objc
public protocol BroadcastManagerDelegate {
/// Invoked when the broadcast state changes.
/// - Parameter isBroadcasting: A Boolean value indicating whether a broadcast is currently in progress.
func broadcastManager(didChangeState isBroadcasting: Bool)
}

#endif
27 changes: 1 addition & 26 deletions Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ class BroadcastScreenCapturer: BufferCapturer {

guard didStart else { return false }

guard let groupIdentifier = Self.groupIdentifier,
let socketPath = Self.socketPath(for: groupIdentifier)
else {
guard let socketPath = BroadcastBundleInfo.socketPath else {
logger.error("Bundle settings improperly configured for screen capture")
return false
}
Expand Down Expand Up @@ -84,29 +82,6 @@ class BroadcastScreenCapturer: BufferCapturer {
frameReader = nil
return true
}

/// Identifier of the app group shared by the primary app and broadcast extension.
@BundleInfo("RTCAppGroupIdentifier")
static var groupIdentifier: String?

/// Bundle identifier of the broadcast extension.
@BundleInfo("RTCScreenSharingExtension")
static var screenSharingExtension: String?

/// Path to the socket file used for interprocess communication.
static var socketPath: String? {
guard let groupIdentifier = Self.groupIdentifier else { return nil }
return Self.socketPath(for: groupIdentifier)
}

private static let kRTCScreensharingSocketFD = "rtc_SSFD"

private static func socketPath(for groupIdentifier: String) -> String? {
guard let sharedContainer = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)
else { return nil }
return sharedContainer.appendingPathComponent(Self.kRTCScreensharingSocketFD).path
}
}

public extension LocalVideoTrack {
Expand Down
Loading

0 comments on commit fbec343

Please sign in to comment.