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:
2026-06-25 18:15:59 +08:00
parent fdf4659048
commit 41dda3cc9b
33 changed files with 3455 additions and 37 deletions

View File

@ -5,6 +5,7 @@
// Created by Codex on 2026/6/25.
//
import AVKit
import SwiftUI
///
@ -354,11 +355,14 @@ struct LiveDetailView: View {
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel: LiveDetailViewModel
@State private var playbackViewModel: LivePlaybackViewModel
@State private var pushReadinessViewModel = LivePushReadinessViewModel()
@State private var copied = false
let onChanged: () -> Void
init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) {
_viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail))
_playbackViewModel = State(initialValue: LivePlaybackViewModel(live: initialDetail))
self.onChanged = onChanged
}
@ -366,10 +370,11 @@ struct LiveDetailView: View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
coverSection
playbackSection
infoSection
pushURLSection
pushModeSection
pushReadinessSection
Spacer(minLength: 80)
}
.padding(AppMetrics.Spacing.pageHorizontal)
@ -387,18 +392,32 @@ struct LiveDetailView: View {
.disabled(viewModel.loading || viewModel.actionInFlight)
}
}
.task { await refresh(showLoading: false) }
.task {
pushReadinessViewModel.configure(pushURL: viewModel.detail.pushUrl)
pushReadinessViewModel.startMonitoring()
await pushReadinessViewModel.refreshPermissions()
await refresh(showLoading: false)
}
.onDisappear {
playbackViewModel.release()
Task { await pushReadinessViewModel.dispose() }
}
}
private var coverSection: some View {
private var playbackSection: some View {
ZStack(alignment: .bottomLeading) {
RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) {
LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing)
.overlay {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.system(size: 42, weight: .semibold))
.foregroundStyle(.white.opacity(0.72))
}
if let player = playbackViewModel.player, playbackViewModel.playableURL != nil {
VideoPlayer(player: player)
.background(Color.black)
} else {
RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) {
LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing)
.overlay {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.system(size: 42, weight: .semibold))
.foregroundStyle(.white.opacity(0.72))
}
}
}
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text(viewModel.detail.displayStatus)
@ -411,6 +430,32 @@ struct LiveDetailView: View {
.font(.system(size: AppMetrics.FontSize.title, weight: .bold))
.foregroundStyle(.white)
.lineLimit(2)
Text(playbackHint)
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(.white.opacity(0.86))
.lineLimit(2)
HStack(spacing: AppMetrics.Spacing.small) {
if playbackViewModel.playableURL != nil {
Button {
playbackViewModel.isPlaying ? playbackViewModel.pause() : playbackViewModel.play()
} label: {
Label(playbackViewModel.isPlaying ? "暂停" : "播放", systemImage: playbackViewModel.isPlaying ? "pause.fill" : "play.fill")
}
Button {
playbackViewModel.reload()
} label: {
Label("重载", systemImage: "arrow.clockwise")
}
}
if let url = playbackViewModel.playableURL {
Link(destination: url) {
Label("打开", systemImage: "safari")
}
}
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.buttonStyle(.borderedProminent)
.tint(.white.opacity(0.22))
}
.padding(AppMetrics.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
@ -420,6 +465,16 @@ struct LiveDetailView: View {
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private var playbackHint: String {
if playbackViewModel.playableURL != nil {
return "已检测到可播放地址"
}
if viewModel.detail.pushUrl.liveTrimmed.lowercased().hasPrefix("rtmp://") {
return "当前仅有 RTMP 推流地址,系统播放器不可直接播放"
}
return "暂无可播放直播地址"
}
private var infoSection: some View {
liveDetailCard("直播信息") {
LiveInfoRow(title: "标题", value: viewModel.detail.title.liveNonEmpty ?? "--")
@ -431,6 +486,9 @@ struct LiveDetailView: View {
private var pushURLSection: some View {
liveDetailCard("推流地址") {
Text("该地址用于 OBS 或第三方推流工具;当前版本不在 App 内采集摄像头/麦克风并推流。")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Text(viewModel.detail.pushUrl.liveNonEmpty ?? "--")
.font(.system(size: AppMetrics.FontSize.subheadline))
.textSelection(.enabled)
@ -458,6 +516,40 @@ struct LiveDetailView: View {
}
}
private var pushReadinessSection: some View {
liveDetailCard("推流诊断") {
LiveInfoRow(title: "相机权限", value: pushReadinessViewModel.cameraPermission.displayText)
LiveInfoRow(title: "麦克风权限", value: pushReadinessViewModel.microphonePermission.displayText)
LiveInfoRow(title: "网络状态", value: pushReadinessViewModel.networkState.displayText)
LiveInfoRow(title: "推流 SDK", value: pushReadinessViewModel.adapterName)
LiveInfoRow(title: "SDK 状态", value: pushReadinessViewModel.sdkStatusText)
Text("此处仅做权限、网络和 SDK 接入状态诊断;开始、暂停、结束仍只控制 manual-live 业务状态。")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
if let message = pushReadinessViewModel.errorMessage {
Text(message)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(Color(hex: 0xEF4444))
}
HStack(spacing: AppMetrics.Spacing.small) {
Button {
Task { await pushReadinessViewModel.refreshPermissions() }
} label: {
Label("重新检查", systemImage: "checkmark.shield")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
diagnosePush()
} label: {
Label("检查 SDK", systemImage: "antenna.radiowaves.left.and.right")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
}
}
private var bottomBar: some View {
HStack(spacing: AppMetrics.Spacing.medium) {
Button {
@ -523,6 +615,7 @@ struct LiveDetailView: View {
await globalLoading.withOptionalLoading(showLoading) {
await viewModel.refresh(api: liveAPI, showLoading: false)
}
syncMediaState()
if let message = viewModel.errorMessage {
toastCenter.show(message)
}
@ -554,6 +647,21 @@ struct LiveDetailView: View {
toastCenter.show(error.localizedDescription)
}
}
private func diagnosePush() {
pushReadinessViewModel.configure(pushURL: viewModel.detail.pushUrl)
do {
try pushReadinessViewModel.runDiagnostics()
toastCenter.show("推流诊断通过")
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func syncMediaState() {
playbackViewModel.load(live: viewModel.detail)
pushReadinessViewModel.configure(pushURL: viewModel.detail.pushUrl)
}
}
private struct LiveInfoRow: View {