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:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user