mirror of
https://github.com/HaishinKit/HaishinKit.swift.git
synced 2026-05-07 20:12:28 +00:00
iOS example app UX improvements (#1868)
This commit is contained in:
committed by
GitHub
parent
5b7495a14e
commit
f342a6d280
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user