iOS example app UX improvements (#1868)

This commit is contained in:
Peyt Spencer Dewar
2026-01-05 03:38:06 -08:00
committed by GitHub
parent 5b7495a14e
commit f342a6d280
9 changed files with 1311 additions and 306 deletions
+1 -1
View File
@@ -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)
+40 -10
View File
@@ -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)
}
}
}
}
+69 -35
View File
@@ -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"))
)
}
}
}
+28 -3
View File
@@ -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
}
}
+197 -11
View File
@@ -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)
}
}
+115 -9
View File
@@ -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)
}
+532 -139
View File
@@ -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
}
}
}
}
)
}
}
+293 -96
View File
@@ -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<Void, Swift.Error>] = []
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)
}
}
}
}
+36 -2
View File
@@ -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
}
}