From 36abd540c4d07f9c03f3265168809cf9abfa4e0b Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:35:14 -0800 Subject: [PATCH 1/9] Define `BroadcastManager` class Mirrors API of the same class from the native Swift SDK. --- lib/src/managers/broadcast_manager.dart | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 lib/src/managers/broadcast_manager.dart diff --git a/lib/src/managers/broadcast_manager.dart b/lib/src/managers/broadcast_manager.dart new file mode 100644 index 000000000..acd6c2b5b --- /dev/null +++ b/lib/src/managers/broadcast_manager.dart @@ -0,0 +1,61 @@ +// Copyright 2025 LiveKit, Inc. +// +// 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. + +import 'package:flutter/foundation.dart'; +import 'package:meta/meta.dart'; +import '../support/native.dart'; + +/// Manages broadcast state and track publication for screen sharing on iOS. +class BroadcastManager extends ChangeNotifier { + + static final BroadcastManager _instance = BroadcastManager._internal(); + BroadcastManager._internal(); + factory BroadcastManager() { + return _instance; + } + + bool _isBroadcasting = false; + + @internal + void broadcastStateChanged(bool isBroadcasting) { + _isBroadcasting = isBroadcasting; + notifyListeners(); + } + + /// Indicates whether a broadcast is currently in progress. + bool get isBroadcasting => _isBroadcasting; + + /// 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. + /// + bool shouldPublishTrack = true; + + /// 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. + /// + void requestActivation() { + Native.broadcastRequestActivation(); + } + + /// 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. + /// + void requestStop() { + Native.broadcastRequestStop(); + } +} \ No newline at end of file From c09b11472395a6e35fc8242c5aea48611f1e0bbf Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:40:06 -0800 Subject: [PATCH 2/9] Copy classes from native Swift SDK Unneeded functionality has been omitted. --- ios/Classes/BroadcastBundleInfo.swift | 23 ++++ ios/Classes/BroadcastManager.swift | 51 ++++++++ ios/Classes/BundleInfo.swift | 34 ++++++ ios/Classes/DarwinNotificationCenter.swift | 135 +++++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 ios/Classes/BroadcastBundleInfo.swift create mode 100644 ios/Classes/BroadcastManager.swift create mode 100644 ios/Classes/BundleInfo.swift create mode 100644 ios/Classes/DarwinNotificationCenter.swift diff --git a/ios/Classes/BroadcastBundleInfo.swift b/ios/Classes/BroadcastBundleInfo.swift new file mode 100644 index 000000000..dc57f4b7e --- /dev/null +++ b/ios/Classes/BroadcastBundleInfo.swift @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/// Bundle info related to the broadcast extension. +enum BroadcastBundleInfo { + + /// Bundle identifier of the broadcast extension. + @BundleInfo("RTCScreenSharingExtension") + static var screenSharingExtension: String? +} diff --git a/ios/Classes/BroadcastManager.swift b/ios/Classes/BroadcastManager.swift new file mode 100644 index 000000000..104ec055d --- /dev/null +++ b/ios/Classes/BroadcastManager.swift @@ -0,0 +1,51 @@ +/* + * 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. + */ + +import Combine +import ReplayKit + +@available(iOS 13.0, *) +final class BroadcastManager { + + static let shared = BroadcastManager() + + let isBroadcastingPublisher: AnyPublisher = + Publishers.Merge( + DarwinNotificationCenter.shared.publisher(for: .broadcastStarted).map { _ in true }, + DarwinNotificationCenter.shared.publisher(for: .broadcastStopped).map { _ in false } + ) + .eraseToAnyPublisher() + + func requestActivation() { + guard let bundleIdentifier = BroadcastBundleInfo.screenSharingExtension else { return } + Task { await Self.showPicker(for: bundleIdentifier) } + } + + func requestStop() { + DarwinNotificationCenter.shared.postNotification(.broadcastRequestStop) + } + + /// Convenience function to show broadcast extension picker. + @MainActor private static func showPicker(for preferredExtension: String) { + let view = RPSystemBroadcastPickerView() + view.preferredExtension = preferredExtension + view.showsMicrophoneButton = false + + let selector = NSSelectorFromString("buttonPressed:") + guard view.responds(to: selector) else { return } + view.perform(selector, with: nil) + } +} diff --git a/ios/Classes/BundleInfo.swift b/ios/Classes/BundleInfo.swift new file mode 100644 index 000000000..cc920f802 --- /dev/null +++ b/ios/Classes/BundleInfo.swift @@ -0,0 +1,34 @@ +/* + * 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. + */ + +import Foundation + +/// A property wrapper type that reflects a value from a bundle's info dictionary. +@propertyWrapper +struct BundleInfo: Sendable { + private let key: String + private let bundle: Bundle + + init(_ key: String, bundle: Bundle = .main) { + self.key = key + self.bundle = bundle + } + + var wrappedValue: Value? { + guard let value = bundle.infoDictionary?[key] as? Value else { return nil } + return value + } +} diff --git a/ios/Classes/DarwinNotificationCenter.swift b/ios/Classes/DarwinNotificationCenter.swift new file mode 100644 index 000000000..50c8ddfb7 --- /dev/null +++ b/ios/Classes/DarwinNotificationCenter.swift @@ -0,0 +1,135 @@ +/* + * 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. + */ + +import Combine +import Foundation + +enum DarwinNotification: String { + case broadcastStarted = "iOS_BroadcastStarted" + case broadcastStopped = "iOS_BroadcastStopped" + case broadcastRequestStop = "iOS_BroadcastRequestStop" +} + +@available(iOS 13.0, *) +final class DarwinNotificationCenter: @unchecked Sendable { + public static let shared = DarwinNotificationCenter() + private let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() + + func postNotification(_ name: DarwinNotification) { + CFNotificationCenterPostNotification(notificationCenter, + CFNotificationName(rawValue: name.rawValue as CFString), + nil, + nil, + true) + } +} + +@available(iOS 13.0, *) +extension DarwinNotificationCenter { + /// Returns a publisher that emits events when broadcasting notifications matching the given name. + func publisher(for name: DarwinNotification) -> Publisher { + Publisher(notificationCenter, name) + } + + /// A publisher that emits notifications. + struct Publisher: Combine.Publisher { + typealias Output = DarwinNotification + typealias Failure = Never + + private let name: DarwinNotification + private let center: CFNotificationCenter? + + fileprivate init(_ center: CFNotificationCenter?, _ name: DarwinNotification) { + self.name = name + self.center = center + } + + func receive( + subscriber: S + ) where S: Subscriber, Never == S.Failure, DarwinNotification == S.Input { + subscriber.receive(subscription: Subscription(subscriber, center, name)) + } + } + + private class SubscriptionBase { + let name: DarwinNotification + let center: CFNotificationCenter? + + init(_ center: CFNotificationCenter?, _ name: DarwinNotification) { + self.name = name + self.center = center + } + + static var callback: CFNotificationCallback = { _, observer, _, _, _ in + guard let observer else { return } + Unmanaged + .fromOpaque(observer) + .takeUnretainedValue() + .notifySubscriber() + } + + func notifySubscriber() { + // Overridden by generic subclass to call specific subscriber's + // receive method. This allows forming a C function pointer to the callback. + } + } + + private class Subscription: SubscriptionBase, Combine.Subscription where S.Input == DarwinNotification, S.Failure == Never { + private var subscriber: S? + + init(_ subscriber: S, _ center: CFNotificationCenter?, _ name: DarwinNotification) { + self.subscriber = subscriber + super.init(center, name) + addObserver() + } + + func request(_: Subscribers.Demand) {} + + private var opaqueSelf: UnsafeRawPointer { + UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque()) + } + + private func addObserver() { + CFNotificationCenterAddObserver(center, + opaqueSelf, + Self.callback, + name.rawValue as CFString, + nil, + .deliverImmediately) + } + + private func removeObserver() { + guard subscriber != nil else { return } + CFNotificationCenterRemoveObserver(center, + opaqueSelf, + CFNotificationName(name.rawValue as CFString), + nil) + subscriber = nil + } + + override func notifySubscriber() { + _ = subscriber?.receive(name) + } + + func cancel() { + removeObserver() + } + + deinit { + removeObserver() + } + } +} From 26befede1bdf55e3e7a434f597526ce5f871dfab Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:42:43 -0800 Subject: [PATCH 3/9] Mark `LiveKitPlugin` as avalible on iOS 13 and higher This is necesary to allow using the Combine framework. Since the minimum deployment target is iOS 13 according to the Podspec, this should not cause any issues. --- shared_swift/LiveKitPlugin.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index b3f0fc982..b6ca6d172 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -23,6 +23,7 @@ import Flutter import UIKit #endif +@available(iOS 13.0, *) public class LiveKitPlugin: NSObject, FlutterPlugin { var processers: Dictionary = [:] From 7804011d811c4af9fec5c5fcb2c447dcbc4630b9 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:45:40 -0800 Subject: [PATCH 4/9] Implement RPC methods for broadcast manager --- lib/src/support/native.dart | 50 +++++++++++++++++++++++++++++++- shared_swift/LiveKitPlugin.swift | 42 ++++++++++++++++++++------- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index c5fb83ee0..1850df35f 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -17,12 +17,19 @@ import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; import '../logger.dart'; +import '../managers/broadcast_manager.dart'; import 'native_audio.dart'; // Method channel methods to call native code. class Native { @internal - static const channel = MethodChannel('livekit_client'); + static final channel = _createChannel(); + + static MethodChannel _createChannel() { + final channel = MethodChannel('livekit_client'); + channel.setMethodCallHandler(_handleMethodCall); + return channel; + } @internal static bool bypassVoiceProcessing = false; @@ -92,4 +99,45 @@ class Native { } return null; } + + static Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'broadcastStateChanged': + if (call.arguments is! bool) { + logger.warning('broadcastStateChanged did not receive bool'); + return null; + } + _broadcastStateChanged(call.arguments as bool); + return null; + default: + logger.warning('Method ${call.method} is not implemented.'); + return null; + } + } + + static void _broadcastStateChanged(bool isBroadcasting) { + BroadcastManager().broadcastStateChanged(isBroadcasting); + } + + @internal + static void broadcastRequestActivation() { + try { + channel.invokeMethod('broadcastRequestActivation', {}); + } catch (error) { + logger.warning('broadcastRequestActivation did throw error: ${error}'); + } + } + + @internal + static void broadcastRequestStop() { + try { + channel.invokeMethod('broadcastRequestStop', {}); + } catch (error) { + logger.warning('broadcastRequestStop did throw error: ${error}'); + } + } } + +// Initialize the channel before first reference so method calls can be handled. +// ignore: unused_element +final _channelInitializer = Native.channel; \ No newline at end of file diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index b6ca6d172..582250459 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -1,4 +1,4 @@ -// Copyright 2024 LiveKit, Inc. +// Copyright 2025 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,15 +21,20 @@ import FlutterMacOS #else import Flutter import UIKit +import Combine #endif @available(iOS 13.0, *) public class LiveKitPlugin: NSObject, FlutterPlugin { var processers: Dictionary = [:] - + var binaryMessenger: FlutterBinaryMessenger? + #if os(iOS) + var cancellable = Set() + #endif + public static func register(with registrar: FlutterPluginRegistrar) { #if os(macOS) @@ -42,6 +47,14 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { let instance = LiveKitPlugin() instance.binaryMessenger = messenger registrar.addMethodCallDelegate(instance, channel: channel) + + #if os(iOS) + BroadcastManager.shared.isBroadcastingPublisher + .sink { isBroadcasting in + channel.invokeMethod("broadcastStateChanged", arguments: isBroadcasting) + } + .store(in: &instance.cancellable) + #endif } #if !os(macOS) @@ -91,16 +104,16 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { return result } #endif - + public func handleStartAudioVisualizer(args: [String: Any?], result: @escaping FlutterResult) { let webrtc = FlutterWebRTCPlugin.sharedSingleton() - + let trackId = args["trackId"] as? String let barCount = args["barCount"] as? Int ?? 7 let isCentered = args["isCentered"] as? Bool ?? true - + if let unwrappedTrackId = trackId { - + let localTrack = webrtc?.localTracks![unwrappedTrackId] if let audioTrack = localTrack as? LocalAudioTrack { let lkLocalTrack = LKLocalAudioTrack(name: unwrappedTrackId, track: audioTrack); @@ -110,7 +123,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { isCentered: isCentered) processers[lkLocalTrack] = processor } - + let track = webrtc?.remoteTrack(forId: unwrappedTrackId) if let audioTrack = track as? RTCAudioTrack { let lkRemoteTrack = LKRemoteAudioTrack(name: unwrappedTrackId, track: audioTrack); @@ -121,11 +134,11 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { processers[lkRemoteTrack] = processor } } - - + + result(true) } - + public func handleStopAudioVisualizer(args: [String: Any?], result: @escaping FlutterResult) { let trackId = args["trackId"] as? String if let unwrappedTrackId = trackId { @@ -228,7 +241,6 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any?] else { print("[LiveKit] arguments must be a dictionary") result(FlutterMethodNotImplemented) @@ -244,6 +256,14 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { handleStopAudioVisualizer(args: args, result: result) case "osVersionString": result(LiveKitPlugin.osVersionString()) + #if os(iOS) + case "broadcastRequestActivation": + BroadcastManager.shared.requestActivation() + result(true) + case "broadcastRequestStop": + BroadcastManager.shared.requestStop() + result(true) + #endif default: print("[LiveKit] method not found: ", call.method) result(FlutterMethodNotImplemented) From 4730909c02c6edaf7f69c24988741a6ca2ce6f39 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:51:26 -0800 Subject: [PATCH 5/9] Properly handle broadcast capture state Same solution as implemented in PR #551 for the native Swift SDK. --- lib/src/participant/local.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index 1731ff278..6894998aa 100644 --- a/lib/src/participant/local.dart +++ b/lib/src/participant/local.dart @@ -28,6 +28,7 @@ import '../exceptions.dart'; import '../extensions.dart'; import '../internal/events.dart'; import '../logger.dart'; +import '../managers/broadcast_manager.dart'; import '../options.dart'; import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_rtc.pb.dart' as lk_rtc; @@ -58,11 +59,22 @@ class LocalParticipant extends Participant { ) { updateFromInfo(info); + if (lkPlatformIs(PlatformType.iOS)) { + BroadcastManager().addListener(_broadcastStateChanged); + } + onDispose(() async { + BroadcastManager().removeListener(_broadcastStateChanged); await unpublishAllTracks(); }); } + /// Handle broadcast state change (iOS only) + void _broadcastStateChanged() { + final isEnabled = BroadcastManager().isBroadcasting && BroadcastManager().shouldPublishTrack; + setScreenShareEnabled(isEnabled); + } + /// Publish an [AudioTrack] to the [Room]. /// For most cases, using [setMicrophoneEnabled] would be simpler and recommended. Future> publishAudioTrack( @@ -620,6 +632,12 @@ class LocalParticipant extends Participant { ScreenShareCaptureOptions captureOptions = screenShareCaptureOptions ?? room.roomOptions.defaultScreenShareCaptureOptions; + if (lkPlatformIs(PlatformType.iOS) && !BroadcastManager().isBroadcasting) { + // Wait until broadcasting to publish track + BroadcastManager().requestActivation(); + return null; + } + /// When capturing chrome table audio, we can't capture audio/video /// track separately, it has to be returned once in getDisplayMedia, /// so we publish it twice here, but only return videoTrack to user. From fa2c5b2e65dd77be7333bd0072c0151b9b43ed69 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:49:40 -0800 Subject: [PATCH 6/9] Use `broadcast-manual` device id Sets the `deviceId` constraint to `broadcast-manual`, ensuring the broadcast picker isn't presented a second time when publishing a screen share track. --- lib/src/track/options.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/track/options.dart b/lib/src/track/options.dart index c5e8226f5..2e6830575 100644 --- a/lib/src/track/options.dart +++ b/lib/src/track/options.dart @@ -179,7 +179,7 @@ class ScreenShareCaptureOptions extends VideoCaptureOptions { Map toMediaConstraintsMap() { var constraints = super.toMediaConstraintsMap(); if (useiOSBroadcastExtension && lkPlatformIs(PlatformType.iOS)) { - constraints['deviceId'] = 'broadcast'; + constraints['deviceId'] = 'broadcast-manual'; } if (lkPlatformIsDesktop()) { if (deviceId != null) { From b380f66e854da7f7361f28c89302039225cd0c2f Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:56:01 -0800 Subject: [PATCH 7/9] Update example app Removes boilerplate for iOS broadcast state management now handled internally by the library. --- .../SampleHandler.swift | 46 +++++----------- example/ios/Runner/AppDelegate.swift | 55 +------------------ .../method_channels/replay_kit_channel.dart | 42 -------------- example/lib/pages/room.dart | 24 -------- example/lib/widgets/controls.dart | 11 ---- 5 files changed, 16 insertions(+), 162 deletions(-) delete mode 100644 example/lib/method_channels/replay_kit_channel.dart diff --git a/example/ios/LiveKit Broadcast Extension/SampleHandler.swift b/example/ios/LiveKit Broadcast Extension/SampleHandler.swift index 1cca039c4..b83e13d79 100644 --- a/example/ios/LiveKit Broadcast Extension/SampleHandler.swift +++ b/example/ios/LiveKit Broadcast Extension/SampleHandler.swift @@ -15,23 +15,23 @@ private enum Constants { } class SampleHandler: RPBroadcastSampleHandler { - + private var clientConnection: SocketConnection? private var uploader: SampleUploader? - + private var frameCount: Int = 0 - + var socketFilePath: String { let sharedContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.appGroupIdentifier) return sharedContainer?.appendingPathComponent("rtc_SSFD").path ?? "" } - + override init() { super.init() if let connection = SocketConnection(filePath: socketFilePath) { clientConnection = connection setupConnection() - + uploader = SampleUploader(connection: connection) } os_log(.debug, log: broadcastLogger, "%{public}s", socketFilePath) @@ -40,28 +40,25 @@ class SampleHandler: RPBroadcastSampleHandler { override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) { // User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional. frameCount = 0 - + DarwinNotificationCenter.shared.postNotification(.broadcastStarted) openConnection() - startReplayKit() } - + override func broadcastPaused() { // User has requested to pause the broadcast. Samples will stop being delivered. } - + override func broadcastResumed() { // User has requested to resume the broadcast. Samples delivery will resume. } - + override func broadcastFinished() { // User has requested to finish the broadcast. DarwinNotificationCenter.shared.postNotification(.broadcastStopped) clientConnection?.close() - closeReplayKit() - } - + override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { switch sampleBufferType { case RPSampleBufferType.video: @@ -73,11 +70,11 @@ class SampleHandler: RPBroadcastSampleHandler { } private extension SampleHandler { - + func setupConnection() { clientConnection?.didClose = { [weak self] error in os_log(.debug, log: broadcastLogger, "client connection did close \(String(describing: error))") - + if let error = error { self?.finishBroadcastWithError(error) } else { @@ -88,7 +85,7 @@ private extension SampleHandler { } } } - + func openConnection() { let queue = DispatchQueue(label: "broadcast.connectTimer") let timer = DispatchSource.makeTimerSource(queue: queue) @@ -97,23 +94,10 @@ private extension SampleHandler { guard self?.clientConnection?.open() == true else { return } - + timer.cancel() } - + timer.resume() } - - func startReplayKit() { - let group=UserDefaults(suiteName: Constants.appGroupIdentifier) - group!.set(false, forKey: "closeReplayKitFromNative") - group!.set(false, forKey: "closeReplayKitFromFlutter") - group!.set(true, forKey: "hasSampleBroadcast") - } - - func closeReplayKit() { - let group = UserDefaults(suiteName: Constants.appGroupIdentifier) - group!.set(true, forKey:"closeReplayKitFromNative") - group!.set(false, forKey: "hasSampleBroadcast") - } } diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index d12203182..71503bb83 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -3,11 +3,7 @@ import Flutter @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { - - var replayKitChannel: FlutterMethodChannel! = nil - var observeTimer: Timer? - var hasEmittedFirstSample = false; - + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -15,56 +11,7 @@ import Flutter guard let controller = window?.rootViewController as? FlutterViewController else { return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - - replayKitChannel = FlutterMethodChannel(name: "io.livekit.example.flutter/replaykit-channel",binaryMessenger: controller.binaryMessenger) - - replayKitChannel.setMethodCallHandler({ - (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in - self.handleReplayKitFromFlutter(result: result, call:call) - }) - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - - func handleReplayKitFromFlutter(result:FlutterResult, call: FlutterMethodCall){ - switch (call.method) { - case "startReplayKit": - self.hasEmittedFirstSample = false - let group=UserDefaults(suiteName: "group.io.livekit.example.flutter") - group!.set(false, forKey: "closeReplayKitFromNative") - group!.set(false, forKey: "closeReplayKitFromFlutter") - self.observeReplayKitStateChanged() - break - case "closeReplayKit": - let group=UserDefaults(suiteName: "group.io.livekit.example.flutter") - group!.set(true,forKey: "closeReplayKitFromFlutter") - result(true) - break - default: - return result(FlutterMethodNotImplemented) - } - } - - func observeReplayKitStateChanged(){ - if (self.observeTimer != nil) { - return - } - - let group=UserDefaults(suiteName: "group.io.livekit.example.flutter") - self.observeTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (timer) in - let closeReplayKitFromNative=group!.bool(forKey: "closeReplayKitFromNative") - let hasSampleBroadcast=group!.bool(forKey: "hasSampleBroadcast") - - if (closeReplayKitFromNative) { - self.hasEmittedFirstSample = false - self.replayKitChannel.invokeMethod("closeReplayKitFromNative", arguments: true) - } else if (hasSampleBroadcast) { - if (!self.hasEmittedFirstSample) { - self.hasEmittedFirstSample = true - self.replayKitChannel.invokeMethod("hasSampleBroadcast", arguments: true) - } - } - } - } } diff --git a/example/lib/method_channels/replay_kit_channel.dart b/example/lib/method_channels/replay_kit_channel.dart deleted file mode 100644 index 0035ef1a6..000000000 --- a/example/lib/method_channels/replay_kit_channel.dart +++ /dev/null @@ -1,42 +0,0 @@ -// Dart imports: -import 'dart:io'; - -// Flutter imports: -import 'package:flutter/services.dart'; -import 'package:livekit_client/livekit_client.dart'; - -class ReplayKitChannel { - static const String kReplayKitChannel = - 'io.livekit.example.flutter/replaykit-channel'; - - static const MethodChannel _replayKitChannel = - MethodChannel(kReplayKitChannel); - - static void listenMethodChannel(Room room) { - _replayKitChannel.setMethodCallHandler((call) async { - if (call.method == 'closeReplayKitFromNative') { - if (!(room.localParticipant?.isScreenShareEnabled() ?? false)) { - return; - } - - await room.localParticipant?.setScreenShareEnabled(false); - } else if (call.method == 'hasSampleBroadcast') { - if (room.localParticipant?.isScreenShareEnabled() ?? true) return; - - await room.localParticipant?.setScreenShareEnabled(true); - } - }); - } - - static void startReplayKit() { - if (!Platform.isIOS) return; - - _replayKitChannel.invokeMethod('startReplayKit'); - } - - static void closeReplayKit() { - if (!Platform.isIOS) return; - - _replayKitChannel.invokeMethod('closeReplayKit'); - } -} diff --git a/example/lib/pages/room.dart b/example/lib/pages/room.dart index 3cbec9efd..81f39731c 100644 --- a/example/lib/pages/room.dart +++ b/example/lib/pages/room.dart @@ -4,7 +4,6 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart'; -import 'package:livekit_example/method_channels/replay_kit_channel.dart'; import '../exts.dart'; import '../utils.dart'; @@ -30,7 +29,6 @@ class _RoomPageState extends State { List participantTracks = []; EventsListener get _listener => widget.listener; bool get fastConnection => widget.room.engine.fastConnectOptions != null; - bool _flagStartedReplayKit = false; @override void initState() { super.initState(); @@ -49,10 +47,6 @@ class _RoomPageState extends State { Hardware.instance.setSpeakerphoneOn(true); } - if (lkPlatformIs(PlatformType.iOS)) { - ReplayKitChannel.listenMethodChannel(widget.room); - } - if (lkPlatformIsDesktop()) { onWindowShouldClose = () async { unawaited(widget.room.disconnect()); @@ -66,9 +60,6 @@ class _RoomPageState extends State { void dispose() { // always dispose listener (() async { - if (lkPlatformIs(PlatformType.iOS)) { - ReplayKitChannel.closeReplayKit(); - } widget.room.removeListener(_onRoomDidUpdate); await _listener.dispose(); await widget.room.dispose(); @@ -212,26 +203,11 @@ class _RoomPageState extends State { if (localParticipantTracks != null) { for (var t in localParticipantTracks) { if (t.isScreenShare) { - if (lkPlatformIs(PlatformType.iOS)) { - if (!_flagStartedReplayKit) { - _flagStartedReplayKit = true; - - ReplayKitChannel.startReplayKit(); - } - } screenTracks.add(ParticipantTrack( participant: widget.room.localParticipant!, type: ParticipantTrackType.kScreenShare, )); } else { - if (lkPlatformIs(PlatformType.iOS)) { - if (_flagStartedReplayKit) { - _flagStartedReplayKit = false; - - ReplayKitChannel.closeReplayKit(); - } - } - userMediaTracks.add( ParticipantTrack(participant: widget.room.localParticipant!)); } diff --git a/example/lib/widgets/controls.dart b/example/lib/widgets/controls.dart index 79f6d6c7e..c65c3c7fc 100644 --- a/example/lib/widgets/controls.dart +++ b/example/lib/widgets/controls.dart @@ -190,23 +190,12 @@ class _ControlsWidgetState extends State { await requestBackgroundPermission(); } - if (lkPlatformIs(PlatformType.iOS)) { - var track = await LocalVideoTrack.createScreenShareTrack( - const ScreenShareCaptureOptions( - useiOSBroadcastExtension: true, - maxFrameRate: 15.0, - ), - ); - await participant.publishVideoTrack(track); - return; - } if (lkPlatformIsWebMobile()) { await context .showErrorDialog('Screen share is not supported on mobile web'); return; } - await participant.setScreenShareEnabled(true, captureScreenAudio: true); } From fea666b3505fe313acca035d5f9e35d3093044ba Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Wed, 12 Feb 2025 10:56:07 -0800 Subject: [PATCH 8/9] Format --- lib/src/managers/broadcast_manager.dart | 3 +-- lib/src/participant/local.dart | 6 ++++-- lib/src/support/native.dart | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/src/managers/broadcast_manager.dart b/lib/src/managers/broadcast_manager.dart index acd6c2b5b..a1b407347 100644 --- a/lib/src/managers/broadcast_manager.dart +++ b/lib/src/managers/broadcast_manager.dart @@ -18,7 +18,6 @@ import '../support/native.dart'; /// Manages broadcast state and track publication for screen sharing on iOS. class BroadcastManager extends ChangeNotifier { - static final BroadcastManager _instance = BroadcastManager._internal(); BroadcastManager._internal(); factory BroadcastManager() { @@ -58,4 +57,4 @@ class BroadcastManager extends ChangeNotifier { void requestStop() { Native.broadcastRequestStop(); } -} \ No newline at end of file +} diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index 6894998aa..9f418fa04 100644 --- a/lib/src/participant/local.dart +++ b/lib/src/participant/local.dart @@ -71,7 +71,8 @@ class LocalParticipant extends Participant { /// Handle broadcast state change (iOS only) void _broadcastStateChanged() { - final isEnabled = BroadcastManager().isBroadcasting && BroadcastManager().shouldPublishTrack; + final isEnabled = BroadcastManager().isBroadcasting && + BroadcastManager().shouldPublishTrack; setScreenShareEnabled(isEnabled); } @@ -632,7 +633,8 @@ class LocalParticipant extends Participant { ScreenShareCaptureOptions captureOptions = screenShareCaptureOptions ?? room.roomOptions.defaultScreenShareCaptureOptions; - if (lkPlatformIs(PlatformType.iOS) && !BroadcastManager().isBroadcasting) { + if (lkPlatformIs(PlatformType.iOS) && + !BroadcastManager().isBroadcasting) { // Wait until broadcasting to publish track BroadcastManager().requestActivation(); return null; diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 1850df35f..c507cdab5 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -111,7 +111,7 @@ class Native { return null; default: logger.warning('Method ${call.method} is not implemented.'); - return null; + return null; } } @@ -140,4 +140,4 @@ class Native { // Initialize the channel before first reference so method calls can be handled. // ignore: unused_element -final _channelInitializer = Native.channel; \ No newline at end of file +final _channelInitializer = Native.channel; From 44f4f5a0eaf312b3079186ce7bb666eac946b358 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:38:33 -0800 Subject: [PATCH 9/9] Run import sorter --- lib/src/managers/broadcast_manager.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/managers/broadcast_manager.dart b/lib/src/managers/broadcast_manager.dart index a1b407347..913d7a7b2 100644 --- a/lib/src/managers/broadcast_manager.dart +++ b/lib/src/managers/broadcast_manager.dart @@ -13,7 +13,9 @@ // limitations under the License. import 'package:flutter/foundation.dart'; + import 'package:meta/meta.dart'; + import '../support/native.dart'; /// Manages broadcast state and track publication for screen sharing on iOS.