Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 49 additions & 12 deletions Sources/StreamVideo/CallSettings/MicrophoneManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,72 @@ public final class MicrophoneManager: ObservableObject, CallSettingsManager, @un
/// The status of the microphone.
@Published public internal(set) var status: CallSettingsStatus
let state = CallSettingsState()

init(callController: CallController, initialStatus: CallSettingsStatus) {
self.callController = callController
status = initialStatus
}

/// Toggles the microphone state.
public func toggle() async throws {
try await updateAudioStatus(status.next)
public func toggle(
file: StaticString = #file,
function: StaticString = #function,
line: UInt = #line
) async throws {
try await updateAudioStatus(
status.next,
file: file,
function: function,
line: line
)
}

/// Enables the microphone.
public func enable() async throws {
try await updateAudioStatus(.enabled)
public func enable(
file: StaticString = #file,
function: StaticString = #function,
line: UInt = #line
) async throws {
try await updateAudioStatus(
.enabled,
file: file,
function: function,
line: line
)
}

/// Disables the microphone.
public func disable() async throws {
try await updateAudioStatus(.disabled)
public func disable(
file: StaticString = #file,
function: StaticString = #function,
line: UInt = #line
) async throws {
try await updateAudioStatus(
.disabled,
file: file,
function: function,
line: line
)
}

// MARK: - private

private func updateAudioStatus(_ status: CallSettingsStatus) async throws {
private func updateAudioStatus(
_ status: CallSettingsStatus,
file: StaticString = #file,
function: StaticString = #function,
line: UInt = #line
) async throws {
try await updateState(
newState: status.boolValue,
current: self.status.boolValue,
action: { [unowned self] state in
try await callController.changeAudioState(isEnabled: state)
try await callController.changeAudioState(
isEnabled: state,
file: file,
function: function,
line: line
)
},
onUpdate: { _ in
self.status = status
Expand Down
2 changes: 1 addition & 1 deletion Sources/StreamVideo/CallState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public class CallState: ObservableObject {
@Published public internal(set) var anonymousParticipantCount: UInt32 = 0
@Published public internal(set) var participantCount: UInt32 = 0
@Published public internal(set) var isInitialized: Bool = false
@Published public internal(set) var callSettings = CallSettings()
@Published public internal(set) var callSettings: CallSettings = .default

@Published public internal(set) var isCurrentUserScreensharing: Bool = false
@Published public internal(set) var duration: TimeInterval = 0
Expand Down
14 changes: 12 additions & 2 deletions Sources/StreamVideo/Controllers/CallController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,18 @@ class CallController: @unchecked Sendable {

/// Changes the audio state for the current user.
/// - Parameter isEnabled: whether audio should be enabled.
func changeAudioState(isEnabled: Bool) async throws {
await webRTCCoordinator.changeAudioState(isEnabled: isEnabled)
func changeAudioState(
isEnabled: Bool,
file: StaticString = #file,
function: StaticString = #function,
line: UInt = #line
) async throws {
await webRTCCoordinator.changeAudioState(
isEnabled: isEnabled,
file: file,
function: function,
line: line
)
}

/// Changes the video state for the current user.
Expand Down
2 changes: 2 additions & 0 deletions Sources/StreamVideo/Models/CallSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import Foundation

/// Represents the settings for a call.
public final class CallSettings: ObservableObject, Sendable, Equatable, CustomStringConvertible {
public static let `default` = CallSettings()

/// Whether the audio is on for the current user.
public let audioOn: Bool
/// Whether the video is on for the current user.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ extension AVAudioSession.PortOverride {
public var description: String {
switch self {
case .none:
return "None"
return ".none"
case .speaker:
return "Speaker"
return ".speaker"
@unknown default:
return "Unknown"
return ".unknown"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ extension RTCAudioStore {
subsystems: .audioSession
)
delegate?.audioSessionAdapterDidUpdateSpeakerOn(
session.currentRoute.isSpeaker
session.currentRoute.isSpeaker,
file: #file,
function: #function,
line: #line
)
}
return
Expand All @@ -101,12 +104,18 @@ extension RTCAudioStore {
switch (activeCallSettings.speakerOn, session.currentRoute.isSpeaker) {
case (true, false):
delegate?.audioSessionAdapterDidUpdateSpeakerOn(
false
false,
file: #file,
function: #function,
line: #line
)

case (false, true) where session.category == AVAudioSession.Category.playAndRecord.rawValue:
delegate?.audioSessionAdapterDidUpdateSpeakerOn(
true
true,
file: #file,
function: #function,
line: #line
)

default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ protocol StreamAudioSessionAdapterDelegate: AnyObject {
/// - audioSession: The `AudioSession` instance that made the update.
/// - callSettings: The updated `CallSettings`.
func audioSessionAdapterDidUpdateSpeakerOn(
_ speakerOn: Bool
_ speakerOn: Bool,
file: StaticString,
function: StaticString,
line: UInt
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,12 @@ extension String.StringInterpolation {
mutating func appendInterpolation<T: CustomStringConvertible>(_ value: T?) {
appendInterpolation(value ?? "nil" as CustomStringConvertible)
}

mutating func appendInterpolation<T: AnyObject>(_ value: T) {
if let convertible = value as? CustomStringConvertible {
appendInterpolation(convertible)
} else {
appendInterpolation("\(Unmanaged.passUnretained(value).toOpaque())")
}
}
}
42 changes: 30 additions & 12 deletions Sources/StreamVideo/Utils/Store/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ final class Store<Namespace: StoreNamespace>: @unchecked Sendable {

/// Executor that processes actions through the pipeline.
private let executor: StoreExecutor<Namespace>


/// Coordinator that can skip redundant actions before execution.
private let coordinator: StoreCoordinator<Namespace>

/// Publisher that holds and emits the current state.
private let stateSubject: CurrentValueSubject<Namespace.State, Never>

Expand All @@ -81,20 +84,23 @@ final class Store<Namespace: StoreNamespace>: @unchecked Sendable {
/// - middleware: Array of middleware for side effects.
/// - logger: Logger for recording store operations.
/// - executor: Executor for processing the action pipeline.
/// - coordinator: Coordinator that validates actions before execution.
init(
identifier: String,
initialState: Namespace.State,
reducers: [Reducer<Namespace>],
middleware: [Middleware<Namespace>],
logger: StoreLogger<Namespace>,
executor: StoreExecutor<Namespace>
executor: StoreExecutor<Namespace>,
coordinator: StoreCoordinator<Namespace>
) {
self.identifier = identifier
stateSubject = .init(initialState)
self.reducers = reducers
self.middleware = []
self.logger = logger
self.executor = executor
self.coordinator = coordinator

middleware.forEach { add($0) }
}
Expand Down Expand Up @@ -241,17 +247,17 @@ final class Store<Namespace: StoreNamespace>: @unchecked Sendable {
/// logger.error("Action failed: \(error)")
/// }
/// ```

///
/// - Returns: A ``StoreTask`` that can be awaited or ignored for
/// fire-and-forget semantics.
@discardableResult
/// - Returns: A ``StoreTask`` that can be awaited for completion
/// or ignored for fire-and-forget semantics.
func dispatch(
_ actions: [StoreActionBox<Namespace.Action>],
file: StaticString = #file,
function: StaticString = #function,
line: UInt = #line
) -> StoreTask<Namespace> {
let task = StoreTask(executor: executor)
let task = StoreTask(executor: executor, coordinator: coordinator)
processingQueue.addTaskOperation { [weak self] in
guard let self else {
return
Expand All @@ -272,9 +278,13 @@ final class Store<Namespace: StoreNamespace>: @unchecked Sendable {
return task
}

/// Dispatches a single boxed action asynchronously.
///
/// Wraps the action in an array and forwards to
/// ``dispatch(_:file:function:line:)``.
///
/// - Returns: A ``StoreTask`` that can be awaited or ignored.
@discardableResult
/// - Returns: A ``StoreTask`` that can be awaited for completion
/// or ignored for fire-and-forget semantics.
func dispatch(
_ action: StoreActionBox<Namespace.Action>,
file: StaticString = #file,
Expand All @@ -289,9 +299,13 @@ final class Store<Namespace: StoreNamespace>: @unchecked Sendable {
)
}

/// Dispatches multiple unboxed actions asynchronously.
///
/// Actions are boxed automatically before being forwarded to
/// ``dispatch(_:file:function:line:)``.
///
/// - Returns: A ``StoreTask`` that can be awaited or ignored.
@discardableResult
/// - Returns: A ``StoreTask`` that can be awaited for completion
/// or ignored for fire-and-forget semantics.
func dispatch(
_ actions: [Namespace.Action],
file: StaticString = #file,
Expand All @@ -306,9 +320,13 @@ final class Store<Namespace: StoreNamespace>: @unchecked Sendable {
)
}

/// Dispatches a single unboxed action asynchronously.
///
/// The action is boxed automatically and forwarded to
/// ``dispatch(_:file:function:line:)``.
///
/// - Returns: A ``StoreTask`` that can be awaited or ignored.
@discardableResult
/// - Returns: A ``StoreTask`` that can be awaited for completion
/// or ignored for fire-and-forget semantics.
func dispatch(
_ action: Namespace.Action,
file: StaticString = #file,
Expand Down
33 changes: 33 additions & 0 deletions Sources/StreamVideo/Utils/Store/StoreCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

/// Coordinates store actions to prevent redundant state transitions.
///
/// The coordinator evaluates an action against the current state before the
/// store processes it.
/// Implementations can override ``shouldExecute(action:state:)``
/// to skip actions that would not yield a different state,
/// reducing unnecessary work along the pipeline.
class StoreCoordinator<Namespace: StoreNamespace>: @unchecked Sendable {

/// Determines whether an action should run for the provided state snapshot.
///
/// This default implementation always executes the action.
/// Subclasses can override the method to run diffing logic or other
/// heuristics that detect state changes and return `false` when the action
/// can be safely skipped.
///
/// - Parameters:
/// - action: The action that is about to be dispatched.
/// - state: The current state before the action runs.
/// - Returns: `true` to process the action; `false` to skip it.
func shouldExecute(
action: Namespace.Action,
state: Namespace.State
) -> Bool {
true
}
}
Loading