diff --git a/Examples/iOS/AudioSourceService.swift b/Examples/iOS/AudioSourceService.swift index 43f59959..dffd10c7 100644 --- a/Examples/iOS/AudioSourceService.swift +++ b/Examples/iOS/AudioSourceService.swift @@ -142,7 +142,7 @@ extension AudioSourceService: AsyncRunner { switch mode { case .audioSource: break - case .audioSourceWithSterao: + case .audioSourceWithStereo: sources = makeAudioSources() tasks.append(Task { for await reason in NotificationCenter.default.notifications(named: AVAudioSession.routeChangeNotification) diff --git a/Examples/iOS/HaishinApp.swift b/Examples/iOS/HaishinApp.swift index 4a327a69..5265b724 100644 --- a/Examples/iOS/HaishinApp.swift +++ b/Examples/iOS/HaishinApp.swift @@ -10,26 +10,56 @@ nonisolated let logger = LBLogger.with("com.haishinkit.HaishinApp") @main struct HaishinApp: App { @State private var preference = PreferenceViewModel() + @State private var isInitialized = false var body: some Scene { WindowGroup { - ContentView() - .environmentObject(preference) + if isInitialized { + ContentView() + .environmentObject(preference) + } else { + LaunchScreen() + .task { + await initialize() + isInitialized = true + } + } } } - init() { - Task { - await SessionBuilderFactory.shared.register(RTMPSessionFactory()) - await SessionBuilderFactory.shared.register(SRTSessionFactory()) - await SessionBuilderFactory.shared.register(HTTPSessionFactory()) + private func initialize() async { + await SessionBuilderFactory.shared.register(RTMPSessionFactory()) + await SessionBuilderFactory.shared.register(SRTSessionFactory()) + await SessionBuilderFactory.shared.register(HTTPSessionFactory()) - await RTCLogger.shared.setLevel(.debug) - await SRTLogger.shared.setLevel(.debug) - } + await RTCLogger.shared.setLevel(.debug) + await SRTLogger.shared.setLevel(.debug) + } + + init() { LBLogger(kHaishinKitIdentifier).level = .debug LBLogger(kRTCHaishinKitIdentifier).level = .debug LBLogger(kRTMPHaishinKitIdentifier).level = .debug LBLogger(kSRTHaishinKitIdentifier).level = .debug } } + +struct LaunchScreen: View { + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + VStack(spacing: 20) { + Image(systemName: "video.fill") + .font(.system(size: 60)) + .foregroundColor(.white) + Text("HaishinKit") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.white) + ProgressView() + .tint(.white) + .padding(.top, 20) + } + } + } +} diff --git a/Examples/iOS/PlaybackView.swift b/Examples/iOS/PlaybackView.swift index 8f1f8e17..8ec4f022 100644 --- a/Examples/iOS/PlaybackView.swift +++ b/Examples/iOS/PlaybackView.swift @@ -9,59 +9,93 @@ struct PlaybackView: View { VStack { PiPHKViewRepresentable(previewSource: model) } + VStack { Spacer() + + if model.hasError { + VStack(spacing: 16) { + Image(systemName: "tv.slash") + .font(.system(size: 48)) + .foregroundColor(.white.opacity(0.7)) + + Text("Can't connect to stream") + .font(.headline) + .foregroundColor(.white) + + Text(model.friendlyErrorMessage) + .font(.subheadline) + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button(action: { + model.dismissError() + }) { + Text("Try Again") + .font(.subheadline.bold()) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color.blue) + .cornerRadius(8) + } + } + .padding(24) + .background(Color.black.opacity(0.8)) + .cornerRadius(16) + } + + Spacer() + HStack { Spacer() switch model.readyState { case .connecting: - Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + .frame(width: 64, height: 64) + .background(Color.black.opacity(0.5)) + .cornerRadius(32) + .padding(16) case .open: Button(action: { Task { await model.stop() } - }, label: { - Image(systemName: "stop.circle") + }) { + Image(systemName: "stop.fill") .foregroundColor(.white) .font(.system(size: 24)) - }) - .frame(width: 60, height: 60) - .background(Color.blue) - .cornerRadius(30.0) - .padding(EdgeInsets(top: 0, leading: 0, bottom: 16.0, trailing: 16.0)) - case .closed: - Button(action: { - Task { - await model.start() + } + .frame(width: 64, height: 64) + .background(Color.red) + .cornerRadius(32) + .padding(16) + case .closed, .closing: + if !model.hasError { + Button(action: { + Task { + await model.start() + } + }) { + Image(systemName: "play.fill") + .foregroundColor(.white) + .font(.system(size: 24)) } - }, label: { - Image(systemName: "play.circle") - .foregroundColor(.white) - .font(.system(size: 24)) - }) - .frame(width: 60, height: 60) - .background(Color.blue) - .cornerRadius(30.0) - .padding(EdgeInsets(top: 0, leading: 0, bottom: 16.0, trailing: 16.0)) - case .closing: - Spacer() + .frame(width: 64, height: 64) + .background(Color.blue) + .cornerRadius(32) + .padding(16) + } } } } - if model.readyState == .connecting { - VStack { - ProgressView() - } - } - }.task { + } + .background(Color.black) + .task { await model.makeSession() - }.alert(isPresented: $model.isShowError) { - Alert( - title: Text("Error"), - message: Text(model.error?.localizedDescription ?? ""), - dismissButton: .default(Text("OK")) - ) } } } diff --git a/Examples/iOS/PlaybackViewModel.swift b/Examples/iOS/PlaybackViewModel.swift index 6eb93cfb..37c630cf 100644 --- a/Examples/iOS/PlaybackViewModel.swift +++ b/Examples/iOS/PlaybackViewModel.swift @@ -8,7 +8,32 @@ import SwiftUI final class PlaybackViewModel: ObservableObject { @Published private(set) var readyState: SessionReadyState = .closed @Published private(set) var error: Error? - @Published var isShowError = false + @Published var hasError = false + + var friendlyErrorMessage: String { + guard let error else { + return "Something went wrong. Please check your connection and try again." + } + + let errorString = String(describing: error).lowercased() + + if errorString.contains("unsupportedcommand") || errorString.contains("error 1") { + return "This server doesn't support watching streams directly. Most streaming servers (like Owncast) require you to watch via a web browser instead." + } else if errorString.contains("timeout") || errorString.contains("timedout") { + return "Connection timed out. The server may be offline or the stream URL might be incorrect." + } else if errorString.contains("invalidstate") { + return "Unable to connect. Please check that a stream is currently live." + } else if errorString.contains("connection") { + return "Couldn't reach the server. Check your internet connection and verify the stream URL in Preferences." + } else { + return "Unable to play this stream. The server may not support direct playback, or no stream is currently live." + } + } + + func dismissError() { + hasError = false + error = nil + } private var view: PiPHKView? private var session: (any Session)? @@ -22,12 +47,12 @@ final class PlaybackViewModel: ObservableObject { do { try await session.connect { Task { @MainActor in - self.isShowError = true + self.hasError = true } } } catch { self.error = error - self.isShowError = true + self.hasError = true } } diff --git a/Examples/iOS/PreferenceView.swift b/Examples/iOS/PreferenceView.swift index cde685db..4531ba1d 100644 --- a/Examples/iOS/PreferenceView.swift +++ b/Examples/iOS/PreferenceView.swift @@ -1,8 +1,24 @@ import HaishinKit import SwiftUI +struct InfoRow: View { + let title: String + let info: String + + var body: some View { + HStack { + Text(title) + Spacer() + Image(systemName: "info.circle") + .foregroundColor(.blue) + } + .contentShape(Rectangle()) + } +} + struct PreferenceView: View { @EnvironmentObject var model: PreferenceViewModel + @State private var showingInfo = false var body: some View { Form { @@ -20,7 +36,15 @@ struct PreferenceView: View { TextField(Preference.default.streamName, text: $model.streamName) }.padding(.vertical, 4) } header: { - Text("Stream") + HStack { + Text("Stream") + Spacer() + Button(action: { showingInfo = true }) { + Image(systemName: "info.circle") + .font(.system(size: 22)) + .foregroundColor(.blue) + } + } } Section { Picker("Format", selection: $model.audioFormat) { @@ -30,35 +54,44 @@ struct PreferenceView: View { } } header: { Text("Audio Codec Settings") + } footer: { + Text("AAC is widely supported. Opus offers better quality at low bitrates.") } Section { - Toggle(isOn: $model.isLowLatencyRateControlEnabled) { - Text("LowLatency") + Toggle(isOn: $model.isHDREnabled) { + Text("HDR Video") } - Picker("BitRateMode", selection: $model.bitRateMode) { + Toggle(isOn: $model.isLowLatencyRateControlEnabled) { + Text("Low Latency Mode") + } + Picker("BitRate Mode", selection: $model.bitRateMode) { ForEach(model.bitRateModes, id: \.description) { index in Text(index.description).tag(index) } } } header: { Text("Video Codec Settings") + } footer: { + Text("HDR captures wider color range. Low latency reduces delay but may affect quality. Average bitrate is recommended for most streams.") } Section { - Picker("View Type", selection: $model.viewType) { - ForEach(ViewType.allCases, id: \.self) { view in - Text(String(describing: view)).tag(view) + Picker("Preview Type", selection: $model.viewType) { + ForEach(ViewType.allCases, id: \.self) { type in + Text(type.displayName).tag(type) } } - Picker("Audio Capture Mode", selection: $model.audioCaptureMode) { + Picker("Audio Capture", selection: $model.audioCaptureMode) { ForEach(AudioSourceServiceMode.allCases, id: \.self) { view in Text(String(describing: view)).tag(view) } } Toggle(isOn: $model.isGPURendererEnabled) { - Text("Use GPU rendering.") + Text("GPU Rendering") } } header: { - Text("Others") + Text("Capture Settings") + } footer: { + Text("Metal preview is faster. AudioEngine mode is recommended for stability.") } Section { Button(action: { @@ -69,9 +102,162 @@ struct PreferenceView: View { PublishView() }) } header: { - Text("Test Case") + Text("Debug") } } + .sheet(isPresented: $showingInfo) { + InfoGuideView(showingInfo: $showingInfo) + } + } +} + +private enum InfoTab: String, CaseIterable { + case preference = "Preference" + case publish = "Publish" +} + +private struct InfoGuideView: View { + @Binding var showingInfo: Bool + @State private var selectedTab: InfoTab = .preference + + var body: some View { + NavigationView { + VStack(spacing: 0) { + Picker("", selection: $selectedTab) { + ForEach(InfoTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .padding() + .padding(.top, 8) + + TabView(selection: $selectedTab) { + PreferenceGuideList() + .tag(InfoTab.preference) + PublishGuideList() + .tag(InfoTab.publish) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + .navigationTitle("Help") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { showingInfo = false } + } + } + } + } +} + +private struct PreferenceGuideList: View { + var body: some View { + List { + Section("Stream Settings") { + GuideRow(title: "URL", description: "RTMP server address (e.g., rtmp://your-server.com/live)") + GuideRow(title: "Stream Name", description: "Unique stream key provided by your streaming platform") + } + Section("Audio Settings") { + GuideRow(title: "Format", description: "AAC: Universal compatibility\nOpus: Better quality at low bitrates") + } + Section("Video Settings") { + GuideRow(title: "HDR Video", description: "Captures wider color/brightness range. Requires HDR-capable camera.") + GuideRow(title: "Low Latency", description: "Reduces stream delay to ~2-3 seconds. May slightly reduce quality.") + GuideRow(title: "BitRate Mode", description: "Average: Consistent file size\nConstant: Stable quality\nVariable: Best quality") + } + Section("Capture Settings") { + GuideRow(title: "Preview Type", description: "Metal: Fast GPU-based preview.\nSystem PiP: Enables background streaming. When you switch apps, receive a phone call, or go to home screen, your stream continues in a floating window instead of dying.") + GuideRow(title: "Audio Capture", description: "AudioEngine: Most stable\nAudioSource: Direct capture\nStereo: For external mics") + GuideRow(title: "GPU Rendering", description: "Uses GPU for video effects. Disable if experiencing issues.") + } + Section("Debug") { + GuideRow(title: "Memory Release Test", description: "Opens PublishView in a sheet to verify memory is properly released when dismissed to help detect memory leaks.") + } + } + } +} + +private struct PublishGuideList: View { + var body: some View { + List { + Section("Stream Settings") { + GuideRowWithIcon(icon: "15", isText: true, title: "FPS", + description: "Frames per second. 15 saves battery, 30 is standard, 60 is ultra-smooth.") + GuideRowWithIcon(icon: "slider.horizontal.3", title: "Bitrate (kbps)", + description: "Video quality. Higher = better but more data. 1500-2500 recommended.") + GuideRowWithIcon(icon: "rectangle.badge.checkmark", title: "720p", + description: "Video resolution (1280×720). Good balance of quality and performance.") + } + Section("Controls") { + GuideRowWithIcon(icon: "record.circle", title: "Record", + description: "Save a local copy to Photos. Only available while streaming.") + GuideRowWithIcon(icon: "mic.fill", title: "Mute", + description: "Mute/unmute microphone. Red when muted.") + GuideRowWithIcon(icon: "arrow.triangle.2.circlepath.camera", title: "Flip Camera", + description: "Switch between front and back cameras.") + GuideRowWithIcon(icon: "flashlight.on.fill", title: "Torch", + description: "Toggle flashlight. Only works with back camera.") + GuideRowWithIcon(icon: "rectangle.on.rectangle", title: "Dual Camera", + description: "Overlay the other camera in your stream. Viewers see both cameras.") + } + Section("Live Stats") { + GuideRowWithIcon(icon: "arrow.up", title: "Upload Speed", + description: "Current upload rate in KB/s. The graph shows last 60 seconds.") + GuideRowWithIcon(icon: "thermometer.medium", title: "Temperature", + description: "Device thermal state. Lower FPS/bitrate if too hot.") + } + } + } +} + +private struct GuideRow: View { + let title: String + let description: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(description) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 2) + } +} + +private struct GuideRowWithIcon: View { + let icon: String + var isText: Bool = false + let title: String + let description: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + if isText { + Text(icon) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.cyan) + .frame(width: 28, height: 28) + .background(Color.cyan.opacity(0.2)) + .cornerRadius(6) + } else { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundColor(.cyan) + .frame(width: 28, height: 28) + .background(Color.cyan.opacity(0.2)) + .cornerRadius(6) + } + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.medium)) + Text(description) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) } } diff --git a/Examples/iOS/PreferenceViewModel.swift b/Examples/iOS/PreferenceViewModel.swift index f775acaa..3356ddd1 100644 --- a/Examples/iOS/PreferenceViewModel.swift +++ b/Examples/iOS/PreferenceViewModel.swift @@ -7,36 +7,142 @@ enum ViewType: String, CaseIterable, Identifiable { case pip var id: Self { self } + + var displayName: String { + switch self { + case .metal: return "Metal" + case .pip: return "System PiP" + } + } } enum AudioSourceServiceMode: String, CaseIterable, Sendable { case audioSource - case audioSourceWithSterao + case audioSourceWithStereo case audioEngine } @MainActor final class PreferenceViewModel: ObservableObject { + private enum Keys { + static let uri = "pref_stream_uri" + static let streamName = "pref_stream_name" + static let audioFormat = "pref_audio_format" + static let bitRateMode = "pref_bitrate_mode" + static let isLowLatencyEnabled = "pref_low_latency" + static let viewType = "pref_view_type" + static let isGPURendererEnabled = "pref_gpu_renderer" + static let audioCaptureMode = "pref_audio_capture_mode" + static let isDualCameraEnabled = "pref_dual_camera" + static let isHDREnabled = "pref_hdr_enabled" + } + @Published var showPublishSheet: Bool = false - var uri = Preference.default.uri - var streamName = Preference.default.streamName + @Published var uri: String { + didSet { + UserDefaults.standard.set(uri, forKey: Keys.uri) + } + } + @Published var streamName: String { + didSet { + UserDefaults.standard.set(streamName, forKey: Keys.streamName) + } + } private(set) var bitRateModes: [VideoCodecSettings.BitRateMode] = [.average] // MARK: - AudioCodecSettings. - @Published var audioFormat: AudioCodecSettings.Format = .aac + @Published var audioFormat: AudioCodecSettings.Format = .aac { + didSet { + UserDefaults.standard.set(audioFormat.rawValue, forKey: Keys.audioFormat) + } + } // MARK: - VideoCodecSettings. - @Published var bitRateMode: VideoCodecSettings.BitRateMode = .average - var isLowLatencyRateControlEnabled: Bool = false + @Published var bitRateMode: VideoCodecSettings.BitRateMode = .average { + didSet { + UserDefaults.standard.set(bitRateMode.description, forKey: Keys.bitRateMode) + } + } + @Published var isLowLatencyRateControlEnabled: Bool = false { + didSet { + UserDefaults.standard.set(isLowLatencyRateControlEnabled, forKey: Keys.isLowLatencyEnabled) + } + } // MARK: - Others - @Published var viewType: ViewType = .metal - var isGPURendererEnabled: Bool = true - @Published var audioCaptureMode: AudioSourceServiceMode = .audioEngine + @Published var viewType: ViewType = .metal { + didSet { + UserDefaults.standard.set(viewType.rawValue, forKey: Keys.viewType) + } + } + @Published var isGPURendererEnabled: Bool = true { + didSet { + UserDefaults.standard.set(isGPURendererEnabled, forKey: Keys.isGPURendererEnabled) + } + } + @Published var audioCaptureMode: AudioSourceServiceMode = .audioEngine { + didSet { + UserDefaults.standard.set(audioCaptureMode.rawValue, forKey: Keys.audioCaptureMode) + } + } + @Published var isDualCameraEnabled: Bool = true { + didSet { + UserDefaults.standard.set(isDualCameraEnabled, forKey: Keys.isDualCameraEnabled) + } + } + @Published var isHDREnabled: Bool = false { + didSet { + UserDefaults.standard.set(isHDREnabled, forKey: Keys.isHDREnabled) + } + } init() { + let defaults = UserDefaults.standard + + self.uri = defaults.string(forKey: Keys.uri) ?? Preference.default.uri + self.streamName = defaults.string(forKey: Keys.streamName) ?? Preference.default.streamName + + if let rawValue = defaults.string(forKey: Keys.audioFormat), + let format = AudioCodecSettings.Format(rawValue: rawValue) { + self.audioFormat = format + } + + if let savedMode = defaults.string(forKey: Keys.bitRateMode) { + if savedMode == VideoCodecSettings.BitRateMode.average.description { + self.bitRateMode = .average + } else if #available(iOS 16.0, tvOS 16.0, *), savedMode == VideoCodecSettings.BitRateMode.constant.description { + self.bitRateMode = .constant + } + } + + if defaults.object(forKey: Keys.isLowLatencyEnabled) != nil { + self.isLowLatencyRateControlEnabled = defaults.bool(forKey: Keys.isLowLatencyEnabled) + } + + if let rawValue = defaults.string(forKey: Keys.viewType), + let type = ViewType(rawValue: rawValue) { + self.viewType = type + } + + if defaults.object(forKey: Keys.isGPURendererEnabled) != nil { + self.isGPURendererEnabled = defaults.bool(forKey: Keys.isGPURendererEnabled) + } + + if let rawValue = defaults.string(forKey: Keys.audioCaptureMode), + let mode = AudioSourceServiceMode(rawValue: rawValue) { + self.audioCaptureMode = mode + } + + if defaults.object(forKey: Keys.isDualCameraEnabled) != nil { + self.isDualCameraEnabled = defaults.bool(forKey: Keys.isDualCameraEnabled) + } + + if defaults.object(forKey: Keys.isHDREnabled) != nil { + self.isHDREnabled = defaults.bool(forKey: Keys.isHDREnabled) + } + if #available(iOS 16.0, tvOS 16.0, *) { bitRateModes.append(.constant) } diff --git a/Examples/iOS/PublishView.swift b/Examples/iOS/PublishView.swift index b16b7d61..e1c3eee5 100644 --- a/Examples/iOS/PublishView.swift +++ b/Examples/iOS/PublishView.swift @@ -22,18 +22,242 @@ enum FPS: String, CaseIterable, Identifiable { var id: Self { self } } +private func bitrateQuality(_ kbps: Double) -> String { + switch kbps { + case ..<1000: return "Low" + case 1000..<1500: return "SD" + case 1500..<2500: return "HD" + case 2500..<3500: return "High" + default: return "Ultra" + } +} + enum VideoEffectItem: String, CaseIterable, Identifiable, Sendable { case none case monochrome + case warm + case vivid var id: Self { self } + var displayName: String { + switch self { + case .none: return "Normal" + case .monochrome: return "B&W" + case .warm: return "Warm" + case .vivid: return "Vivid" + } + } + func makeVideoEffect() -> VideoEffect? { switch self { case .none: return nil case .monochrome: return MonochromeEffect() + case .warm: + return WarmEffect() + case .vivid: + return VividEffect() + } + } +} + +struct StreamButton: View { + let readyState: SessionReadyState + let onStart: () -> Void + let onStop: () -> Void + + @State private var isPulsing = false + @State private var countdown = 3 + @State private var countdownTimer: Timer? + + var body: some View { + Button(action: { + switch readyState { + case .closed: + onStart() + case .open: + onStop() + default: + break + } + }) { + ZStack { + if readyState == .open { + Circle() + .stroke(Color.red.opacity(0.5), lineWidth: 3) + .frame(width: 76, height: 76) + .scaleEffect(isPulsing ? 1.2 : 1.0) + .opacity(isPulsing ? 0 : 0.8) + .animation( + .easeInOut(duration: 1.0).repeatForever(autoreverses: false), + value: isPulsing + ) + } + + Circle() + .fill(buttonBackground) + .frame(width: 70, height: 70) + .shadow(color: shadowColor, radius: 8, x: 0, y: 4) + + VStack(spacing: 2) { + switch readyState { + case .connecting: + Text("\(countdown)") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + case .closing: + Text("...") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + case .open: + Image(systemName: "stop.fill") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + Text("END") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + case .closed: + Image(systemName: "dot.radiowaves.left.and.right") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.white) + Text("GO LIVE") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.white) + } + } + } + } + .disabled(readyState == .connecting || readyState == .closing) + .onAppear { + if readyState == .open { + isPulsing = true + } + } + .onChange(of: readyState) { newState in + isPulsing = (newState == .open) + if newState == .connecting { + startCountdown() + } else { + stopCountdown() + } + } + .onDisappear { + stopCountdown() + } + } + + private func startCountdown() { + countdown = 3 + countdownTimer?.invalidate() + countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + if countdown > 1 { + countdown -= 1 + } + } + } + + private func stopCountdown() { + countdownTimer?.invalidate() + countdownTimer = nil + countdown = 3 + } + + private var buttonBackground: LinearGradient { + switch readyState { + case .open: + return LinearGradient( + colors: [Color.red, Color.red.opacity(0.8)], + startPoint: .top, + endPoint: .bottom + ) + case .connecting, .closing: + return LinearGradient( + colors: [Color.orange, Color.orange.opacity(0.8)], + startPoint: .top, + endPoint: .bottom + ) + case .closed: + return LinearGradient( + colors: [Color.green, Color.green.opacity(0.7)], + startPoint: .top, + endPoint: .bottom + ) + } + } + + private var shadowColor: Color { + switch readyState { + case .open: + return Color.red.opacity(0.5) + case .connecting, .closing: + return Color.orange.opacity(0.5) + case .closed: + return Color.green.opacity(0.5) + } + } +} + +private func formatDuration(_ duration: TimeInterval) -> String { + let hours = Int(duration) / 3600 + let minutes = (Int(duration) % 3600) / 60 + let seconds = Int(duration) % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%d:%02d", minutes, seconds) +} + +private func thermalStateText(_ state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: return "Cool" + case .fair: return "Warm" + case .serious: return "Hot" + case .critical: return "Critical" + @unknown default: return "Unknown" + } +} + +private func thermalStateColor(_ state: ProcessInfo.ThermalState) -> Color { + switch state { + case .nominal: return .green + case .fair: return .yellow + case .serious: return .orange + case .critical: return .red + @unknown default: return .white + } +} + +struct StatusBadge: View { + let text: String + let color: Color + var textColor: Color = .white + + var body: some View { + Text(text) + .font(.system(size: 10, weight: .bold)) + .foregroundColor(textColor) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(color) + .cornerRadius(4) + } +} + +struct SmallIconButton: View { + let icon: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(color) + .frame(width: 44, height: 44) + .background(Color.black.opacity(0.3)) + .cornerRadius(22) } } } @@ -42,160 +266,302 @@ struct PublishView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject var preference: PreferenceViewModel @StateObject private var model = PublishViewModel() + @State private var showFilterHint = true + @State private var showFilterChange = false + @State private var filterChangeId = 0 var body: some View { ZStack { VStack { - if preference.viewType == .pip { - PiPHKViewRepresentable(previewSource: model) - } else { - MTHKViewRepresentable(previewSource: model) + MTHKViewRepresentable(previewSource: model, videoGravity: .resizeAspectFill) + } + + if model.isLoading { + Color.black.opacity(0.6) + .ignoresSafeArea() + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + Text("Loading Camera...") + .font(.headline) + .foregroundColor(.white) } } - VStack { - Spacer() - Chart(model.stats) { - LineMark( - x: .value("time", $0.date), - y: .value("currentBytesOutPerSecond", $0.currentBytesOutPerSecond) - ) + + if showFilterHint && !model.isLoading { + VStack(spacing: 10) { + VStack(spacing: 8) { + HStack(spacing: 16) { + Image(systemName: "chevron.left") + Text(model.visualEffectItem.displayName) + .font(.system(size: 14, weight: .medium)) + Image(systemName: "chevron.right") + } + Text("Swipe to change filter") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.7)) + } + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.black.opacity(0.35)) + .cornerRadius(12) + HStack(spacing: 6) { + ForEach(VideoEffectItem.allCases) { effect in + Circle() + .fill(effect == model.visualEffectItem ? Color.white : Color.white.opacity(0.4)) + .frame(width: 6, height: 6) + } + } + } + .transition(.opacity) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation(.easeOut(duration: 0.5)) { + showFilterHint = false + } + } } - .frame(height: 300) - .padding(32) } - VStack { - HStack(spacing: 16) { - if !model.audioSources.isEmpty { - Picker("AudioSource", selection: $model.audioSource) { - ForEach(model.audioSources, id: \.description) { source in - Text(source.description).tag(source) + + if !showFilterHint { + VStack(spacing: 10) { + Text(model.visualEffectItem.displayName) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 14) + .background(Color.black.opacity(0.35)) + .cornerRadius(12) + HStack(spacing: 6) { + ForEach(VideoEffectItem.allCases) { effect in + Circle() + .fill(effect == model.visualEffectItem ? Color.white : Color.white.opacity(0.4)) + .frame(width: 6, height: 6) + } + } + } + .opacity(showFilterChange ? 1 : 0) + .animation(.easeOut(duration: 0.3), value: showFilterChange) + } + + VStack(spacing: 0) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + if model.readyState == .open { + HStack(spacing: 6) { + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + Text(formatDuration(model.streamDuration)) + .font(.system(size: 16, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.black.opacity(0.6)) + .cornerRadius(8) + } + + if !model.isLoading { + Text("720p") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.black.opacity(0.6)) + .cornerRadius(4) + } + + if !model.audioSources.isEmpty { + Picker("AudioSource", selection: $model.audioSource) { + ForEach(model.audioSources, id: \.description) { source in + Text(source.description).tag(source) + } + } + .frame(width: 180) + .background(Color.black.opacity(0.4)) + .cornerRadius(8) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 8) { + HStack(spacing: 6) { + if model.readyState == .open { + StatusBadge(text: "LIVE", color: .red) + } + if model.isRecording { + StatusBadge(text: "REC", color: .orange) + } + if preference.isHDREnabled { + StatusBadge(text: "HDR", color: .purple) + } + if model.isAudioMuted { + StatusBadge(text: "MUTED", color: .gray) + } + if model.isTorchEnabled { + StatusBadge(text: "TORCH", color: .yellow, textColor: .black) + } + if model.visualEffectItem != .none { + StatusBadge(text: model.visualEffectItem.displayName.uppercased(), color: .cyan) } } - .frame(width: 200) - .background(Color.black.opacity(0.2)) - .cornerRadius(16) - .padding(16) - } - Spacer() - Button(action: { - model.toggleRecording() - }, label: { - Image(systemName: model.isRecording ? - "recordingtape.circle.fill" : - "recordingtape.circle") - .resizable() - .scaledToFit() - .foregroundColor(.white) - .frame(width: 30, height: 30) - }) - Button(action: { - model.toggleAudioMuted() - }, label: { - Image(systemName: model.isAudioMuted ? - "microphone.slash.circle" : - "microphone.circle") - .resizable() - .scaledToFit() - .foregroundColor(.white) - .frame(width: 30, height: 30) - }) - Button(action: { - model.flipCamera() - }, label: { - Image(systemName: - "arrow.trianglehead.2.clockwise.rotate.90.camera") - .resizable() - .scaledToFit() - .foregroundColor(.white) - .frame(width: 30, height: 30) - }) - Button(action: { - model.toggleTorch() - }, label: { - Image(systemName: model.isTorchEnabled ? - "flashlight.on.circle.fill" : - "flashlight.off.circle.fill") - .resizable() - .scaledToFit() - .foregroundColor(.white) - .frame(width: 30, height: 30) - }) - } - .frame(height: 50) - HStack { - Spacer() - Toggle(isOn: $model.isHDREnabled) { - Text("HDR") - }.frame(width: 120) - Picker("FPS", selection: $model.currentFPS) { - ForEach(FPS.allCases) { - Text($0.rawValue).tag($0) + + if model.isVolumeOn { + Text("Volume up causes echo") + .font(.system(size: 10)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.red.opacity(0.8)) + .cornerRadius(4) } } - .onChange(of: model.currentFPS) { tag in - model.setFrameRate(tag.frameRate) - } - .pickerStyle(.segmented) - .frame(width: 150) - }.frame(height: 80) + } + .padding(16) + Spacer() - TabView(selection: $model.visualEffectItem) { - ForEach(VideoEffectItem.allCases) { - Text($0.rawValue).padding() + + VStack(spacing: 10) { + if model.readyState == .open && !model.stats.isEmpty { + HStack(spacing: 8) { + HStack(spacing: 3) { + Image(systemName: "arrow.up") + .font(.system(size: 9, weight: .bold)) + Text("\(model.currentUploadKBps)") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + Text("KB/s") + .font(.system(size: 8)) + .foregroundColor(.white.opacity(0.6)) + } + + Chart(model.stats) { + LineMark( + x: .value("time", $0.date), + y: .value("bytes", $0.currentBytesOutPerSecond) + ) + .foregroundStyle(Color.cyan) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + .chartYAxis(.hidden) + .chartXAxis(.hidden) + .frame(height: 28) + + HStack(spacing: 3) { + Image(systemName: "thermometer.medium") + .font(.system(size: 9)) + Text(thermalStateText(model.thermalState)) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(thermalStateColor(model.thermalState)) + } + } + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.black.opacity(0.4)) + .cornerRadius(8) + } + + HStack(spacing: 0) { + HStack(spacing: 4) { + ForEach(FPS.allCases) { fps in + Button(action: { + model.currentFPS = fps + model.setFrameRate(fps.frameRate) + }) { + Text(fps.rawValue) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(model.currentFPS == fps ? .white : .white.opacity(0.5)) + .frame(width: 44, height: 44) + .background(model.currentFPS == fps ? Color.white.opacity(0.25) : Color.black.opacity(0.3)) + .cornerRadius(22) + } + } + } + + Spacer() + + HStack(spacing: 6) { + SmallIconButton(icon: model.isRecording ? "record.circle.fill" : "record.circle", + color: model.isRecording ? .orange : .white) { + model.toggleRecording() + } + .disabled(model.readyState != .open) + .opacity(model.readyState == .open ? 1.0 : 0.4) + + SmallIconButton(icon: model.isAudioMuted ? "mic.slash.fill" : "mic.fill", + color: model.isAudioMuted ? .red : .white) { + model.toggleAudioMuted() + } + + SmallIconButton(icon: "arrow.triangle.2.circlepath.camera", + color: .white) { + model.flipCamera() + } + + SmallIconButton(icon: model.isTorchEnabled ? "flashlight.on.fill" : "flashlight.off.fill", + color: model.isTorchEnabled ? .yellow : .white) { + model.toggleTorch() + } + .disabled(model.currentCamera == "Front") + .opacity(model.currentCamera == "Front" ? 0.4 : 1.0) + + SmallIconButton(icon: model.isDualCameraEnabled ? "rectangle.on.rectangle.fill" : "rectangle.on.rectangle", + color: model.isDualCameraEnabled ? .cyan : .white) { + model.toggleDualCamera() + } + } + } + + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text("\(Int(model.videoBitRates))") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(.white) + Text("kbps") + .font(.system(size: 9)) + .foregroundColor(.white.opacity(0.5)) + Text("•") + .foregroundColor(.white.opacity(0.3)) + Text(bitrateQuality(model.videoBitRates)) + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.cyan) + } + Slider(value: $model.videoBitRates, in: 500...4000, step: 100) + .tint(.cyan) + } + + StreamButton( + readyState: model.readyState, + onStart: { model.showPreLiveDialog = true }, + onStop: { model.stopPublishing() } + ) + .confirmationDialog("Ready to Go Live?", isPresented: $model.showPreLiveDialog, titleVisibility: .visible) { + Button("Go Live with Recording") { + model.startPublishing(preference, withRecording: true) + } + Button("Go Live without Recording") { + model.startPublishing(preference, withRecording: false) + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Recording saves a copy of your stream to Photos at \(Int(model.videoBitRates)) kbps.") + } } } - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) - .frame(height: 100) - .onChange(of: model.visualEffectItem) { tag in - model.setVisualEffet(tag) - } - HStack { - Slider( - value: $model.videoBitRates, - in: 100...4000, - step: 100 - ) { - Text("Video BitRate(kbp)") - } minimumValueLabel: { - Text("100") - } maximumValueLabel: { - Text("4,000") - } - .padding(.leading, 32) - .frame(maxWidth: .infinity) - Text("\(Int(model.videoBitRates))/kbps") - Spacer() - switch model.readyState { - case .connecting: - Spacer() - case .open: - Button(action: { - model.stopPublishing() - }, label: { - Image(systemName: "stop.circle") - .foregroundColor(.white) - .font(.system(size: 24)) - }) - .frame(width: 60, height: 60) - .background(Color.blue) - .cornerRadius(30.0) - .padding(EdgeInsets(top: 0, leading: 0, bottom: 16.0, trailing: 16.0)) - case .closing: - Spacer() - case .closed: - Button(action: { - model.startPublishing(preference) - }, label: { - Image(systemName: "record.circle") - .foregroundColor(.white) - .font(.system(size: 24)) - }) - .frame(width: 60, height: 60) - .background(Color.blue) - .cornerRadius(30.0) - .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16.0)) - } - }.frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.bottom, 16) + .background( + LinearGradient( + colors: [.clear, .black.opacity(0.25), .black.opacity(0.5)], + startPoint: .top, + endPoint: .bottom + ) + ) } } .onAppear { @@ -213,6 +579,33 @@ struct PublishView: View { dismissButton: .default(Text("OK")) ) } + .gesture( + DragGesture(minimumDistance: 50) + .onEnded { value in + if abs(value.translation.width) > abs(value.translation.height) { + let effects = VideoEffectItem.allCases + guard let currentIndex = effects.firstIndex(of: model.visualEffectItem) else { return } + let newIndex: Int + if value.translation.width < 0 { + newIndex = (currentIndex + 1) % effects.count + } else { + newIndex = (currentIndex - 1 + effects.count) % effects.count + } + let newEffect = effects[newIndex] + model.visualEffectItem = newEffect + model.setVisualEffet(newEffect) + filterChangeId += 1 + showFilterChange = true + let currentId = filterChangeId + Task { + try? await Task.sleep(for: .milliseconds(800)) + if filterChangeId == currentId { + showFilterChange = false + } + } + } + } + ) } } diff --git a/Examples/iOS/PublishViewModel.swift b/Examples/iOS/PublishViewModel.swift index 2bc8e08d..1d5250ed 100644 --- a/Examples/iOS/PublishViewModel.swift +++ b/Examples/iOS/PublishViewModel.swift @@ -1,12 +1,22 @@ import AVFoundation import HaishinKit +import MediaPlayer import Photos import RTCHaishinKit import SwiftUI @MainActor final class PublishViewModel: ObservableObject { - @Published var currentFPS: FPS = .fps30 + private enum Keys { + static let currentFPS = "publish_fps" + static let videoBitRates = "publish_bitrate" + } + + @Published var currentFPS: FPS = .fps30 { + didSet { + UserDefaults.standard.set(currentFPS.rawValue, forKey: Keys.currentFPS) + } + } @Published var visualEffectItem: VideoEffectItem = .none @Published private(set) var error: Error? { didSet { @@ -16,6 +26,7 @@ final class PublishViewModel: ObservableObject { } } @Published var isShowError = false + @Published var showPreLiveDialog = false @Published private(set) var isAudioMuted = false @Published private(set) var isTorchEnabled = false @Published private(set) var readyState: SessionReadyState = .closed @@ -29,24 +40,23 @@ final class PublishViewModel: ObservableObject { } @Published private(set) var audioSources: [AudioSource] = [] @Published private(set) var isRecording = false - @Published var isHDREnabled = false { - didSet { - Task { - do { - if isHDREnabled { - try await mixer.setDynamicRangeMode(.hdr) - } else { - try await mixer.setDynamicRangeMode(.sdr) - } - } catch { - logger.info(error) - } - } - } - } @Published private(set) var stats: [Stats] = [] - @Published var videoBitRates: Double = 100 { + @Published private(set) var currentCamera: String = "Back" + @Published private(set) var isDualCameraEnabled: Bool = false + @Published private(set) var isVolumeOn: Bool = false + @Published private(set) var isLoading: Bool = true + @Published private(set) var videoDimensions: String = "" + @Published private(set) var batteryUsed: Float = 0 + @Published private(set) var streamDuration: TimeInterval = 0 + @Published private(set) var thermalState: ProcessInfo.ThermalState = .nominal + @Published private(set) var currentUploadKBps: Int = 0 + private var streamStartBattery: Float = 0 + private var streamStartTime: Date? + private var batteryTimer: Timer? + private var durationTimer: Timer? + @Published var videoBitRates: Double = 2000 { didSet { + UserDefaults.standard.set(videoBitRates, forKey: Keys.videoBitRates) Task { guard let session else { return @@ -57,9 +67,7 @@ final class PublishViewModel: ObservableObject { } } } - // If you want to use the multi-camera feature, please make create a MediaMixer with a capture mode. - // let mixer = MediaMixer(captureSesionMode: .multi) - private(set) var mixer = MediaMixer(captureSessionMode: .multi) + private(set) var mixer = MediaMixer() private var tasks: [Task] = [] private var session: (any Session)? private var recorder: StreamRecorder? @@ -67,19 +75,48 @@ final class PublishViewModel: ObservableObject { private var audioSourceService = AudioSourceService() @ScreenActor private var videoScreenObject: VideoTrackScreenObject? @ScreenActor private var currentVideoEffect: VideoEffect? + private var volumeObserver: NSKeyValueObservation? + private var mtView: MTHKView? + private var isMixerReady = false init() { + let defaults = UserDefaults.standard + + if let rawValue = defaults.string(forKey: Keys.currentFPS), + let fps = FPS(rawValue: rawValue) { + self.currentFPS = fps + } + + if defaults.object(forKey: Keys.videoBitRates) != nil { + self.videoBitRates = defaults.double(forKey: Keys.videoBitRates) + } + Task { @ScreenActor in videoScreenObject = VideoTrackScreenObject() } } - func startPublishing(_ preference: PreferenceViewModel) { + func startPublishing(_ preference: PreferenceViewModel, withRecording: Bool = false) { Task { guard let session else { return } stats.removeAll() + + let recorder = StreamRecorder() + await mixer.addOutput(recorder) + self.recorder = recorder + + if withRecording { + do { + try await recorder.startRecording() + isRecording = true + } catch { + self.error = error + logger.warn(error) + } + } + do { try await session.connect { Task { @MainActor in @@ -95,6 +132,25 @@ final class PublishViewModel: ObservableObject { func stopPublishing() { Task { + if isRecording { + do { + if let videoFile = try await recorder?.stopRecording() { + Task.detached { + try await PHPhotoLibrary.shared().performChanges { + let creationRequest = PHAssetCreationRequest.forAsset() + creationRequest.addResource(with: .video, fileURL: videoFile, options: nil) + } + } + } + } catch { + logger.warn(error) + } + isRecording = false + } + if let recorder { + await mixer.removeOutput(recorder) + self.recorder = nil + } do { try await session?.close() } catch { @@ -107,7 +163,6 @@ final class PublishViewModel: ObservableObject { if isRecording { Task { do { - // To use this in a product, you need to consider recovery procedures in case moving to the Photo Library fails. if let videoFile = try await recorder?.stopRecording() { Task.detached { try await PHPhotoLibrary.shared().performChanges { @@ -128,18 +183,17 @@ final class PublishViewModel: ObservableObject { logger.warn(error) } } - recorder = nil isRecording = false } } else { Task { - let recorder = StreamRecorder() - await mixer.addOutput(recorder) + guard let recorder else { + logger.warn("Recorder not initialized") + return + } do { - // When starting a recording while connected to Xcode, it freezes for about 30 seconds. iOS26 + Xcode26. try await recorder.startRecording() isRecording = true - self.recorder = recorder } catch { self.error = error logger.warn(error) @@ -178,7 +232,6 @@ final class PublishViewModel: ObservableObject { } func makeSession(_ preference: PreferenceViewModel) async { - // Make session. do { session = try await SessionBuilderFactory.shared.make(preference.makeURL()) .setMode(.publish) @@ -186,11 +239,16 @@ final class PublishViewModel: ObservableObject { guard let session else { return } - let videoSettings = await session.stream.videoSettings - videoBitRates = Double(videoSettings.bitRate / 1000) + var videoSettings = await session.stream.videoSettings + videoSettings.bitRate = Int(videoBitRates * 1000) + try? await session.stream.setVideoSettings(videoSettings) await session.stream.setBitRateStrategy(StatsMonitor({ data in Task { @MainActor in self.stats.append(data) + if self.stats.count > 60 { + self.stats.removeFirst(self.stats.count - 60) + } + self.currentUploadKBps = data.currentBytesOutPerSecond / 1024 } })) await mixer.addOutput(session.stream) @@ -200,6 +258,10 @@ final class PublishViewModel: ObservableObject { switch readyState { case .open: UIApplication.shared.isIdleTimerDisabled = false + self.startBatteryTracking() + case .closed: + UIApplication.shared.isIdleTimerDisabled = true + self.stopBatteryTracking() default: UIApplication.shared.isIdleTimerDisabled = true } @@ -225,74 +287,134 @@ final class PublishViewModel: ObservableObject { } func startRunning(_ preference: PreferenceViewModel) { + isMixerReady = false + isDualCameraEnabled = false + + let isGPURendererEnabled = preference.isGPURendererEnabled + Task { + tasks.forEach { $0.cancel() } + tasks.removeAll() + + await audioSourceService.stopRunning() + await mixer.stopRunning() + try? await mixer.attachAudio(nil) + try? await mixer.attachVideo(nil, track: 0) + try? await mixer.attachVideo(nil, track: 1) + if let session { + await mixer.removeOutput(session.stream) + try? await session.close() + } + session = nil + + mixer = MediaMixer(captureSessionMode: .multi) + let audioCaptureMode = preference.audioCaptureMode await audioSourceService.setUp(preference.audioCaptureMode) await mixer.configuration { session in switch audioCaptureMode { case .audioSource: session.automaticallyConfiguresApplicationAudioSession = true - case .audioSourceWithSterao: - // It is required for the stereo setting. + case .audioSourceWithStereo: session.automaticallyConfiguresApplicationAudioSession = false case .audioEngine: session.automaticallyConfiguresApplicationAudioSession = true } } - // SetUp a mixer. await mixer.setMonitoringEnabled(DeviceUtil.isHeadphoneConnected()) var videoMixerSettings = await mixer.videoMixerSettings videoMixerSettings.mode = .offscreen await mixer.setVideoMixerSettings(videoMixerSettings) - // Attach devices - let back = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: currentPosition) - try? await mixer.attachVideo(back, track: 0) - let front = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) - try? await mixer.attachVideo(front, track: 1) { videoUnit in + + await configureScreen(isGPURendererEnabled: isGPURendererEnabled) + + let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + let frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) + try? await mixer.attachVideo(backCamera, track: 0) { videoUnit in + videoUnit.isVideoMirrored = false + } + try? await mixer.attachVideo(frontCamera, track: 1) { videoUnit in videoUnit.isVideoMirrored = true } + var videoMixerSettings2 = await mixer.videoMixerSettings + videoMixerSettings2.mainTrack = currentPosition == .front ? 1 : 0 + await mixer.setVideoMixerSettings(videoMixerSettings2) + currentCamera = currentPosition == .front ? "Front" : "Back" if audioCaptureMode == .audioSource { try? await mixer.attachAudio(AVCaptureDevice.default(for: .audio)) } await audioSourceService.startRunning() await mixer.startRunning() + + isMixerReady = true + if let mtView { + await mixer.addOutput(mtView) + } + + do { + if preference.isHDREnabled { + try await mixer.setDynamicRangeMode(.hdr) + } else { + try await mixer.setDynamicRangeMode(.sdr) + } + } catch { + logger.info(error) + } await makeSession(preference) + let isLandscape = await UIDevice.current.orientation.isLandscape + await updateVideoEncoderSize(isLandscape: isLandscape) + let screenSize = await mixer.screen.size + if let session = self.session { + let videoSettings = await session.stream.videoSettings + self.videoDimensions = "Screen: \(Int(screenSize.width))x\(Int(screenSize.height)) | Video: \(videoSettings.videoSize.width)x\(videoSettings.videoSize.height)" + } + isLoading = false } orientationDidChange() - Task { @ScreenActor in - guard let videoScreenObject else { - return - } - if await preference.isGPURendererEnabled { - await mixer.screen.isGPURendererEnabled = true - } else { - await mixer.screen.isGPURendererEnabled = false - } - videoScreenObject.cornerRadius = 16.0 - videoScreenObject.track = 1 - videoScreenObject.horizontalAlignment = .right - videoScreenObject.layoutMargin = .init(top: 16, left: 0, bottom: 0, right: 16) - videoScreenObject.size = .init(width: 160 * 2, height: 90 * 2) - await mixer.screen.size = .init(width: 720, height: 1280) - await mixer.screen.backgroundColor = UIColor.black.cgColor - try? await mixer.screen.addChild(videoScreenObject) - } - Task { + tasks.append(Task { for await buffer in await audioSourceService.buffer { await mixer.append(buffer.0, when: buffer.1) } - } - Task { + }) + tasks.append(Task { for await sources in await audioSourceService.sourcesUpdates() { audioSources = sources if let first = sources.first, audioSource == .empty { audioSource = first } } + }) + startVolumeMonitoring() + } + + @ScreenActor + private func configureScreen(isGPURendererEnabled: Bool) async { + await mixer.screen.isGPURendererEnabled = isGPURendererEnabled + await mixer.screen.size = .init(width: 720, height: 1280) + await mixer.screen.backgroundColor = UIColor.black.cgColor + } + + private func startVolumeMonitoring() { + let audioSession = AVAudioSession.sharedInstance() + try? audioSession.setActive(true) + isVolumeOn = audioSession.outputVolume > 0 + volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] _, change in + Task { @MainActor in + if let volume = change.newValue { + self?.isVolumeOn = volume > 0 + } + } } } + private func stopVolumeMonitoring() { + volumeObserver?.invalidate() + volumeObserver = nil + } + func stopRunning() { + isMixerReady = false + stopVolumeMonitoring() Task { await audioSourceService.stopRunning() await mixer.stopRunning() @@ -309,27 +431,27 @@ final class PublishViewModel: ObservableObject { func flipCamera() { Task { - if await mixer.isMultiCamSessionEnabled { - var videoMixerSettings = await mixer.videoMixerSettings - if videoMixerSettings.mainTrack == 0 { - videoMixerSettings.mainTrack = 1 - await mixer.setVideoMixerSettings(videoMixerSettings) - Task { @ScreenActor in - videoScreenObject?.track = 0 - } - } else { - videoMixerSettings.mainTrack = 0 - await mixer.setVideoMixerSettings(videoMixerSettings) - Task { @ScreenActor in - videoScreenObject?.track = 1 - } + var videoMixerSettings = await mixer.videoMixerSettings + if videoMixerSettings.mainTrack == 0 { + videoMixerSettings.mainTrack = 1 + await mixer.setVideoMixerSettings(videoMixerSettings) + currentPosition = .front + currentCamera = "Front" + if isTorchEnabled { + await mixer.setTorchEnabled(false) + isTorchEnabled = false + } + Task { @ScreenActor in + videoScreenObject?.track = 0 } } else { - let position: AVCaptureDevice.Position = currentPosition == .back ? .front : .back - try? await mixer.attachVideo(AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position)) { videoUnit in - videoUnit.isVideoMirrored = position == .front + videoMixerSettings.mainTrack = 0 + await mixer.setVideoMixerSettings(videoMixerSettings) + currentPosition = .back + currentCamera = "Back" + Task { @ScreenActor in + videoScreenObject?.track = 1 } - currentPosition = position } } } @@ -353,10 +475,34 @@ final class PublishViewModel: ObservableObject { } } + func toggleDualCamera() { + let isEnabled = isDualCameraEnabled + let position = currentPosition + Task { @ScreenActor in + if isEnabled { + if let videoScreenObject { + try? await mixer.screen.removeChild(videoScreenObject) + } + await MainActor.run { isDualCameraEnabled = false } + } else { + if let videoScreenObject { + videoScreenObject.size = .init(width: 400, height: 224) + videoScreenObject.cornerRadius = 8.0 + videoScreenObject.track = position == .front ? 0 : 1 + videoScreenObject.verticalAlignment = .top + videoScreenObject.horizontalAlignment = .right + videoScreenObject.layoutMargin = .init(top: 32, left: 0, bottom: 0, right: 32) + videoScreenObject.invalidateLayout() + try? await mixer.screen.addChild(videoScreenObject) + } + await MainActor.run { isDualCameraEnabled = true } + } + } + } + func setFrameRate(_ fps: Float64) { Task { do { - // Sets to input frameRate. try? await mixer.configuration(video: 0) { video in do { try video.setFrameRate(fps) @@ -371,7 +517,6 @@ final class PublishViewModel: ObservableObject { logger.error(error) } } - // Sets to output frameRate. try await mixer.setFrameRate(fps) if var videoSettings = await session?.stream.videoSettings { videoSettings.expectedFrameRate = fps @@ -385,17 +530,74 @@ final class PublishViewModel: ObservableObject { func orientationDidChange() { Task { @ScreenActor in - if let orientation = await DeviceUtil.videoOrientation(by: UIApplication.shared.statusBarOrientation) { - await mixer.setVideoOrientation(orientation) - } - if await UIDevice.current.orientation.isLandscape { - await mixer.screen.size = .init(width: 1280, height: 720) - } else { - await mixer.screen.size = .init(width: 720, height: 1280) + await mixer.setVideoOrientation(.portrait) + await mixer.screen.size = .init(width: 720, height: 1280) + let screenSize = await mixer.screen.size + Task { @MainActor in + await self.updateVideoEncoderSize(isLandscape: false) + if let session = self.session { + let videoSettings = await session.stream.videoSettings + self.videoDimensions = "Screen: \(Int(screenSize.width))x\(Int(screenSize.height)) | Video: \(videoSettings.videoSize.width)x\(videoSettings.videoSize.height)" + } else { + self.videoDimensions = "Screen: \(Int(screenSize.width))x\(Int(screenSize.height))" + } } } } + private func updateVideoEncoderSize(isLandscape: Bool) async { + guard let session else { return } + var videoSettings = await session.stream.videoSettings + let targetSize: CGSize = isLandscape + ? CGSize(width: 1280, height: 720) + : CGSize(width: 720, height: 1280) + if videoSettings.videoSize != targetSize { + videoSettings.videoSize = targetSize + try? await session.stream.setVideoSettings(videoSettings) + } + } + + private func startBatteryTracking() { + UIDevice.current.isBatteryMonitoringEnabled = true + streamStartBattery = UIDevice.current.batteryLevel + streamStartTime = Date() + batteryUsed = 0 + streamDuration = 0 + + durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateDuration() + } + } + + batteryTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateBatteryStats() + } + } + } + + private func stopBatteryTracking() { + durationTimer?.invalidate() + durationTimer = nil + batteryTimer?.invalidate() + batteryTimer = nil + updateBatteryStats() + } + + private func updateDuration() { + guard let startTime = streamStartTime else { return } + streamDuration = Date().timeIntervalSince(startTime) + } + + private func updateBatteryStats() { + let currentBattery = UIDevice.current.batteryLevel + if currentBattery >= 0 && streamStartBattery >= 0 { + batteryUsed = (streamStartBattery - currentBattery) * 100 + } + thermalState = ProcessInfo.processInfo.thermalState + } + private func selectAudioSource(_ audioSource: AudioSource) { Task { try await audioSourceService.selectAudioSource(audioSource) @@ -408,16 +610,11 @@ final class PublishViewModel: ObservableObject { extension PublishViewModel: MTHKViewRepresentable.PreviewSource { nonisolated func connect(to view: MTHKView) { - Task { - await mixer.addOutput(view) - } - } -} - -extension PublishViewModel: PiPHKViewRepresentable.PreviewSource { - nonisolated func connect(to view: PiPHKView) { - Task { - await mixer.addOutput(view) + Task { @MainActor in + self.mtView = view + if isMixerReady { + await mixer.addOutput(view) + } } } } diff --git a/Examples/iOS/VisualEffect.swift b/Examples/iOS/VisualEffect.swift index b4cf0f0a..e20567f6 100644 --- a/Examples/iOS/VisualEffect.swift +++ b/Examples/iOS/VisualEffect.swift @@ -6,12 +6,46 @@ final class MonochromeEffect: VideoEffect { let filter: CIFilter? = CIFilter(name: "CIColorMonochrome") func execute(_ image: CIImage) -> CIImage { - guard let filter: CIFilter = filter else { + guard let filter else { return image } filter.setValue(image, forKey: "inputImage") filter.setValue(CIColor(red: 0.75, green: 0.75, blue: 0.75), forKey: "inputColor") filter.setValue(1.0, forKey: "inputIntensity") - return filter.outputImage! + return filter.outputImage ?? image + } +} + +final class VividEffect: VideoEffect { + let filter: CIFilter? = CIFilter(name: "CIColorControls") + + func execute(_ image: CIImage) -> CIImage { + guard let filter else { + return image + } + filter.setValue(image, forKey: "inputImage") + filter.setValue(1.5, forKey: "inputSaturation") + filter.setValue(1.15, forKey: "inputContrast") + return filter.outputImage ?? image + } +} + +final class WarmEffect: VideoEffect { + let filter: CIFilter? = CIFilter(name: "CITemperatureAndTint") + let controls: CIFilter? = CIFilter(name: "CIColorControls") + + func execute(_ image: CIImage) -> CIImage { + guard let filter, let controls else { + return image + } + filter.setValue(image, forKey: "inputImage") + filter.setValue(CIVector(x: 6500, y: 0), forKey: "inputNeutral") + filter.setValue(CIVector(x: 4000, y: 0), forKey: "inputTargetNeutral") + guard let warmed = filter.outputImage else { return image } + + controls.setValue(warmed, forKey: "inputImage") + controls.setValue(1.1, forKey: "inputSaturation") + controls.setValue(1.05, forKey: "inputContrast") + return controls.outputImage ?? image } }