Add OperatingArea and PilotCertification modules with live push readiness.
Migrate operating area and pilot certification from home placeholders, extend Live with playback and push readiness flows, and add pilot certificate OSS upload. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,290 @@
|
||||
//
|
||||
// LivePushReadinessViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
/// 直播推流权限状态。
|
||||
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
|
||||
@Observable
|
||||
/// 推流准备 ViewModel,检查权限、网络和默认 SDK 可用性。
|
||||
final class LivePushReadinessViewModel {
|
||||
var cameraPermission: LivePushPermissionState = .unknown
|
||||
var microphonePermission: LivePushPermissionState = .unknown
|
||||
var networkState: LivePushNetworkState = .unknown
|
||||
var prepared = false
|
||||
var running = false
|
||||
var errorMessage: String?
|
||||
|
||||
let adapterName: String
|
||||
let sdkStatusText: String
|
||||
|
||||
@ObservationIgnored private let permissionProvider: any LivePermissionProviding
|
||||
@ObservationIgnored private let networkMonitor: any LiveNetworkMonitoring
|
||||
@ObservationIgnored private let adapter: any LivePushAdapter
|
||||
@ObservationIgnored 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user