将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 Co-authored-by: Cursor <cursoragent@cursor.com>
291 lines
8.4 KiB
Swift
291 lines
8.4 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|
||
}
|