新增运营区域与飞手认证模块,并完善直播推流就绪流程
将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 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 PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
@ -459,13 +460,17 @@ struct LiveAlbumPreviewView: View {
|
||||
private struct LiveAlbumPreviewPage: View {
|
||||
let file: LiveAlbumFileItem
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var playbackViewModel: LivePlaybackViewModel
|
||||
|
||||
init(file: LiveAlbumFileItem) {
|
||||
self.file = file
|
||||
_playbackViewModel = State(initialValue: LivePlaybackViewModel(urlString: file.url))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if file.isVideo && file.coverImg?.liveTrimmed.isEmpty != false {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 72, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
if file.isVideo {
|
||||
videoContent
|
||||
} else {
|
||||
RemoteImage(urlString: file.previewURL, contentMode: .fit) {
|
||||
ProgressView()
|
||||
@ -487,6 +492,33 @@ private struct LiveAlbumPreviewPage: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(Color.black)
|
||||
.onDisappear {
|
||||
playbackViewModel.release()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var videoContent: some View {
|
||||
if let player = playbackViewModel.player, playbackViewModel.playableURL != nil {
|
||||
VideoPlayer(player: player)
|
||||
.aspectRatio(16.0 / 9.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
} else if file.coverImg?.liveTrimmed.isEmpty == false {
|
||||
RemoteImage(urlString: file.previewURL, contentMode: .fit) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "play.slash.fill")
|
||||
.font(.system(size: 62, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("当前视频地址暂不可播放")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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