diff --git a/Sources/DolbyIORTSCore/Builder/StreamSourceBuilder.swift b/Sources/DolbyIORTSCore/Builder/StreamSourceBuilder.swift index 8f5dbcb..76fc820 100644 --- a/Sources/DolbyIORTSCore/Builder/StreamSourceBuilder.swift +++ b/Sources/DolbyIORTSCore/Builder/StreamSourceBuilder.swift @@ -125,10 +125,6 @@ final class StreamSourceBuilder { } func build() throws -> StreamSource { - guard !hasMissingAudioTrack else { - throw BuildError.missingAudioTrack - } - guard !hasMissingVideoTrack, let videoTrack = videoTrack else { throw BuildError.missingVideoTrack } diff --git a/Sources/DolbyIORTSCore/Manager/MillicastLoggerHandler.swift b/Sources/DolbyIORTSCore/Manager/MillicastLoggerHandler.swift index 97dddec..531ceef 100644 --- a/Sources/DolbyIORTSCore/Manager/MillicastLoggerHandler.swift +++ b/Sources/DolbyIORTSCore/Manager/MillicastLoggerHandler.swift @@ -9,16 +9,43 @@ import os final class MillicastLoggerHandler: NSObject { private static let logger = Logger.make(category: String(describing: MillicastLoggerHandler.self)) - + private var logFilePath: String? + override init() { super.init() MCLogger.setDelegate(self) MCLogger.disableWebsocketLogs(true) } + + func setLogFilePath(filePath: String?) { + logFilePath = filePath + } } extension MillicastLoggerHandler: MCLoggerDelegate { func onLog(withMessage message: String!, level: MCLogLevel) { Self.logger.debug("🪵 onLog - \(message), log-level - \(level.rawValue)") + + guard + let logFilePath = logFilePath, + let messageData = "\(String(describing: message))\n".data(using: .utf8) + else { + Self.logger.error("🪵 Error writing file - no file path provided") + return + } + + let fileURL = URL(fileURLWithPath: logFilePath, isDirectory: false) + do { + if FileManager.default.fileExists(atPath: fileURL.path) { + let fileHandle = try FileHandle(forWritingTo: fileURL) + fileHandle.seekToEndOfFile() + fileHandle.write(messageData) + fileHandle.closeFile() + } else { + try messageData.write(to: fileURL, options: .atomicWrite) + } + } catch { + Self.logger.error("🪵 Error writing file - \(error.localizedDescription)") + } } } diff --git a/Sources/DolbyIORTSCore/Manager/SubscriptionManager.swift b/Sources/DolbyIORTSCore/Manager/SubscriptionManager.swift index 78680cd..64e8004 100644 --- a/Sources/DolbyIORTSCore/Manager/SubscriptionManager.swift +++ b/Sources/DolbyIORTSCore/Manager/SubscriptionManager.swift @@ -50,19 +50,13 @@ protocol SubscriptionManagerProtocol: AnyObject { func unprojectAudio(for source: StreamSource) } -struct SubscriptionConfiguration { - let autoReconnect = false - let videoJitterMinimumDelayMs: UInt = 20 - let statsDelayMs: UInt = 1000 - let forcePlayoutDelay = false - let disableAudio = false - let rtcEventLogOutputPath: String? = nil -} - final class SubscriptionManager: SubscriptionManagerProtocol { + private enum Defaults { - static let subscribeURL = "https://director.millicast.com/api/director/subscribe" + static let productionSubscribeURL = "https://director.millicast.com/api/director/subscribe" + static let developmentSubscribeURL = "https://director-dev.millicast.com/api/director/subscribe" } + private static let logger = Logger.make(category: String(describing: SubscriptionManager.self)) private var subscriber: MCSubscriber! @@ -99,7 +93,7 @@ final class SubscriptionManager: SubscriptionManagerProtocol { return false } - let credentials = self.makeCredentials(streamName: streamName, accountID: accountID) + let credentials = self.makeCredentials(streamName: streamName, accountID: accountID, useDevelopmentServer: configuration.useDevelopmentServer) self.subscriber.setCredentials(credentials) @@ -226,31 +220,29 @@ private extension SubscriptionManager { func makeSubscriber(with configuration: SubscriptionConfiguration) -> MCSubscriber? { let subscriber = MCSubscriber.create() - - subscriber?.enableStats(true) - + let options = MCClientOptions() options.autoReconnect = configuration.autoReconnect - options.videoJitterMinimumDelayMs = Int32(configuration.videoJitterMinimumDelayMs) + options.videoJitterMinimumDelayMs = Int32(configuration.videoJitterMinimumDelayInMs) options.statsDelayMs = Int32(configuration.statsDelayMs) - if let rtcEventLogOutputPath = configuration.rtcEventLogOutputPath { + if let rtcEventLogOutputPath = configuration.rtcEventLogPath { options.rtcEventLogOutputPath = rtcEventLogOutputPath } options.disableAudio = configuration.disableAudio - options.forcePlayoutDelay = configuration.forcePlayoutDelay + options.forcePlayoutDelay = configuration.noPlayoutDelay subscriber?.setOptions(options) - subscriber?.enableStats(true) + subscriber?.enableStats(configuration.enableStats) return subscriber } - func makeCredentials(streamName: String, accountID: String) -> MCSubscriberCredentials { + func makeCredentials(streamName: String, accountID: String, useDevelopmentServer: Bool) -> MCSubscriberCredentials { let credentials = MCSubscriberCredentials() credentials.accountId = accountID credentials.streamName = streamName credentials.token = "" - credentials.apiUrl = Defaults.subscribeURL + credentials.apiUrl = useDevelopmentServer ? Defaults.developmentSubscribeURL : Defaults.productionSubscribeURL return credentials } diff --git a/Sources/DolbyIORTSCore/Model/StreamDetail.swift b/Sources/DolbyIORTSCore/Model/StreamDetail.swift index f4c520e..7b4e9aa 100644 --- a/Sources/DolbyIORTSCore/Model/StreamDetail.swift +++ b/Sources/DolbyIORTSCore/Model/StreamDetail.swift @@ -8,7 +8,6 @@ public struct StreamDetail: Equatable, Identifiable { public let id = UUID() public let streamName: String public let accountID: String - public var streamId: String { "\(self.accountID)/\(self.streamName)" } public init(streamName: String, accountID: String) { self.streamName = streamName diff --git a/Sources/DolbyIORTSCore/Model/StreamState.swift b/Sources/DolbyIORTSCore/Model/StreamState.swift index e12d10e..b8c68c4 100644 --- a/Sources/DolbyIORTSCore/Model/StreamState.swift +++ b/Sources/DolbyIORTSCore/Model/StreamState.swift @@ -25,10 +25,15 @@ public enum StreamState: Equatable { self = .connected case let .subscribed(state): - self = .subscribed( - sources: state.sources, - numberOfStreamViewers: state.numberOfStreamViewers - ) + let streamSources = state.sources + if !streamSources.isEmpty { + self = .subscribed( + sources: streamSources, + numberOfStreamViewers: state.numberOfStreamViewers + ) + } else { + self = .error(.connectFailed(reason: "")) + } case .stopped: self = .stopped diff --git a/Sources/DolbyIORTSCore/Model/StreamingStatistics.swift b/Sources/DolbyIORTSCore/Model/StreamingStatistics.swift index 4d1a7f4..58b2511 100644 --- a/Sources/DolbyIORTSCore/Model/StreamingStatistics.swift +++ b/Sources/DolbyIORTSCore/Model/StreamingStatistics.swift @@ -5,23 +5,27 @@ import Foundation import MillicastSDK -public struct AllStreamingStatistics : Equatable, Hashable { +public struct StreamingStatistics : Equatable, Hashable { public let roundTripTime: Double? - public var videoStatsInboundRtpList: [StatsInboundRtp]? - public var audioStatsInboundRtpList: [StatsInboundRtp]? + public var videoStatsInboundRtp: StatsInboundRtp? + public var audioStatsInboundRtp: StatsInboundRtp? } -public struct StreamingStatistics : Equatable, Hashable { +public struct AllStreamStatistics : Equatable, Hashable { public let roundTripTime: Double? - public let audioStatsInboundRtp: StatsInboundRtp? - public let videoStatsInboundRtp: StatsInboundRtp? + public var videoStatsInboundRtpList: [StatsInboundRtp] + public var audioStatsInboundRtpList: [StatsInboundRtp] } public struct StatsInboundRtp : Equatable, Hashable { - public let sid: String public let kind: String + public let sid: String public let mid: String + public let decoderImplementation: String? + public let trackIdentifier: String public let decoder: String? + public let processingDelay: Double + public let decodeTime: Double public let frameWidth: Int public let frameHeight: Int public let fps: Int @@ -29,13 +33,18 @@ public struct StatsInboundRtp : Equatable, Hashable { public let totalEnergy: Double public let framesReceived: Int public let framesDecoded: Int + public let framesDropped: Int + public let jitterBufferEmittedCount: Int + public let jitterBufferDelay: Double + public let jitterBufferTargetDelay: Double + public let jitterBufferMinimumDelay: Double public let nackCount: Int public let bytesReceived: Int public let totalSampleDuration: Double public let codec: String? public let jitter: Double - public let packetsReceived: Double - public let packetsLost: Double + public let packetsReceived: Int + public let packetsLost: Int public let timestamp: Double public var codecName: String? @@ -44,7 +53,7 @@ public struct StatsInboundRtp : Equatable, Hashable { } } -extension AllStreamingStatistics { +extension AllStreamStatistics { init?(_ report: MCStatsReport) { let receivedType = MCRemoteInboundRtpStreamStats.get_type() guard let remoteInboundStreamStatsList = report.getStatsOf(receivedType) as? [MCRemoteInboundRtpStreamStats] else { @@ -60,50 +69,76 @@ extension AllStreamingStatistics { let codecType = MCCodecsStats.get_type() let codecStatsList = report.getStatsOf(codecType) as? [MCCodecsStats] + videoStatsInboundRtpList = [StatsInboundRtp]() let videos = inboundRtpStreamStatsList .filter { $0.kind == "video" } .map { StatsInboundRtp($0, codecStatsList: codecStatsList) } - videoStatsInboundRtpList = [StatsInboundRtp]() - audioStatsInboundRtpList = [StatsInboundRtp]() - videoStatsInboundRtpList?.append(contentsOf: videos) + videoStatsInboundRtpList.append(contentsOf: videos) + audioStatsInboundRtpList = [StatsInboundRtp]() let audios = inboundRtpStreamStatsList .filter { $0.kind == "audio" } .map { StatsInboundRtp($0, codecStatsList: codecStatsList) } - audioStatsInboundRtpList?.append(contentsOf: audios) + audioStatsInboundRtpList.append(contentsOf: audios) } } extension StatsInboundRtp { init(_ stats: MCInboundRtpStreamStats, codecStatsList: [MCCodecsStats]?) { - sid = stats.sid as String kind = stats.kind as String + sid = stats.sid as String mid = stats.mid as String - decoder = stats.decoder_implementation as String? + decoderImplementation = stats.decoder_implementation as String? frameWidth = Int(stats.frame_width) frameHeight = Int(stats.frame_height) fps = Int(stats.frames_per_second) - audioLevel = Int(stats.audio_level) - totalEnergy = stats.total_audio_energy + bytesReceived = Int(stats.bytes_received) framesReceived = Int(stats.frames_received) + packetsReceived = Int(stats.packets_received) framesDecoded = Int(stats.frames_decoded) + framesDropped = Int(stats.frames_dropped) + jitterBufferEmittedCount = Int(stats.jitter_buffer_emitted_count) + jitter = stats.jitter * 1000 + processingDelay = Self.msNormalised( + numerator: stats.total_processing_delay, + denominator: stats.frames_decoded + ) + decodeTime = Self.msNormalised( + numerator: stats.total_decode_time, + denominator: stats.frames_decoded + ) + jitterBufferDelay = Self.msNormalised( + numerator: stats.jitter_buffer_delay, + denominator: stats.jitter_buffer_emitted_count + ) + jitterBufferTargetDelay = Self.msNormalised( + numerator: stats.jitter_buffer_target_delay, + denominator: stats.jitter_buffer_emitted_count + ) + jitterBufferMinimumDelay = Self.msNormalised( + numerator: stats.jitter_buffer_minimum_delay, + denominator: stats.jitter_buffer_emitted_count + ) nackCount = Int(stats.nack_count) - bytesReceived = Int(stats.bytes_received) + packetsLost = Int(stats.packets_lost) + trackIdentifier = stats.track_identifier as String + decoder = stats.decoder_implementation as String? + audioLevel = Int(stats.audio_level) + totalEnergy = stats.total_audio_energy totalSampleDuration = stats.total_samples_duration codec = stats.codec_id as String? - jitter = stats.jitter - packetsReceived = Double(stats.packets_received) - packetsLost = Double(stats.packets_lost) timestamp = Double(stats.timestamp) if let codecStats = codecStatsList?.first(where: { $0.sid == stats.codec_id }) { codecName = codecStats.mime_type as String - } else { - codecName = nil } } + + private static func msNormalised(numerator: Double, denominator: UInt) -> Double { + denominator == 0 ? 0 : numerator * 1000 / Double(denominator) + } } diff --git a/Sources/DolbyIORTSCore/Model/SubscriptionConfiguration.swift b/Sources/DolbyIORTSCore/Model/SubscriptionConfiguration.swift new file mode 100644 index 0000000..cf20f2a --- /dev/null +++ b/Sources/DolbyIORTSCore/Model/SubscriptionConfiguration.swift @@ -0,0 +1,49 @@ +// +// SubscriptionConfiguration.swift +// + +import Foundation + +public struct SubscriptionConfiguration { + public enum Constants { + public static let useDevelopmentServer = false + public static let autoReconnect = true + public static let videoJitterMinimumDelayInMs: UInt = 20 + public static let statsDelayMs: UInt = 1000 + public static let noPlayoutDelay = false + public static let disableAudio = false + public static let enableStats = true + } + + public let useDevelopmentServer: Bool + public let autoReconnect: Bool + public let videoJitterMinimumDelayInMs: UInt + public let statsDelayMs: UInt + public let noPlayoutDelay: Bool + public let disableAudio: Bool + public let rtcEventLogPath: String? + public let sdkLogPath: String? + public let enableStats: Bool + + public init( + useDevelopmentServer: Bool = Constants.useDevelopmentServer, + autoReconnect: Bool = Constants.autoReconnect, + videoJitterMinimumDelayInMs: UInt = Constants.videoJitterMinimumDelayInMs, + statsDelayMs: UInt = Constants.statsDelayMs, + noPlayoutDelay: Bool = Constants.noPlayoutDelay, + disableAudio: Bool = Constants.disableAudio, + rtcEventLogPath: String? = nil, + sdkLogPath: String? = nil, + enableStats: Bool = Constants.enableStats + ) { + self.useDevelopmentServer = useDevelopmentServer + self.autoReconnect = autoReconnect + self.videoJitterMinimumDelayInMs = videoJitterMinimumDelayInMs + self.statsDelayMs = statsDelayMs + self.noPlayoutDelay = noPlayoutDelay + self.disableAudio = disableAudio + self.rtcEventLogPath = rtcEventLogPath + self.sdkLogPath = sdkLogPath + self.enableStats = enableStats + } +} diff --git a/Sources/DolbyIORTSCore/Model/VideoQuality.swift b/Sources/DolbyIORTSCore/Model/VideoQuality.swift index 8496123..bc4acb1 100644 --- a/Sources/DolbyIORTSCore/Model/VideoQuality.swift +++ b/Sources/DolbyIORTSCore/Model/VideoQuality.swift @@ -4,7 +4,8 @@ import Foundation -public enum VideoQuality: Equatable { +public enum VideoQuality: String, Equatable, CaseIterable, Identifiable { + case auto case high case medium @@ -35,4 +36,8 @@ public enum VideoQuality: Equatable { return "Low" } } + + public var id: String { + return rawValue + } } diff --git a/Sources/DolbyIORTSCore/State/State.swift b/Sources/DolbyIORTSCore/State/State.swift index 2276bf3..7a30897 100644 --- a/Sources/DolbyIORTSCore/State/State.swift +++ b/Sources/DolbyIORTSCore/State/State.swift @@ -48,7 +48,7 @@ struct SubscribedState { private(set) var streamSourceBuilders: [StreamSourceBuilder] private(set) var numberOfStreamViewers: Int - private(set) var streamingStats: AllStreamingStatistics? + private(set) var streamingStats: AllStreamStatistics? private(set) var cachedSourceZeroVideoTrackAndMid: VideoTrackAndMid? private(set) var cachedSourceZeroAudioTrackAndMid: AudioTrackAndMid? @@ -131,15 +131,19 @@ struct SubscribedState { numberOfStreamViewers = count } - mutating func updateStreamingStatistics(_ stats: AllStreamingStatistics) { + mutating func updateStreamingStatistics(_ stats: AllStreamStatistics) { streamingStats = stats - stats.videoStatsInboundRtpList?.forEach { eachVideoStats in + stats.videoStatsInboundRtpList.forEach { videoStats in guard let builder = streamSourceBuilders.first( - where: { $0.videoTrack?.trackInfo.mid == eachVideoStats.mid } + where: { $0.videoTrack?.trackInfo.mid == videoStats.mid } ) else { return } - let sourceStatistics = StreamingStatistics(roundTripTime: stats.roundTripTime, audioStatsInboundRtp: stats.audioStatsInboundRtpList?.first, videoStatsInboundRtp: eachVideoStats) + let sourceStatistics = StreamingStatistics( + roundTripTime: stats.roundTripTime, + videoStatsInboundRtp: videoStats, + audioStatsInboundRtp: stats.audioStatsInboundRtpList.first + ) builder.setStatistics(sourceStatistics) } } diff --git a/Sources/DolbyIORTSCore/State/StateMachine.swift b/Sources/DolbyIORTSCore/State/StateMachine.swift index 3ad56ad..0fae1fa 100644 --- a/Sources/DolbyIORTSCore/State/StateMachine.swift +++ b/Sources/DolbyIORTSCore/State/StateMachine.swift @@ -71,6 +71,9 @@ final class StateMachine { } func onSubscribed() { + if case .subscribed = currentState { + return + } currentState = .subscribed( .init( cachedVideoTrackDetail: cachedSourceZeroVideoTrackAndMid, @@ -79,7 +82,6 @@ final class StateMachine { ) cachedSourceZeroAudioTrackAndMid = nil cachedSourceZeroAudioTrackAndMid = nil - } func onSubscribedError(_ reason: String) { @@ -91,10 +93,17 @@ final class StateMachine { } func onActive(_ streamId: String, tracks: [String], sourceId: String?) { + // This is a workaround for an SDK behaviour where the some `onActive` callbacks arrive even before the `onSubscribed` + // In this case it's safe to assume a state change to `.subscribed` provided the current state is `.subscribing` + if case .subscribing = currentState { + // Mimic an `onSubscribed` callback + onSubscribed() + } + switch currentState { case var .subscribed(state): state.add(streamId: streamId, sourceId: sourceId, tracks: tracks) - currentState = .subscribed(state) + self.currentState = .subscribed(state) default: Self.logger.error("🛑 Unexpected state on onActive - \(self.currentState.description)") } @@ -104,15 +113,7 @@ final class StateMachine { switch currentState { case var .subscribed(state): state.remove(streamId: streamId, sourceId: sourceId) - - // FIXME: Currently SDK does not have a callback for Publisher stopping the publishing - // What we get instead is `onInactive` callbacks for all the video sources - ie, `onInactive` is called `n` times if we have `n` sources - // This workaround checks for active `source` count to decide the expected `state` transition - if state.sources.isEmpty { - currentState = .stopped - } else { - currentState = .subscribed(state) - } + currentState = .subscribed(state) default: Self.logger.error("🛑 Unexpected state on onInactive - \(self.currentState.description)") } @@ -185,7 +186,7 @@ final class StateMachine { } } - func onStatsReport(_ streamingStats: AllStreamingStatistics) { + func onStatsReport(_ streamingStats: AllStreamStatistics) { switch currentState { case var .subscribed(state): state.updateStreamingStatistics(streamingStats) diff --git a/Sources/DolbyIORTSCore/StreamOrchestrator.swift b/Sources/DolbyIORTSCore/StreamOrchestrator.swift index 1477b4f..8438181 100644 --- a/Sources/DolbyIORTSCore/StreamOrchestrator.swift +++ b/Sources/DolbyIORTSCore/StreamOrchestrator.swift @@ -9,10 +9,7 @@ import os @globalActor public final actor StreamOrchestrator { - public struct Configuration { - let retryOnConnectionError = true - } - + private static let logger = Logger.make(category: String(describing: StreamOrchestrator.self)) private enum Defaults { @@ -24,7 +21,6 @@ public final actor StreamOrchestrator { private let stateMachine: StateMachine = StateMachine(initialState: .disconnected) private let subscriptionManager: SubscriptionManagerProtocol private let rendererRegistry: RendererRegistryProtocol - private let taskScheduler: TaskSchedulerProtocol private var subscriptions: Set = [] private lazy var stateSubject: CurrentValueSubject = CurrentValueSubject(.disconnected) @@ -32,28 +28,20 @@ public final actor StreamOrchestrator { .removeDuplicates() .eraseToAnyPublisher() private var activeStreamDetail: StreamDetail? - private static var configuration: StreamOrchestrator.Configuration = .init() private let logHandler: MillicastLoggerHandler = .init() private init() { self.init( subscriptionManager: SubscriptionManager(), - taskScheduler: TaskScheduler(), rendererRegistry: RendererRegistry() ) } - - static func setStreamOrchestratorConfiguration(_ configuration: StreamOrchestrator.Configuration) { - Self.configuration = configuration - } init( subscriptionManager: SubscriptionManagerProtocol, - taskScheduler: TaskSchedulerProtocol, rendererRegistry: RendererRegistryProtocol ) { self.subscriptionManager = subscriptionManager - self.taskScheduler = taskScheduler self.rendererRegistry = rendererRegistry self.subscriptionManager.delegate = self @@ -66,11 +54,12 @@ public final actor StreamOrchestrator { } } - public func connect(streamName: String, accountID: String) async -> Bool { + public func connect(streamName: String, accountID: String, configuration: SubscriptionConfiguration = .init()) async -> Bool { Self.logger.debug("👮‍♂️ Start subscribe") - + logHandler.setLogFilePath(filePath: configuration.sdkLogPath) + async let startConnectionStateUpdate: Void = stateMachine.startConnection(streamName: streamName, accountID: accountID) - async let startConnection = subscriptionManager.connect(streamName: streamName, accountID: accountID, configuration: .init()) + async let startConnection = subscriptionManager.connect(streamName: streamName, accountID: accountID, configuration: configuration) let (_, connectionResult) = await (startConnectionStateUpdate, startConnection) if connectionResult { @@ -83,7 +72,8 @@ public final actor StreamOrchestrator { public func stopConnection() async -> Bool { Self.logger.debug("👮‍♂️ Stop subscribe") - activeStreamDetail = nil + reset() + async let stopSubscribeOnStateMachine: Void = stateMachine.stopSubscribe() async let resetRegistry: Void = rendererRegistry.reset() async let stopSubscription: Bool = await subscriptionManager.stopSubscribe() @@ -212,23 +202,6 @@ private extension StreamOrchestrator { .sink { state in Task { [weak self] in guard let self = self else { return } - switch state { - case let .error(errorState): - switch errorState.error { - case .connectFailed: - await self.scheduleReconnection() - default: - //No-op - break - } - case .stopped: - await self.scheduleReconnection() - - default: - // No-op - break - } - // Populate updates public facing states await self.stateSubject.send(StreamState(state: state)) } @@ -236,21 +209,9 @@ private extension StreamOrchestrator { .store(in: &subscriptions) } - func scheduleReconnection() { - guard Self.configuration.retryOnConnectionError, let streamDetail = self.activeStreamDetail else { - return - } - Self.logger.debug("👮‍♂️ Scheduling a reconnect") - taskScheduler.scheduleTask(timeInterval: Defaults.retryConnectionTimeInterval) { [weak self] in - guard let self = self else { return } - Task { - self.taskScheduler.invalidate() - _ = await self.connect( - streamName: streamDetail.streamName, - accountID: streamDetail.accountID - ) - } - } + func reconnectToStream(streamDetail: StreamDetail) async { + Self.logger.debug("👮‍♂️ Attempting a reconnect") + _ = await connect(streamName: streamDetail.streamName, accountID: streamDetail.accountID) } func startSubscribe() async -> Bool { @@ -267,6 +228,11 @@ private extension StreamOrchestrator { default: break } } + + func reset() { + activeStreamDetail = nil + logHandler.setLogFilePath(filePath: nil) + } } // MARK: SubscriptionManagerDelegate implementation @@ -338,7 +304,7 @@ extension StreamOrchestrator: SubscriptionManagerDelegate { } nonisolated public func onStatsReport(_ report: MCStatsReport) { - guard let streamingStats = AllStreamingStatistics(report) else { + guard let streamingStats = AllStreamStatistics(report) else { return } Task { @StreamOrchestrator [weak self] in diff --git a/Sources/DolbyIORTSUIKit/Private/ViewModels/StreamViewModel.swift b/Sources/DolbyIORTSUIKit/Private/ViewModels/StreamViewModel.swift index cefc0a7..ae0eaa9 100644 --- a/Sources/DolbyIORTSUIKit/Private/ViewModels/StreamViewModel.swift +++ b/Sources/DolbyIORTSUIKit/Private/ViewModels/StreamViewModel.swift @@ -238,23 +238,24 @@ final class StreamViewModel: ObservableObject { } func playAudio(for source: StreamSource) { - Task { + Task { @StreamOrchestrator [weak self] in + guard let self = self else { return } await self.streamOrchestrator.playAudio(for: source) } } func stopAudio(for source: StreamSource) { - Task { + Task { @StreamOrchestrator [weak self] in + guard let self = self else { return } await self.streamOrchestrator.stopAudio(for: source) } } private func startObservers() { let settingsPublisher = settingsManager.publisher(for: settingsMode) - - Task { [weak self] in + Task { @StreamOrchestrator [weak self] in guard let self = self else { return } - await streamOrchestrator.statePublisher + await self.streamOrchestrator.statePublisher .combineLatest(settingsPublisher) .receive(on: DispatchQueue.main) .sink { state, settings in diff --git a/Sources/DolbyIORTSUIKit/Private/Views/Settings/SettingsButton.swift b/Sources/DolbyIORTSUIKit/Private/Views/Settings/SettingsButton.swift index 1b138d9..4eec3c5 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/Settings/SettingsButton.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/Settings/SettingsButton.swift @@ -5,18 +5,17 @@ import DolbyIOUIKit import SwiftUI -struct SettingsButton: View { +public struct SettingsButton: View { private let onAction: () -> Void - init(onAction: @escaping (() -> Void) = {}) { + public init(onAction: @escaping (() -> Void) = {}) { self.onAction = onAction } - var body: some View { + public var body: some View { IconButton(iconAsset: .settings, action: { onAction() }) - .scaleEffect(0.5, anchor: .trailing) } } diff --git a/Sources/DolbyIORTSUIKit/Private/Views/StatsInfo/StatisticsInfoView.swift b/Sources/DolbyIORTSUIKit/Private/Views/StatsInfo/StatisticsInfoView.swift index 3695c8d..e4b36b1 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/StatsInfo/StatisticsInfoView.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/StatsInfo/StatisticsInfoView.swift @@ -24,34 +24,70 @@ struct StatisticsInfoView: View { private var theme: Theme { themeManager.theme } + + private var pullDownIndicatorView: some View { + RoundedRectangle(cornerRadius: Layout.cornerRadius4x) + .fill(Color.gray) + .frame(width: Layout.spacing6x, height: Layout.spacing1x) + .padding([.top], Layout.spacing0_5x) + } + + private var titleView: some View { + Text("stream.media-stats.label", bundle: .module, font: fontTitle) + .frame(maxWidth: .infinity, alignment: .center) + .padding([.top, .bottom], Layout.spacing3x) + } var body: some View { ScrollView { VStack { - RoundedRectangle(cornerRadius: 3) - .fill(Color.gray) - .frame(width: 48, height: 5) - .padding([.top], 5) - Text("stream.media-stats.label", font: fontTitle) - .frame(maxWidth: .infinity, alignment: .center) - .padding([.top], 20) - .padding([.bottom], 25) + pullDownIndicatorView + + titleView HStack { - Text("stream.stats.name.label", font: fontCaption).frame(width: 170, alignment: .leading) - Text("stream.stats.value.label", font: fontCaption).frame(width: 170, alignment: .leading) + Text("stream.stats.name.label", bundle: .module, font: fontCaption) + .frame(minWidth: Layout.spacing0x, maxWidth: .infinity, alignment: .leading) + + Text("stream.stats.value.label", bundle: .module, font: fontCaption) + .frame(minWidth: Layout.spacing0x, maxWidth: .infinity, alignment: .leading) } ForEach(viewModel.data) { item in HStack { - Text(item.key).font(fontTable).foregroundColor(Color(theme.neutral200)).frame(width: 170, alignment: .leading) - Text(item.value).font(fontTableValue).foregroundColor(Color(theme.onBackground)).frame(width: 170, alignment: .leading) + Text(verbatim: item.key, font: fontTable) + .foregroundColor(Color(theme.neutral200)) + .frame(minWidth: Layout.spacing0x, maxWidth: .infinity, alignment: .leading) + + Text(verbatim: item.value, font: fontTableValue) + .foregroundColor(Color(theme.onBackground)) + .frame(minWidth: Layout.spacing0x, maxWidth: .infinity, alignment: .leading) } - .padding([.top], 5) + .padding([.top], Layout.spacing0_5x) } } - .padding([.leading, .trailing], 15) - .padding([.bottom], 10) + .padding([.leading, .trailing], Layout.spacing2x) + .padding(.bottom, Layout.spacing3x) } + .contextMenu { + Button(action: { + copyToPasteboard(text: formattedStatisticsText()) + }) { + Text("stream.stats.copy.label", bundle: .module) + Image(systemName: "doc.on.doc") + } + } + } + + private func formattedStatisticsText() -> String { + var text = "" + viewModel.data.forEach { item in + text += "\(item.key): \(item.value)\n" + } + return text + } + + private func copyToPasteboard(text: String) { + UIPasteboard.general.string = text } } diff --git a/Sources/DolbyIORTSUIKit/Private/Views/StatsInfo/StatsInfoViewModel.swift b/Sources/DolbyIORTSUIKit/Private/Views/StatsInfo/StatsInfoViewModel.swift index 256955c..45bb5f3 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/StatsInfo/StatsInfoViewModel.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/StatsInfo/StatsInfoViewModel.swift @@ -8,7 +8,7 @@ import Foundation final class StatsInfoViewModel: ObservableObject { private let streamSource: StreamSource - + init(streamSource: StreamSource) { self.streamSource = streamSource } @@ -18,43 +18,181 @@ final class StatsInfoViewModel: ObservableObject { var key: String var value: String } - + var data: [StatData] { guard let stats = streamSource.streamingStatistics else { return [] } - + var result = [StatData]() - - if let rtt = stats.roundTripTime { - result.append(StatData(key: String(localized: "stream.stats.rtt.label"), value: String(rtt))) + + if let mid = stats.videoStatsInboundRtp?.mid { + result.append( + StatData( + key: String(localized: "stream.stats.mid.label", bundle: .module), + value: String(mid) + ) + ) + } + if let decoderImplementation = stats.videoStatsInboundRtp?.decoderImplementation { + result.append( + StatData( + key: String(localized: "stream.stats.decoder-impl.label", bundle: .module), + value: String(decoderImplementation) + ) + ) + } + if let processingDelay = stats.videoStatsInboundRtp?.processingDelay { + result.append( + StatData( + key: String(localized: "stream.stats.processing-delay.label", bundle: .module), + value: String(format:"%.2f ms", processingDelay) + ) + ) + } + if let decodeTime = stats.videoStatsInboundRtp?.decodeTime { + result.append( + StatData( + key: String(localized: "stream.stats.decode-time.label", bundle: .module), + value: String(format:"%.2f ms", decodeTime) + ) + ) } if let videoResolution = stats.videoStatsInboundRtp?.videoResolution { - result.append(StatData(key: String(localized: "stream.stats.video-resolution.label"), value: videoResolution)) + result.append( + StatData( + key: String(localized: "stream.stats.video-resolution.label", bundle: .module), + value: videoResolution + ) + ) } if let fps = stats.videoStatsInboundRtp?.fps { - result.append(StatData(key: String(localized: "stream.stats.fps.label"), value: String(fps))) + result.append( + StatData( + key: String(localized: "stream.stats.fps.label", bundle: .module), + value: String(fps) + ) + ) + } + if let videoBytesReceived = stats.videoStatsInboundRtp?.bytesReceived { + result.append( + StatData( + key: String(localized: "stream.stats.video-total-received.label", bundle: .module), + value: formatBytes(bytes: videoBytesReceived) + ) + ) } if let audioBytesReceived = stats.audioStatsInboundRtp?.bytesReceived { - result.append(StatData(key: String(localized: "stream.stats.audio-total-received.label"), value: formatBytes(bytes: audioBytesReceived))) + result.append( + StatData( + key: String(localized: "stream.stats.audio-total-received.label", bundle: .module), + value: formatBytes(bytes: audioBytesReceived) + ) + ) } - if let videoBytesReceived = stats.videoStatsInboundRtp?.bytesReceived { - result.append(StatData(key: String(localized: "stream.stats.video-total-received.label"), value: formatBytes(bytes: videoBytesReceived))) + if let packetsReceived = stats.videoStatsInboundRtp?.packetsReceived { + result.append( + StatData( + key: String(localized: "stream.stats.packets-received.label", bundle: .module), + value: String(packetsReceived) + ) + ) } - if let audioPacketsLost = stats.audioStatsInboundRtp?.packetsLost { - result.append(StatData(key: String(localized: "stream.stats.audio-packet-loss.label"), value: String(audioPacketsLost))) + if let framesDecoded = stats.videoStatsInboundRtp?.framesDecoded { + result.append( + StatData( + key: String(localized: "stream.stats.frames-decoded.label", bundle: .module), + value: String(framesDecoded) + ) + ) } - if let videoPacketsLost = stats.videoStatsInboundRtp?.packetsLost { - result.append(StatData(key: String(localized: "stream.stats.video-packet-loss.label"), value: String(videoPacketsLost))) + if let framesDropped = stats.videoStatsInboundRtp?.framesDropped { + result.append( + StatData( + key: String(localized: "stream.stats.frames-dropped.label", bundle: .module), + value: String(framesDropped) + ) + ) } - if let audioJitter = stats.audioStatsInboundRtp?.jitter { - result.append(StatData(key: String(localized: "stream.stats.audio-jitter.label"), value: "\(Int(audioJitter * 1000)) ms")) + if let jitterBufferEmittedCount = stats.videoStatsInboundRtp?.jitterBufferEmittedCount { + result.append( + StatData( + key: String(localized: "stream.stats.jitter-buffer-est-count.label", bundle: .module), + value: String(jitterBufferEmittedCount) + ) + ) } if let videoJitter = stats.videoStatsInboundRtp?.jitter { - result.append(StatData(key: String(localized: "stream.stats.video-jitter.label"), value: "\(Int(videoJitter * 1000)) ms")) + result.append( + StatData( + key: String(localized: "stream.stats.video-jitter.label", bundle: .module), + value: "\(videoJitter) ms" + ) + ) + } + if let audioJitter = stats.audioStatsInboundRtp?.jitter { + result.append( + StatData( + key: String(localized: "stream.stats.audio-jitter.label", bundle: .module), + value: "\(Int(audioJitter * 1000)) ms" + ) + ) + } + if let jitterBufferDelay = stats.videoStatsInboundRtp?.jitterBufferDelay { + result.append( + StatData( + key: String(localized: "stream.stats.jitter-buffer-delay.label", bundle: .module), + value: String(format:"%.2f ms", jitterBufferDelay) + ) + ) + } + if let jitterBufferTargetDelay = stats.videoStatsInboundRtp?.jitterBufferTargetDelay { + result.append( + StatData( + key: String(localized: "stream.stats.jitter-buffer-target-delay.label", bundle: .module), + value: String(format:"%.2f ms", jitterBufferTargetDelay) + ) + ) + } + if let jitterBufferMinimumDelay = stats.videoStatsInboundRtp?.jitterBufferMinimumDelay { + result.append( + StatData( + key: String(localized: "stream.stats.jitter-buffer-minimum-delay.label", bundle: .module), + value: String(format:"%.2f ms", jitterBufferMinimumDelay) + ) + ) + } + if let videoPacketsLost = stats.videoStatsInboundRtp?.packetsLost { + result.append( + StatData( + key: String(localized: "stream.stats.video-packet-loss.label", bundle: .module), + value: String(videoPacketsLost) + ) + ) + } + if let audioPacketsLost = stats.audioStatsInboundRtp?.packetsLost { + result.append( + StatData( + key: String(localized: "stream.stats.audio-packet-loss.label", bundle: .module), + value: String(audioPacketsLost) + ) + ) + } + if let rtt = stats.roundTripTime { + result.append( + StatData( + key: String(localized: "stream.stats.rtt.label", bundle: .module), + value: String(rtt) + ) + ) } if let timestamp = stats.audioStatsInboundRtp?.timestamp { - result.append(StatData(key: String(localized: "stream.stats.timestamp.label"), value: dateStr(timestamp / 1000))) + result.append( + StatData( + key: String(localized: "stream.stats.timestamp.label", bundle: .module), + value: dateStr(timestamp / 1000) + ) + ) } let audioCodec = stats.audioStatsInboundRtp?.codecName let videoCodec = stats.videoStatsInboundRtp?.codecName @@ -64,30 +202,35 @@ final class StatsInfoViewModel: ObservableObject { delimiter = "" } let codecs = "\(audioCodec ?? "")\(delimiter)\(videoCodec ?? "")" - result.append(StatData(key: String(localized: "stream.stats.codecs.label"), value: codecs)) + result.append( + StatData( + key: String(localized: "stream.stats.codecs.label", bundle: .module), + value: codecs + ) + ) } return result } - + private func dateStr(_ timestamp: Double) -> String { let dateFormatter = DateFormatter() dateFormatter.timeZone = TimeZone(abbreviation: "GMT") dateFormatter.locale = NSLocale.current dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS'Z'" - + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) return dateFormatter.string(from: date) } - + private func formatBytes(bytes: Int) -> String { return "\(formatNumber(input: bytes))B" } - + private func formatBitRate(bitRate: Int) -> String { let value = formatNumber(input: bitRate).lowercased() return "\(value)bps" } - + private func formatNumber(input: Int) -> String { if input < KILOBYTES { return String(input) } if input >= KILOBYTES && input < MEGABYTES { return "\(input / KILOBYTES) K"} else { return "\(input / MEGABYTES) M" } diff --git a/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererView.swift b/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererView.swift index 2651d0b..5d8b725 100644 --- a/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererView.swift +++ b/Sources/DolbyIORTSUIKit/Private/Views/VideoRenderer/VideoRendererView.swift @@ -16,6 +16,7 @@ struct VideoRendererView: View { @State var isViewVisible = false @ObservedObject private var themeManager = ThemeManager.shared + @AppConfiguration(\.showDebugFeatures) var showDebugFeatures init( viewModel: VideoRendererViewModel, @@ -51,10 +52,27 @@ struct VideoRendererView: View { } @ViewBuilder - private func showLabel(for source: StreamSource) -> some View { + private var sourceLabelView: some View { if viewModel.showSourceLabel { - SourceLabel(sourceId: source.sourceId.displayLabel) - .padding(5) + SourceLabel(sourceId: viewModel.streamSource.sourceId.displayLabel) + .padding(Layout.spacing0_5x) + } else { + EmptyView() + } + } + + @ViewBuilder + private var videoQualityIndicatorView: some View { + if showDebugFeatures, let videoQualityIndicatorText = viewModel.videoQuality.description.first?.uppercased() { + Text( + verbatim: videoQualityIndicatorText, + font: .custom("AvenirNext-Regular", size: FontSize.caption1, relativeTo: .caption) + ) + .foregroundColor(Color(uiColor: themeManager.theme.onPrimary)) + .padding(.horizontal, Layout.spacing1x) + .background(Color(uiColor: themeManager.theme.neutral400)) + .cornerRadius(Layout.cornerRadius4x) + .padding(Layout.spacing0_5x) } else { EmptyView() } @@ -83,7 +101,10 @@ struct VideoRendererView: View { VideoRendererViewInteral(viewRenderer: viewRenderer) .frame(width: videoSize.width, height: videoSize.height) .overlay(alignment: .bottomLeading) { - showLabel(for: viewModel.streamSource) + sourceLabelView + } + .overlay(alignment: .bottomTrailing) { + videoQualityIndicatorView } .overlay { audioPlaybackIndicatorView diff --git a/Sources/DolbyIORTSUIKit/Public/Configurations/AppConfigurations.swift b/Sources/DolbyIORTSUIKit/Public/Configurations/AppConfigurations.swift new file mode 100644 index 0000000..5822df6 --- /dev/null +++ b/Sources/DolbyIORTSUIKit/Public/Configurations/AppConfigurations.swift @@ -0,0 +1,104 @@ +// +// AppConfigurations.swift +// + +import Combine +import Foundation +import SwiftUI + +public final class AppConfigurations { + + public static let standard = AppConfigurations(userDefaults: .standard) + fileprivate let userDefaults: UserDefaults + + fileprivate let _appConfigurationsChangedSubject = PassthroughSubject() + fileprivate lazy var appConfigurationsChangedSubject = { + _appConfigurationsChangedSubject.eraseToAnyPublisher() + }() + + init(userDefaults: UserDefaults) { + self.userDefaults = userDefaults + } + + @UserDefault("show_debug_features") + public var showDebugFeatures: Bool = false +} + +@propertyWrapper +public struct UserDefault { + let key: String + let defaultValue: Value + + public var wrappedValue: Value { + get { fatalError("Wrapped value should not be used.") } + set { fatalError("Wrapped value should not be used.") } + } + + init(wrappedValue: Value, _ key: String) { + self.defaultValue = wrappedValue + self.key = key + } + + public static subscript( + _enclosingInstance instance: AppConfigurations, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> Value { + get { + let container = instance.userDefaults + let key = instance[keyPath: storageKeyPath].key + let defaultValue = instance[keyPath: storageKeyPath].defaultValue + return container.object(forKey: key) as? Value ?? defaultValue + } + set { + let container = instance.userDefaults + let key = instance[keyPath: storageKeyPath].key + container.set(newValue, forKey: key) + instance._appConfigurationsChangedSubject.send(wrappedKeyPath) + } + } +} + +@propertyWrapper +public struct AppConfiguration: DynamicProperty { + + @ObservedObject private var appConfigurationsObserver: PublisherObservableObject + private let keyPath: ReferenceWritableKeyPath + private let appConfigurations: AppConfigurations + + public init(_ keyPath: ReferenceWritableKeyPath, appConfigurations: AppConfigurations = .standard) { + self.keyPath = keyPath + self.appConfigurations = appConfigurations + let publisher = appConfigurations + .appConfigurationsChangedSubject + .filter { changedKeyPath in + changedKeyPath == keyPath + }.map { _ in () } + .eraseToAnyPublisher() + self.appConfigurationsObserver = .init(publisher: publisher) + } + + public var wrappedValue: Value { + get { appConfigurations[keyPath: keyPath] } + nonmutating set { appConfigurations[keyPath: keyPath] = newValue } + } + + public var projectedValue: Binding { + Binding( + get: { wrappedValue }, + set: { wrappedValue = $0 } + ) + } +} + +final class PublisherObservableObject: ObservableObject { + + var subscriber: AnyCancellable? + + init(publisher: AnyPublisher) { + subscriber = publisher.sink(receiveValue: { [weak self] _ in + self?.objectWillChange.send() + }) + } +} + diff --git a/Sources/DolbyIORTSUIKit/Public/Screens/Settings/SettingsScreen.swift b/Sources/DolbyIORTSUIKit/Public/Screens/Settings/SettingsScreen.swift index fc4a0ba..341f83e 100644 --- a/Sources/DolbyIORTSUIKit/Public/Screens/Settings/SettingsScreen.swift +++ b/Sources/DolbyIORTSUIKit/Public/Screens/Settings/SettingsScreen.swift @@ -5,7 +5,7 @@ import SwiftUI import DolbyIOUIKit -public struct SettingsScreen: View { +public struct SettingsScreen: View { @Environment(\.presentationMode) var presentationMode @StateObject private var viewModel: SettingsViewModel @@ -14,8 +14,17 @@ public struct SettingsScreen: View { @State private var isShowingStreamSortOrderScreen: Bool = false @State private var isShowingAudioSelectionScreen: Bool = false - public init(mode: SettingsMode) { + @ViewBuilder private let moreSettings: Content + + public init(mode: SettingsMode, @ViewBuilder moreSettings: () -> Content) { _viewModel = StateObject(wrappedValue: SettingsViewModel(mode: mode)) + self.moreSettings = moreSettings() + } + + public init(mode: SettingsMode) where Content == EmptyView { + self.init(mode: mode) { + EmptyView() + } } public var body: some View { @@ -42,6 +51,8 @@ public struct SettingsScreen: View { .hidden() List { + moreSettings + Toggle(isOn: Binding( get: { viewModel.showSourceLabels }, set: { viewModel.setShowSourceLabels($0) }) @@ -50,7 +61,7 @@ public struct SettingsScreen: View { "settings.show-source-labels.label", bundle: .module, style: .titleMedium, - font: .custom("AvenirNext-Regular", size: CGFloat(14.0), relativeTo: .body) + font: .custom("AvenirNext-Regular", size: FontSize.body) ) } @@ -79,6 +90,7 @@ public struct SettingsScreen: View { action: { isShowingAudioSelectionScreen = true } ) } + .environment(\.defaultMinListRowHeight, Layout.spacing6x) .navigationBarBackButtonHidden() .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Sources/DolbyIORTSUIKit/Resources/en.lproj/Localizable.strings b/Sources/DolbyIORTSUIKit/Resources/en.lproj/Localizable.strings index f977111..8ade778 100644 --- a/Sources/DolbyIORTSUIKit/Resources/en.lproj/Localizable.strings +++ b/Sources/DolbyIORTSUIKit/Resources/en.lproj/Localizable.strings @@ -63,3 +63,36 @@ Follow video: Switch audio source when a video is selected. If the selected vide "network.disconnected.title.label" = "Disconnected from the network"; "technical-error.title.label" = "We are currently facing some technical difficulties. Please try again later."; + +/** Streaming Statistics */ + +"stream.media-stats.label" = "Streaming statistics"; +"stream.stats.copy.label" = "Copy Stream statistics"; +"stream.stats.name.label" = "Name"; +"stream.stats.value.label" = "Value"; +"stream.stats.mid.label" = "MID"; +"stream.stats.rtt.label" = "RTT"; +"stream.stats.video-resolution.label" = "Video Resolution"; +"stream.stats.fps.label" = "FPS"; +"stream.stats.video-bitrate.label" = "Video Bitrate"; +"stream.stats.audio-bitrate.label" = "Audio Bitrate"; +"stream.stats.video-total-received.label" = "Video Total Received"; +"stream.stats.audio-total-received.label" = "Audio Total Received"; +"stream.stats.video-packet-loss.label" = "Video Packet Loss"; +"stream.stats.audio-packet-loss.label" = "Audio Packet Loss"; +"stream.stats.video-jitter.label" = "Video Jitter"; +"stream.stats.audio-jitter.label" = "Audio Jitter"; +"stream.stats.codecs.label" = "Codecs"; +"stream.stats.timestamp.label" = "Timestamp"; +"stream.stats.server.label" = "Server"; +"stream.stats.cluster.label" = "Cluster"; +"stream.stats.decoder-impl.label" = "Decoder Impl"; +"stream.stats.processing-delay.label" = "Processing delay"; +"stream.stats.decode-time.label" = "Decode time"; +"stream.stats.packets-received.label" = "Packets received"; +"stream.stats.frames-decoded.label" = "Frames decoded"; +"stream.stats.frames-dropped.label" = "Frames dropped"; +"stream.stats.jitter-buffer-est-count.label" = "Jitter buffer Est count"; +"stream.stats.jitter-buffer-delay.label" = "Jitter buffer delay"; +"stream.stats.jitter-buffer-target-delay.label" = "Jitter buffer target delay"; +"stream.stats.jitter-buffer-minimum-delay.label" = "Jitter buffer min delay"; diff --git a/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/settings.imageset/settings.pdf b/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/settings.imageset/settings.pdf index 301b342..a7260e6 100644 Binary files a/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/settings.imageset/settings.pdf and b/Sources/DolbyIOUIKit/Resources/Media.xcassets/Buttons/settings.imageset/settings.pdf differ diff --git a/Sources/DolbyIOUIKit/Resources/fr.lproj/Localizable.strings b/Sources/DolbyIOUIKit/Resources/fr.lproj/Localizable.strings deleted file mode 100644 index a4e87a7..0000000 --- a/Sources/DolbyIOUIKit/Resources/fr.lproj/Localizable.strings +++ /dev/null @@ -1,4 +0,0 @@ -"testA.localized.key" = "testA.fr"; -"testB.localized.key" = "testB.fr"; -"testC.localized.key" = "testC.fr"; -"testD.localized.key" = "testD.fr"; diff --git a/Sources/DolbyIOUIKit/Views/Components/SelectionsGroup.swift b/Sources/DolbyIOUIKit/Views/Components/SelectionsGroup.swift index 2a267c3..6050acf 100644 --- a/Sources/DolbyIOUIKit/Views/Components/SelectionsGroup.swift +++ b/Sources/DolbyIOUIKit/Views/Components/SelectionsGroup.swift @@ -84,12 +84,15 @@ struct SelectionsGroup_Previews: PreviewProvider { static var previews: some View { VStack { - SelectionsGroup(settings: [ - .init(key: "testA.localized.key", bundle: .module, selected: true), - .init(key: "testB.localized.key", bundle: .module, selected: false), - .init(key: "testC.localized.key", bundle: .module, selected: false) - ], footer: "testD.localized.key", bundle: .module) { index in - print("index: \(index)") + SelectionsGroup( + settings: [ + .init(key: "Key 1", selected: true), + .init(key: "Key 2", selected: false), + .init(key: "Key 3", selected: false) + ], + footer: "testD.localized.key" + ) { _ in + // No-op } } } diff --git a/Sources/DolbyIOUIKit/Views/Components/SelectionsScreen.swift b/Sources/DolbyIOUIKit/Views/Components/SelectionsScreen.swift index f3f9e3c..e729d6b 100644 --- a/Sources/DolbyIOUIKit/Views/Components/SelectionsScreen.swift +++ b/Sources/DolbyIOUIKit/Views/Components/SelectionsScreen.swift @@ -48,10 +48,14 @@ public struct SelectionsScreen: View { @available(tvOS, unavailable) struct SelectionsScreen_Previews: PreviewProvider { static var previews: some View { - SelectionsScreen(settings: [ - .init(key: "testA.localized.key", bundle: .module, selected: true), - .init(key: "testB.localized.key", bundle: .module, selected: false), - .init(key: "testC.localized.key", bundle: .module, selected: false) - ], footer: "testD.localized.key", footerBundle: .module) + SelectionsScreen( + settings: [ + .init(key: "Key 1", selected: true), + .init(key: "Key 2", selected: false), + .init(key: "Key 3", selected: false) + ], + footer: "testD.localized.key", + footerBundle: .module + ) } } diff --git a/Sources/DolbyIOUIKit/Views/Components/SettingsCell.swift b/Sources/DolbyIOUIKit/Views/Components/SettingsCell.swift index d9ab22c..3c4bddd 100644 --- a/Sources/DolbyIOUIKit/Views/Components/SettingsCell.swift +++ b/Sources/DolbyIOUIKit/Views/Components/SettingsCell.swift @@ -40,7 +40,7 @@ public struct SettingsCell: View { Text( text, bundle: bundle, - font: .custom("AvenirNext-Regular", size: CGFloat(14.0), relativeTo: .body), + font: .custom("AvenirNext-Regular", size: FontSize.body), textColor: textColor ) } @@ -51,7 +51,7 @@ public struct SettingsCell: View { Text( value, bundle: bundle, - font: .custom("AvenirNext-Regular", size: CGFloat(14.0), relativeTo: .body), + font: .custom("AvenirNext-Regular", size: FontSize.body), textColor: valueColor ) } @@ -70,19 +70,12 @@ struct SettingsCell_Previews: PreviewProvider { static var previews: some View { List { Section { - SettingsCell(text: "testA.localized.key", - value: "Connection order", - image: .arrowRight, - bundle: .module, - action: {} - ) - .listRowSeparator(.visible) - SettingsCell(text: "Audio selection", value: "First source", image: .textLink, action: {} - ).listRowSeparator(.visible) + ) + .listRowSeparator(.visible) } .listRowBackground(Color.gray) } diff --git a/Sources/DolbyIOUIKit/Views/Components/Text.swift b/Sources/DolbyIOUIKit/Views/Components/Text.swift index 8ecc805..c9491b0 100644 --- a/Sources/DolbyIOUIKit/Views/Components/Text.swift +++ b/Sources/DolbyIOUIKit/Views/Components/Text.swift @@ -84,13 +84,6 @@ struct Text_Previews: PreviewProvider { static var previews: some View { Group { VStack { - Text( - "testA.localized.key", - bundle: .module, - style: .titleMedium, - font: .custom("AvenirNext-Regular", size: FontSize.title1, relativeTo: .title) - ) - Text( verbatim: "This is a regular text", style: .titleMedium,