diff --git a/Sources/StreamVideo/CallKit/CallKitService.swift b/Sources/StreamVideo/CallKit/CallKitService.swift index 920cdd569..e4500e319 100644 --- a/Sources/StreamVideo/CallKit/CallKitService.swift +++ b/Sources/StreamVideo/CallKit/CallKitService.swift @@ -13,6 +13,7 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { @Injected(\.callCache) private var callCache @Injected(\.uuidFactory) private var uuidFactory + @Injected(\.timers) private var timers /// Represents a call that is being managed by the service. final class CallEntry: Equatable, @unchecked Sendable { @@ -541,20 +542,16 @@ open class CallKitService: NSObject, CXProviderDelegate, @unchecked Sendable { /// - Parameter callState: The state of the call. open func setUpRingingTimer(for callState: GetCallResponse) { let timeout = TimeInterval(callState.call.settings.ring.autoCancelTimeoutMs / 1000) - ringingTimerCancellable = Foundation.Timer.publish( - every: timeout, - on: .main, - in: .default - ) - .autoconnect() - .sink { [weak self] _ in - log.debug( - "Detected ringing timeout, hanging up...", - subsystems: .callKit - ) - self?.callEnded(callState.call.cid, ringingTimedOut: true) - self?.ringingTimerCancellable = nil - } + ringingTimerCancellable = timers + .timer(for: timeout) + .sink { [weak self] _ in + log.debug( + "Detected ringing timeout, hanging up...", + subsystems: .callKit + ) + self?.callEnded(callState.call.cid, ringingTimedOut: true) + self?.ringingTimerCancellable = nil + } } /// A method that's being called every time the StreamVideo instance is getting updated. diff --git a/Sources/StreamVideo/CallState.swift b/Sources/StreamVideo/CallState.swift index c72ddb24a..5284c737e 100644 --- a/Sources/StreamVideo/CallState.swift +++ b/Sources/StreamVideo/CallState.swift @@ -33,7 +33,8 @@ public struct PermissionRequest: @unchecked Sendable, Identifiable { public class CallState: ObservableObject { @Injected(\.streamVideo) var streamVideo - + @Injected(\.timers) var timers + /// The id of the current session. /// When a call is started, a unique session identifier is assigned to the user in the call. @Published public internal(set) var sessionId: String = "" @@ -153,7 +154,7 @@ public class CallState: ObservableObject { } private var localCallSettingsUpdate = false - private var durationTimer: Foundation.Timer? + private var durationCancellable: AnyCancellable? /// We mark this one as `nonisolated` to allow us to initialise a state instance without isolation. /// That's a safe operation because `MainActor` is only required to ensure that all `@Published` @@ -497,36 +498,21 @@ public class CallState: ObservableObject { private func setupDurationTimer() { resetTimer() - durationTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { [weak self] timer in - guard let self else { - timer.invalidate() - return - } - Task { - await MainActor.run { - self.updateDuration() + durationCancellable = timers + .timer(for: 1.0) + .receive(on: DispatchQueue.main) + .compactMap { [weak self] _ in + if let startedAt = self?.startedAt { + return Date().timeIntervalSince(startedAt) + } else { + return 0 } } - }) + .assign(to: \.duration, onWeak: self) } private func resetTimer() { - durationTimer?.invalidate() - durationTimer = nil - } - - @objc private func updateDuration() { - guard let startedAt else { - update(duration: 0) - return - } - let timeInterval = Date().timeIntervalSince(startedAt) - update(duration: timeInterval) - } - - private func update(duration: TimeInterval) { - if duration != self.duration { - self.duration = duration - } + durationCancellable?.cancel() + durationCancellable = nil } } diff --git a/Sources/StreamVideo/StreamVideo.swift b/Sources/StreamVideo/StreamVideo.swift index 6777f38e2..d770557dd 100644 --- a/Sources/StreamVideo/StreamVideo.swift +++ b/Sources/StreamVideo/StreamVideo.swift @@ -15,6 +15,7 @@ public typealias UserTokenUpdater = @Sendable(UserToken) -> Void public class StreamVideo: ObservableObject, @unchecked Sendable { @Injected(\.callCache) private var callCache + @Injected(\.timers) private var timers public final class State: ObservableObject, @unchecked Sendable { @Published public internal(set) var connection: ConnectionStatus @@ -500,11 +501,23 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { log.debug("WS connected") } - while (!connected && !timeout) { - try await Task.sleep(nanoseconds: 100_000) - } - - if timeout { + do { + log.debug("Listening for WS connection") + _ = try await timers + .timer(for: 0.1) + .filter { [weak webSocketClient] _ in + guard let webSocketClient else { + return false + } + switch webSocketClient.connectionState { + case .connected: + return true + default: + return false + } + } + .nextValue(timeout: 30) + } catch { log.debug("Timeout while waiting for WS connection opening") throw ClientError.NetworkError() } @@ -554,18 +567,16 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { } log.debug("Waiting for connection id") - while (loadConnectionIdFromHealthcheck() == nil && !timeout) { - try? await Task.sleep(nanoseconds: 100_000) - } - - control.cancel() - - if let connectionId = loadConnectionIdFromHealthcheck() { - log.debug("Connection id available from the WS") - return connectionId + do { + return try await timers + .timer(for: 0.1) + .log(.debug) { _ in "Waiting for connection id" } + .compactMap { [weak self] _ in self?.loadConnectionIdFromHealthcheck() } + .nextValue(timeout: 5) + } catch { + log.warning("Unable to load connectionId.") + return "" } - - return "" } private func loadConnectionIdFromHealthcheck() -> String? { diff --git a/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/StreamCallAudioRecorder.swift b/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/StreamCallAudioRecorder.swift index d5b816d0a..df964f804 100644 --- a/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/StreamCallAudioRecorder.swift +++ b/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/StreamCallAudioRecorder.swift @@ -16,6 +16,7 @@ open class StreamCallAudioRecorder: @unchecked Sendable { @Injected(\.activeCallProvider) private var activeCallProvider @Injected(\.activeCallAudioSession) private var activeCallAudioSession + @Injected(\.timers) private var timers /// The builder used to create the AVAudioRecorder instance. let audioRecorderBuilder: AVAudioRecorderBuilder @@ -123,10 +124,8 @@ open class StreamCallAudioRecorder: @unchecked Sendable { updateMetersTimerCancellable?.cancel() disposableBag.remove("update-meters") - updateMetersTimerCancellable = Foundation - .Timer - .publish(every: 0.1, on: .main, in: .default) - .autoconnect() + updateMetersTimerCancellable = timers + .timer(for: ScreenPropertiesAdapter.currentValue.refreshRate) .sinkTask(storeIn: disposableBag, identifier: "update-meters") { [weak self, audioRecorder] _ in audioRecorder.updateMeters() self?._metersPublisher.send(audioRecorder.averagePower(forChannel: 0)) diff --git a/Sources/StreamVideo/Utils/OrderedCapacityQueue/OrderedCapacityQueue.swift b/Sources/StreamVideo/Utils/OrderedCapacityQueue/OrderedCapacityQueue.swift index 7086beb6f..a8a6d1b18 100644 --- a/Sources/StreamVideo/Utils/OrderedCapacityQueue/OrderedCapacityQueue.swift +++ b/Sources/StreamVideo/Utils/OrderedCapacityQueue/OrderedCapacityQueue.swift @@ -8,6 +8,8 @@ import Foundation /// A thread-safe queue that maintains a fixed capacity and removes elements after /// a specified time interval. final class OrderedCapacityQueue { + @Injected(\.timers) private var timers + private let queue = UnfairQueue() /// The maximum number of elements the queue can hold. @@ -48,10 +50,8 @@ final class OrderedCapacityQueue { init(capacity: Int, removalTime: TimeInterval) { self.capacity = capacity self.removalTime = removalTime - removalTimerCancellable = Foundation - .Timer - .publish(every: ScreenPropertiesAdapter.currentValue.refreshRate, on: .main, in: .default) - .autoconnect() + removalTimerCancellable = timers + .timer(for: ScreenPropertiesAdapter.currentValue.refreshRate) .sink { [weak self] _ in self?.removeItemsIfRequired() } } @@ -86,14 +86,8 @@ final class OrderedCapacityQueue { /// should be enabled. private func toggleRemovalObservation(_ isEnabled: Bool) { if isEnabled, removalTimerCancellable == nil { - removalTimerCancellable = Foundation - .Timer - .publish( - every: ScreenPropertiesAdapter.currentValue.refreshRate, - on: .main, - in: .default - ) - .autoconnect() + removalTimerCancellable = timers + .timer(for: ScreenPropertiesAdapter.currentValue.refreshRate) .sink { [weak self] _ in self?.removeItemsIfRequired() } } else if !isEnabled, removalTimerCancellable != nil { removalTimerCancellable?.cancel() diff --git a/Sources/StreamVideo/Utils/ScreenPropertiesAdapter/ScreenPropertiesAdapter.swift b/Sources/StreamVideo/Utils/ScreenPropertiesAdapter/ScreenPropertiesAdapter.swift index fae1e7cf1..82bcc2bc8 100644 --- a/Sources/StreamVideo/Utils/ScreenPropertiesAdapter/ScreenPropertiesAdapter.swift +++ b/Sources/StreamVideo/Utils/ScreenPropertiesAdapter/ScreenPropertiesAdapter.swift @@ -7,11 +7,11 @@ import Foundation import UIKit #endif -final class ScreenPropertiesAdapter: @unchecked Sendable { +public final class ScreenPropertiesAdapter: @unchecked Sendable { - private(set) var preferredFramesPerSecond: Int = 0 - private(set) var refreshRate: TimeInterval = 0 - private(set) var scale: CGFloat = 0 + public private(set) var preferredFramesPerSecond: Int = 0 + public private(set) var refreshRate: TimeInterval = 0 + public private(set) var scale: CGFloat = 0 init() { Task { @MainActor in @@ -29,11 +29,11 @@ final class ScreenPropertiesAdapter: @unchecked Sendable { } extension ScreenPropertiesAdapter: InjectionKey { - nonisolated(unsafe) static var currentValue: ScreenPropertiesAdapter = .init() + public nonisolated(unsafe) static var currentValue: ScreenPropertiesAdapter = .init() } extension InjectedValues { - var screenProperties: ScreenPropertiesAdapter { + public var screenProperties: ScreenPropertiesAdapter { set { Self[ScreenPropertiesAdapter.self] = newValue } get { Self[ScreenPropertiesAdapter.self] } } diff --git a/Sources/StreamVideo/Utils/Timers.swift b/Sources/StreamVideo/Utils/Timers/Timer.swift similarity index 100% rename from Sources/StreamVideo/Utils/Timers.swift rename to Sources/StreamVideo/Utils/Timers/Timer.swift diff --git a/Sources/StreamVideo/Utils/Timers/TimerStorage.swift b/Sources/StreamVideo/Utils/Timers/TimerStorage.swift new file mode 100644 index 000000000..47fc777ad --- /dev/null +++ b/Sources/StreamVideo/Utils/Timers/TimerStorage.swift @@ -0,0 +1,63 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +/// A protocol that provides publishers emitting time events at a set interval. +public protocol TimerProviding { + /// Returns a publisher that emits the current date every specified interval. + /// + /// - Parameter interval: The time interval at which the publisher should emit. + /// - Returns: A publisher emitting `Date` values on the given interval. + func timer(for interval: TimeInterval) -> AnyPublisher +} + +/// A concrete implementation of `TimerProviding` that reuses publishers +/// for the same time intervals. +final class TimerStorage: TimerProviding { + /// A serial queue to synchronize access to the internal storage. + private let queue = UnfairQueue() + + /// Stores interval-publisher pairs for reuse. + private var storage: [TimeInterval: AnyPublisher] = [:] + + /// Creates a new instance of `TimerStorage`. + init() {} + + /// Returns a shared timer publisher for the given interval. If one already + /// exists, it is reused. Otherwise, a new one is created and stored. + /// + /// - Parameter interval: The time interval at which the timer should tick. + /// - Returns: A publisher that emits the current date on the main run loop. + public func timer(for interval: TimeInterval) -> AnyPublisher { + queue.sync { + if let publisher = storage[interval] { + return publisher + } else { + let publisher = Foundation + .Timer + .publish(every: interval, tolerance: interval, on: .main, in: .common) + .autoconnect() + .eraseToAnyPublisher() + storage[interval] = publisher + return publisher + } + } + } +} + +/// An injection key for providing a default `TimerProviding` implementation. +enum TimerProviderKey: InjectionKey { + /// The default value for the `TimerProviding` dependency. + nonisolated(unsafe) public static var currentValue: TimerProviding = TimerStorage() +} + +extension InjectedValues { + /// Accessor for the shared `TimerProviding` dependency. + public var timers: TimerProviding { + get { Self[TimerProviderKey.self] } + set { Self[TimerProviderKey.self] = newValue } + } +} diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift index 8c5c37ed3..dbf6f89e5 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift @@ -30,6 +30,7 @@ extension WebRTCCoordinator.StateMachine.Stage { @unchecked Sendable { @Injected(\.internetConnectionObserver) private var internetConnectionObserver + @Injected(\.timers) private var timers private var internetObservationCancellable: AnyCancellable? private var timeInStageCancellable: AnyCancellable? @@ -186,10 +187,8 @@ extension WebRTCCoordinator.StateMachine.Stage { guard context.disconnectionTimeout > 0 else { return } - timeInStageCancellable = Foundation - .Timer - .publish(every: context.disconnectionTimeout, on: .main, in: .default) - .autoconnect() + timeInStageCancellable = timers + .timer(for: context.disconnectionTimeout) .sink { [weak self] _ in self?.didTimeInStageExpired() } } diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCStatsReporter.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCStatsReporter.swift index e0af3cf20..40bd7215a 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCStatsReporter.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCStatsReporter.swift @@ -16,6 +16,7 @@ import Foundation final class WebRTCStatsReporter: @unchecked Sendable { @Injected(\.thermalStateObserver) private var thermalStateObserver + @Injected(\.timers) private var timers /// The session ID associated with this reporter. var sessionID: String @@ -121,10 +122,8 @@ final class WebRTCStatsReporter: @unchecked Sendable { } collectionCancellable?.cancel() - collectionCancellable = Foundation - .Timer - .publish(every: interval, on: .main, in: .default) - .autoconnect() + collectionCancellable = timers + .timer(for: interval) .log(.debug, subsystems: .webRTC) { _ in "Will collect stats." } .sink { [weak self] _ in self?.collectStats() } @@ -142,10 +141,8 @@ final class WebRTCStatsReporter: @unchecked Sendable { } deliveryCancellable?.cancel() - deliveryCancellable = Foundation - .Timer - .publish(every: interval, on: .main, in: .default) - .autoconnect() + deliveryCancellable = timers + .timer(for: interval) .compactMap { [weak self] _ in self?.latestReportSubject.value } .log(.debug, subsystems: .webRTC) { [weak self] in "Will deliver stats report (timestamp:\($0.timestamp)) on \(self?.sfuAdapter?.hostname ?? "-")." diff --git a/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift b/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift index 9ccb70777..3957837dd 100644 --- a/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift +++ b/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift @@ -28,7 +28,8 @@ public struct LivestreamPlayer: View { @Injected(\.colors) var colors @Injected(\.formatters.mediaDuration) private var formatter: MediaDurationFormatter - + @Injected(\.timers) private var timers + var viewFactory: Factory /// The policy that defines how users join the livestream. @@ -155,9 +156,8 @@ public struct LivestreamPlayer: View { let startsAt = state.startsAt, livestreamState == .backstage, timerCancellable == nil { - timerCancellable = Timer - .publish(every: 1, on: .main, in: .default) - .autoconnect() + timerCancellable = timers + .timer(for: 1) .sinkTask { @MainActor _ in countdown = startsAt.timeIntervalSinceNow if countdown <= 0 { diff --git a/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureEnforcedStopAdapter.swift b/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureEnforcedStopAdapter.swift index 88da048d3..a30ad3234 100644 --- a/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureEnforcedStopAdapter.swift +++ b/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureEnforcedStopAdapter.swift @@ -17,6 +17,8 @@ final class PictureInPictureEnforcedStopAdapter { /// Adapter that provides the current application state. @Injected(\.applicationStateAdapter) private var applicationStateAdapter + @Injected(\.timers) private var timers + @Injected(\.screenProperties) private var screenProperties /// A serial dispatch queue for background processing. private let processingQueue = DispatchQueue(label: UUID().uuidString) @@ -63,10 +65,8 @@ final class PictureInPictureEnforcedStopAdapter { ) { switch (applicationState, isPictureInPictureActive) { case (.foreground, true): - Foundation - .Timer - .publish(every: 0.1, on: .main, in: .default) - .autoconnect() + timers + .timer(for: screenProperties.refreshRate) .filter { [weak self] _ in self?.applicationStateAdapter.state == .foreground } .log(.debug) { _ in "Will attempt to forcefully stop Picture-in-Picture." } .receive(on: DispatchQueue.main) diff --git a/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureTrackStateAdapter.swift b/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureTrackStateAdapter.swift index 2bf5249f5..4c88dc34c 100644 --- a/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureTrackStateAdapter.swift +++ b/Sources/StreamVideoSwiftUI/Utils/PictureInPicture/PictureInPictureTrackStateAdapter.swift @@ -13,6 +13,9 @@ import StreamWebRTC /// and maintains track state consistency. final class PictureInPictureTrackStateAdapter: @unchecked Sendable { + @Injected(\.timers) private var timers + @Injected(\.screenProperties) private var screenProperties + private enum DisposableKey: String { case timePublisher } private let store: PictureInPictureStore @@ -70,9 +73,8 @@ final class PictureInPictureTrackStateAdapter: @unchecked Sendable { self.activeTracksBeforePiP = activeTracksBeforePiP } - Timer - .publish(every: 0.1, on: .main, in: .default) - .autoconnect() + timers + .timer(for: screenProperties.refreshRate) .sink { [weak self] _ in self?.checkTracksState() } .store(in: disposableBag, key: DisposableKey.timePublisher.rawValue) diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index cb3420662..6fd7864c5 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -642,6 +642,7 @@ 40D1657F2B5FE8AB00C6D951 /* (null) in Sources */ = {isa = PBXBuildFile; }; 40D2873B2DB12CAD006AD8C7 /* DefaultAudioSessionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D2873A2DB12CAD006AD8C7 /* DefaultAudioSessionPolicyTests.swift */; }; 40D2873D2DB12E46006AD8C7 /* OwnCapabilitiesAudioSessionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D2873C2DB12E46006AD8C7 /* OwnCapabilitiesAudioSessionPolicyTests.swift */; }; + 40D3A02D2DE3437700260532 /* TimerStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D3A02C2DE3437700260532 /* TimerStorage.swift */; }; 40D6ADDD2ACDB51C00EF5336 /* VideoRenderer_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D6ADDC2ACDB51C00EF5336 /* VideoRenderer_Tests.swift */; }; 40D946412AA5ECEF00C8861B /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D946402AA5ECEF00C8861B /* CodeScanner.swift */; }; 40D946432AA5F65300C8861B /* DemoQRCodeScannerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D946422AA5F65300C8861B /* DemoQRCodeScannerButton.swift */; }; @@ -1240,7 +1241,7 @@ 84A7E1922883647200526C98 /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7E18F2883647200526C98 /* Event.swift */; }; 84A7E1942883652000526C98 /* EventMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7E1932883652000526C98 /* EventMiddleware.swift */; }; 84A7E1962883661A00526C98 /* BackgroundTaskScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7E1952883661A00526C98 /* BackgroundTaskScheduler.swift */; }; - 84A7E1A82883E46200526C98 /* Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7E1A72883E46200526C98 /* Timers.swift */; }; + 84A7E1A82883E46200526C98 /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7E1A72883E46200526C98 /* Timer.swift */; }; 84A7E1AE2883E6B300526C98 /* HTTPUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7E1AD2883E6B300526C98 /* HTTPUtils.swift */; }; 84A7E1B02883E73100526C98 /* EventBatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7E1AF2883E73100526C98 /* EventBatcher.swift */; }; 84AF64D2287C78E70012A503 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AF64D1287C78E70012A503 /* User.swift */; }; @@ -2100,6 +2101,7 @@ 40D1657D2B5FE82200C6D951 /* HalfSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HalfSheetView.swift; sourceTree = ""; }; 40D2873A2DB12CAD006AD8C7 /* DefaultAudioSessionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAudioSessionPolicyTests.swift; sourceTree = ""; }; 40D2873C2DB12E46006AD8C7 /* OwnCapabilitiesAudioSessionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwnCapabilitiesAudioSessionPolicyTests.swift; sourceTree = ""; }; + 40D3A02C2DE3437700260532 /* TimerStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerStorage.swift; sourceTree = ""; }; 40D6ADDC2ACDB51C00EF5336 /* VideoRenderer_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRenderer_Tests.swift; sourceTree = ""; }; 40D946402AA5ECEF00C8861B /* CodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = ""; }; 40D946422AA5F65300C8861B /* DemoQRCodeScannerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoQRCodeScannerButton.swift; sourceTree = ""; }; @@ -2630,7 +2632,7 @@ 84A7E18F2883647200526C98 /* Event.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = ""; }; 84A7E1932883652000526C98 /* EventMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMiddleware.swift; sourceTree = ""; }; 84A7E1952883661A00526C98 /* BackgroundTaskScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskScheduler.swift; sourceTree = ""; }; - 84A7E1A72883E46200526C98 /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = ""; }; + 84A7E1A72883E46200526C98 /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = ""; }; 84A7E1A92883E4AD00526C98 /* APIKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKey.swift; sourceTree = ""; }; 84A7E1AD2883E6B300526C98 /* HTTPUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPUtils.swift; sourceTree = ""; }; 84A7E1AF2883E73100526C98 /* EventBatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBatcher.swift; sourceTree = ""; }; @@ -3492,6 +3494,15 @@ path = StreamPixelBufferRepository; sourceTree = ""; }; + 404780412DE61EAD0085AE05 /* Timers */ = { + isa = PBXGroup; + children = ( + 40D3A02C2DE3437700260532 /* TimerStorage.swift */, + 84A7E1A72883E46200526C98 /* Timer.swift */, + ); + path = Timers; + sourceTree = ""; + }; 4049CE802BBBF73A003D07D2 /* AsyncImage */ = { isa = PBXGroup; children = ( @@ -5818,6 +5829,7 @@ 84AF64D3287C79220012A503 /* Utils */ = { isa = PBXGroup; children = ( + 404780412DE61EAD0085AE05 /* Timers */, 40AD64C32DC269E60077AE15 /* Proximity */, 40FF825B2D63523C0029AA80 /* Sorting */, 403A4FE92D660E3E00ECD46A /* Swift6Migration */, @@ -5845,7 +5857,6 @@ 8268615F290A7556005BFFED /* SystemEnvironment.swift */, 841FF51A2A5FED4800809BBB /* SystemEnvironment+XStreamClient.swift */, 401A0F022AB1C1B600BE2DBD /* ThermalStateObserver.swift */, - 84A7E1A72883E46200526C98 /* Timers.swift */, 40A0E9612B88D3DC0089E8D3 /* UIInterfaceOrientation+CGOrientation.swift */, 84C2997C28784BB30034B735 /* Utils.swift */, 4067F3062CDA32F0002E28BD /* AudioSession */, @@ -7302,6 +7313,7 @@ 84BAD7842A6C01AF00733156 /* BroadcastBufferReader.swift in Sources */, 40034C312CFE168D00A318B1 /* StreamLocaleProvider.swift in Sources */, 40916E782DA94A150061D860 /* Publisher+AsyncStream.swift in Sources */, + 40D3A02D2DE3437700260532 /* TimerStorage.swift in Sources */, 84D91E9C2C7CB0AA00B163A0 /* CallSessionParticipantCountsUpdatedEvent.swift in Sources */, 846E4AF529CDEA66003733AB /* ConnectUserDetailsRequest.swift in Sources */, 846D16262A52CE8C0036CE4C /* SpeakerManager.swift in Sources */, @@ -7565,7 +7577,7 @@ 841BAA312BD15CDE000C73E4 /* VideoQuality.swift in Sources */, 4067F30F2CDA3394002E28BD /* AVAudioSessionCategoryOptions+Convenience.swift in Sources */, 40BBC4A32C623D03002AEF92 /* RTCIceGatheringState+CustomStringConvertible.swift in Sources */, - 84A7E1A82883E46200526C98 /* Timers.swift in Sources */, + 84A7E1A82883E46200526C98 /* Timer.swift in Sources */, 40B284E12D52422A0064C1FE /* AVAudioSessionPortOverride+Convenience.swift in Sources */, 84DC38B129ADFCFD00946713 /* AudioSettings.swift in Sources */, 848CCCED2AB8ED8F002E83A2 /* CallHLSBroadcastingStartedEvent.swift in Sources */,