Files
suixinkan_ios_new/suixinkan/Features/Live/ViewModels/LivePushReadinessViewModel.swift
汉秋 26f4d0e671 Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.
Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

290 lines
8.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// LivePushReadinessViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import AVFoundation
import Foundation
import Network
import Combine
///
enum LivePushPermissionState: Equatable {
case unknown
case granted
case denied
var displayText: String {
switch self {
case .unknown: "未检查"
case .granted: "已授权"
case .denied: "未授权"
}
}
}
///
enum LivePushNetworkState: Equatable {
case unknown
case unavailable
case wifi
case cellular
case other
var displayText: String {
switch self {
case .unknown: "检测中"
case .unavailable: "网络不可用"
case .wifi: "Wi-Fi"
case .cellular: "蜂窝网络"
case .other: "其他网络"
}
}
}
/// SDK RTMP/RTC SDK
protocol LivePushAdapter {
var name: String { get }
var isAvailable: Bool { get }
func prepare(pushURL: URL) async throws
func start() async throws
func stop() async throws
func dispose() async
}
/// AVFoundation 便
protocol LivePermissionProviding {
func cameraPermission() async -> LivePushPermissionState
func microphonePermission() async -> LivePushPermissionState
}
/// NWPathMonitor 便
protocol LiveNetworkMonitoring: AnyObject {
var currentState: LivePushNetworkState { get }
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void)
func stop()
}
enum LivePushReadinessError: LocalizedError, Equatable {
case missingPushURL
case invalidPushURL
case permissionDenied
case networkUnavailable
case sdkUnavailable
var errorDescription: String? {
switch self {
case .missingPushURL:
"暂无推流地址"
case .invalidPushURL:
"推流地址格式无效"
case .permissionDenied:
"请先开启相机和麦克风权限"
case .networkUnavailable:
"当前网络不可用"
case .sdkUnavailable:
"当前版本未接入真推流 SDK"
}
}
}
struct UnsupportedLivePushAdapter: LivePushAdapter {
let name = "未接入推流 SDK"
let isAvailable = false
func prepare(pushURL: URL) async throws {
throw LivePushReadinessError.sdkUnavailable
}
func start() async throws {
throw LivePushReadinessError.sdkUnavailable
}
func stop() async throws {}
func dispose() async {}
}
struct SystemLivePermissionProvider: LivePermissionProviding {
func cameraPermission() async -> LivePushPermissionState {
await permission(for: .video)
}
func microphonePermission() async -> LivePushPermissionState {
await permission(for: .audio)
}
private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState {
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
return .granted
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: mediaType)
return granted ? .granted : .denied
case .denied, .restricted:
return .denied
@unknown default:
return .denied
}
}
}
final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.suixinkan.live.network")
private(set) var currentState: LivePushNetworkState = .unknown
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
monitor.pathUpdateHandler = { [weak self] path in
let state = Self.state(from: path)
self?.currentState = state
onChange(state)
}
monitor.start(queue: queue)
}
func stop() {
monitor.cancel()
}
private static func state(from path: NWPath) -> LivePushNetworkState {
guard path.status == .satisfied else { return .unavailable }
if path.usesInterfaceType(.wifi) { return .wifi }
if path.usesInterfaceType(.cellular) { return .cellular }
return .other
}
}
@MainActor
/// ViewModel SDK
final class LivePushReadinessViewModel: ObservableObject {
@Published var cameraPermission: LivePushPermissionState = .unknown
@Published var microphonePermission: LivePushPermissionState = .unknown
@Published var networkState: LivePushNetworkState = .unknown
@Published var prepared = false
@Published var running = false
@Published var errorMessage: String?
let adapterName: String
let sdkStatusText: String
private let permissionProvider: any LivePermissionProviding
private let networkMonitor: any LiveNetworkMonitoring
private let adapter: any LivePushAdapter
@Published private var pushURL: URL?
init(
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(),
adapter: any LivePushAdapter = UnsupportedLivePushAdapter()
) {
self.permissionProvider = permissionProvider
self.networkMonitor = networkMonitor
self.adapter = adapter
self.adapterName = adapter.name
self.sdkStatusText = adapter.isAvailable ? "已接入" : "未接入真推流 SDK"
self.networkState = networkMonitor.currentState
}
func configure(pushURL rawValue: String) {
let value = rawValue.liveTrimmed
guard !value.isEmpty else {
pushURL = nil
errorMessage = LivePushReadinessError.missingPushURL.localizedDescription
return
}
guard let url = URL(string: value), url.scheme?.isEmpty == false else {
pushURL = nil
errorMessage = LivePushReadinessError.invalidPushURL.localizedDescription
return
}
pushURL = url
errorMessage = nil
}
func startMonitoring() {
networkState = networkMonitor.currentState
networkMonitor.start { [weak self] state in
Task { @MainActor in
self?.networkState = state
}
}
}
func stopMonitoring() {
networkMonitor.stop()
}
func refreshPermissions() async {
async let camera = permissionProvider.cameraPermission()
async let microphone = permissionProvider.microphonePermission()
cameraPermission = await camera
microphonePermission = await microphone
}
func runDiagnostics() throws {
do {
try validateReadiness()
} catch {
errorMessage = error.localizedDescription
throw error
}
guard adapter.isAvailable else {
errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription
throw LivePushReadinessError.sdkUnavailable
}
errorMessage = nil
}
func prepare() async throws {
try validateReadiness()
guard let pushURL else { throw LivePushReadinessError.missingPushURL }
do {
try await adapter.prepare(pushURL: pushURL)
prepared = true
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
throw error
}
}
func startPush() async throws {
try validateReadiness()
guard adapter.isAvailable else {
errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription
throw LivePushReadinessError.sdkUnavailable
}
try await adapter.start()
running = true
prepared = true
errorMessage = nil
}
func stopPush() async {
try? await adapter.stop()
running = false
}
func dispose() async {
stopMonitoring()
await adapter.dispose()
running = false
prepared = false
}
private func validateReadiness() throws {
guard pushURL != nil else {
throw LivePushReadinessError.missingPushURL
}
guard cameraPermission != .denied, microphonePermission != .denied else {
throw LivePushReadinessError.permissionDenied
}
guard networkState != .unavailable else {
throw LivePushReadinessError.networkUnavailable
}
}
}