// // 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 } } }