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); } 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() + } + } +} diff --git a/lib/src/managers/broadcast_manager.dart b/lib/src/managers/broadcast_manager.dart new file mode 100644 index 000000000..913d7a7b2 --- /dev/null +++ b/lib/src/managers/broadcast_manager.dart @@ -0,0 +1,62 @@ +// 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(); + } +} diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index 1731ff278..9f418fa04 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,23 @@ 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 +633,13 @@ 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. diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index c5fb83ee0..c507cdab5 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; 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) { diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index b3f0fc982..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,14 +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) @@ -41,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) @@ -90,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); @@ -109,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); @@ -120,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 { @@ -227,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) @@ -243,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)