将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
691 lines
28 KiB
Swift
691 lines
28 KiB
Swift
//
|
||
// LiveManagementViews.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import AVKit
|
||
import SwiftUI
|
||
|
||
/// 直播管理首页,展示直播列表和控制入口。
|
||
struct LiveManagementView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.liveAPI) private var liveAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
@StateObject private var viewModel = LiveManagementViewModel()
|
||
@State private var showAddPage = false
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
summarySection
|
||
ScrollView {
|
||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||
if viewModel.items.isEmpty {
|
||
AppContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right")
|
||
.frame(maxWidth: .infinity, minHeight: 280)
|
||
} else {
|
||
ForEach(viewModel.items) { item in
|
||
LiveManagementCard(
|
||
item: item,
|
||
onCopyURL: {
|
||
UIPasteboard.general.string = item.pushUrl
|
||
toastCenter.show("推流地址已复制")
|
||
},
|
||
onControl: { Task { await control(item) } },
|
||
onFinish: { Task { await finish(item) } },
|
||
detail: {
|
||
LiveDetailView(initialDetail: item) {
|
||
Task { await reload(showLoading: false) }
|
||
}
|
||
}
|
||
)
|
||
.onAppear {
|
||
guard item.id == viewModel.items.last?.id else { return }
|
||
Task { await viewModel.loadMore(api: liveAPI, scenicId: accountContext.currentScenic?.id) }
|
||
}
|
||
}
|
||
}
|
||
|
||
if viewModel.loadingMore {
|
||
ProgressView()
|
||
.padding(.vertical, AppMetrics.Spacing.small)
|
||
}
|
||
Spacer(minLength: 80)
|
||
}
|
||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||
}
|
||
Button {
|
||
showAddPage = true
|
||
} label: {
|
||
Label("添加直播", systemImage: "plus.circle.fill")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||
.background(.white)
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle("直播管理")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.refreshable { await reload(showLoading: false) }
|
||
.task(id: accountContext.currentScenic?.id) {
|
||
await reload(showLoading: viewModel.items.isEmpty)
|
||
}
|
||
.navigationDestination(isPresented: $showAddPage) {
|
||
LiveAddView {
|
||
showAddPage = false
|
||
Task { await reload(showLoading: false) }
|
||
}
|
||
}
|
||
}
|
||
|
||
private var summarySection: some View {
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
LiveSummaryChip(title: "总直播", value: "\(viewModel.items.count)", color: AppDesign.primary)
|
||
LiveSummaryChip(title: "进行中", value: "\(viewModel.liveRunningCount)", color: AppDesign.success)
|
||
LiveSummaryChip(title: "已结束", value: "\(viewModel.liveFinishedCount)", color: Color(hex: 0x64748B))
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white)
|
||
}
|
||
|
||
private func reload(showLoading: Bool) async {
|
||
await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
|
||
await viewModel.reload(api: liveAPI, scenicId: accountContext.currentScenic?.id, showLoading: false)
|
||
}
|
||
if let message = viewModel.errorMessage {
|
||
toastCenter.show(message)
|
||
}
|
||
}
|
||
|
||
private func control(_ item: LiveEntity) async {
|
||
do {
|
||
try await viewModel.control(api: liveAPI, item: item, scenicId: accountContext.currentScenic?.id)
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func finish(_ item: LiveEntity) async {
|
||
do {
|
||
try await viewModel.finish(api: liveAPI, item: item, scenicId: accountContext.currentScenic?.id)
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct LiveSummaryChip: View {
|
||
let title: String
|
||
let value: String
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
Text(value)
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
|
||
.foregroundStyle(color)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||
.padding(.vertical, AppMetrics.Spacing.small)
|
||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
}
|
||
|
||
private struct LiveManagementCard<Detail: View>: View {
|
||
let item: LiveEntity
|
||
let onCopyURL: () -> Void
|
||
let onControl: () -> Void
|
||
let onFinish: () -> Void
|
||
@ViewBuilder let detail: () -> Detail
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||
RemoteImage(urlString: item.coverImg, contentMode: .fill) {
|
||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
|
||
.fill(Color(hex: 0xE8EEF8))
|
||
.overlay {
|
||
Image(systemName: "play.rectangle.fill")
|
||
.font(.system(size: 28, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary.opacity(0.75))
|
||
}
|
||
}
|
||
.frame(width: 118, height: 72)
|
||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
Text(item.title.liveNonEmpty ?? "暂无标题")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.lineLimit(1)
|
||
Text("观看:\(item.viewsCount)")
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
Text("时长:\(item.duration.liveDurationText)")
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
Text(item.displayStatus)
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||
.foregroundStyle(statusColor)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
NavigationLink(destination: detail) {
|
||
LiveSmallAction(title: "详情", color: AppDesign.primary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
Button(action: onControl) {
|
||
LiveSmallAction(title: item.status == 2 ? "暂停" : "开始", color: Color(hex: 0xFF7B00))
|
||
}
|
||
.disabled(item.status == 3)
|
||
Button(action: onFinish) {
|
||
LiveSmallAction(title: "结束", color: Color(hex: 0xEF4444))
|
||
}
|
||
.disabled(item.status == 3)
|
||
Button(action: onCopyURL) {
|
||
LiveSmallAction(title: "复制地址", color: AppDesign.success)
|
||
}
|
||
.disabled(item.pushUrl.liveTrimmed.isEmpty)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
private var statusColor: Color {
|
||
switch item.status {
|
||
case 2: AppDesign.success
|
||
case 3: Color(hex: 0x64748B)
|
||
default: AppDesign.primary
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct LiveSmallAction: View {
|
||
let title: String
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||
.foregroundStyle(color)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 32)
|
||
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
}
|
||
|
||
/// 添加直播页面。
|
||
struct LiveAddView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.liveAPI) private var liveAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var title = ""
|
||
@State private var coverURL = ""
|
||
@State private var submitting = false
|
||
let onCreated: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
ScrollView {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
previewSection
|
||
formSection
|
||
}
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
}
|
||
Button {
|
||
Task { await submit() }
|
||
} label: {
|
||
HStack {
|
||
if submitting { ProgressView().tint(.white) }
|
||
Text(submitting ? "创建中..." : "确认添加")
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(!canSubmit)
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
.background(.white)
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle("添加直播")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
private var canSubmit: Bool {
|
||
!submitting && !title.liveTrimmed.isEmpty && coverURL.liveTrimmed.liveIsHTTPURL
|
||
}
|
||
|
||
private var previewSection: some View {
|
||
ZStack(alignment: .bottomLeading) {
|
||
if coverURL.liveTrimmed.liveIsHTTPURL {
|
||
RemoteImage(urlString: coverURL.liveTrimmed, contentMode: .fill) {
|
||
previewPlaceholder
|
||
}
|
||
} else {
|
||
previewPlaceholder
|
||
}
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||
Text("直播封面")
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(0.9))
|
||
Text(title.liveTrimmed.isEmpty ? "输入标题后预览展示效果" : title.liveTrimmed)
|
||
.font(.system(size: AppMetrics.FontSize.title2, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(2)
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(LinearGradient(colors: [.clear, .black.opacity(0.72)], startPoint: .top, endPoint: .bottom))
|
||
}
|
||
.frame(height: 210)
|
||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
private var previewPlaceholder: some View {
|
||
LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||
.overlay {
|
||
Image(systemName: "play.rectangle.fill")
|
||
.font(.system(size: 46, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(0.72))
|
||
}
|
||
}
|
||
|
||
private var formSection: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||
Text("直播信息")
|
||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||
TextField("请输入直播标题", text: $title)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
TextField("请输入 http/https 图片地址", text: $coverURL)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.keyboardType(.URL)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
if !coverURL.liveTrimmed.isEmpty && !coverURL.liveTrimmed.liveIsHTTPURL {
|
||
Text("封面图地址需以 http:// 或 https:// 开头")
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.warning)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
private func submit() async {
|
||
guard canSubmit else { return }
|
||
guard let scenicId = accountContext.currentScenic?.id else {
|
||
toastCenter.show("当前账号缺少景区信息")
|
||
return
|
||
}
|
||
submitting = true
|
||
defer { submitting = false }
|
||
do {
|
||
try await liveAPI.liveCreate(LiveCreateRequest(scenicId: String(scenicId), title: title.liveTrimmed, coverImg: coverURL.liveTrimmed))
|
||
onCreated()
|
||
dismiss()
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 直播详情页面,展示推流信息和控制动作。
|
||
struct LiveDetailView: View {
|
||
@Environment(\.liveAPI) private var liveAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
@StateObject private var viewModel: LiveDetailViewModel
|
||
@StateObject private var playbackViewModel: LivePlaybackViewModel
|
||
@StateObject private var pushReadinessViewModel = LivePushReadinessViewModel()
|
||
@State private var copied = false
|
||
let onChanged: () -> Void
|
||
|
||
init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) {
|
||
_viewModel = StateObject(wrappedValue: LiveDetailViewModel(detail: initialDetail))
|
||
_playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(live: initialDetail))
|
||
self.onChanged = onChanged
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
ScrollView {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
playbackSection
|
||
infoSection
|
||
pushURLSection
|
||
pushModeSection
|
||
pushReadinessSection
|
||
Spacer(minLength: 80)
|
||
}
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
}
|
||
bottomBar
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle("直播信息")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("刷新") {
|
||
Task { await refresh(showLoading: true) }
|
||
}
|
||
.disabled(viewModel.loading || viewModel.actionInFlight)
|
||
}
|
||
}
|
||
.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 playbackSection: some View {
|
||
ZStack(alignment: .bottomLeading) {
|
||
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)
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||
.frame(height: 26)
|
||
.background(statusColor.opacity(0.92), in: Capsule())
|
||
Text(viewModel.detail.title.liveNonEmpty ?? "暂无标题")
|
||
.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)
|
||
.background(LinearGradient(colors: [.clear, .black.opacity(0.72)], startPoint: .top, endPoint: .bottom))
|
||
}
|
||
.frame(height: 210)
|
||
.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 ?? "--")
|
||
LiveInfoRow(title: "状态", value: viewModel.detail.displayStatus)
|
||
LiveInfoRow(title: "直播时长", value: viewModel.detail.duration.liveDurationText)
|
||
LiveInfoRow(title: "观看人次", value: "\(viewModel.detail.viewsCount)")
|
||
}
|
||
}
|
||
|
||
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)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
Button {
|
||
UIPasteboard.general.string = viewModel.detail.pushUrl
|
||
copied = true
|
||
toastCenter.show("推流地址已复制")
|
||
} label: {
|
||
Label(copied ? "已复制推流地址" : "复制推流地址", systemImage: "doc.on.doc")
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.disabled(viewModel.detail.pushUrl.liveTrimmed.isEmpty)
|
||
}
|
||
}
|
||
|
||
private var pushModeSection: some View {
|
||
liveDetailCard("推流模式") {
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
pushModeButton("清晰度优先", mode: 1)
|
||
pushModeButton("流畅度优先", mode: 2)
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
Task { await control() }
|
||
} label: {
|
||
Text(viewModel.detail.status == 2 ? "暂停直播" : "开始直播")
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(viewModel.detail.status == 3 || viewModel.actionInFlight)
|
||
|
||
Button(role: .destructive) {
|
||
Task { await finish() }
|
||
} label: {
|
||
Text("结束直播")
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.disabled(viewModel.detail.status == 3 || viewModel.actionInFlight)
|
||
}
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
.background(.white)
|
||
}
|
||
|
||
private var statusColor: Color {
|
||
switch viewModel.detail.status {
|
||
case 2: AppDesign.success
|
||
case 3: Color(hex: 0x64748B)
|
||
default: AppDesign.primary
|
||
}
|
||
}
|
||
|
||
private func liveDetailCard<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
content()
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
private func pushModeButton(_ title: String, mode: Int) -> some View {
|
||
Button {
|
||
Task { await setPushMode(mode) }
|
||
} label: {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(viewModel.detail.manualPushMode == mode ? .white : AppDesign.textSecondary)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 46)
|
||
.background(viewModel.detail.manualPushMode == mode ? AppDesign.primary : Color(hex: 0xEEF2F7), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
.disabled(viewModel.actionInFlight || viewModel.detail.manualPushMode == mode)
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func refresh(showLoading: Bool) async {
|
||
await globalLoading.withOptionalLoading(showLoading) {
|
||
await viewModel.refresh(api: liveAPI, showLoading: false)
|
||
}
|
||
syncMediaState()
|
||
if let message = viewModel.errorMessage {
|
||
toastCenter.show(message)
|
||
}
|
||
}
|
||
|
||
private func control() async {
|
||
do {
|
||
try await viewModel.control(api: liveAPI)
|
||
onChanged()
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func finish() async {
|
||
do {
|
||
try await viewModel.finish(api: liveAPI)
|
||
onChanged()
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func setPushMode(_ mode: Int) async {
|
||
do {
|
||
try await viewModel.setPushMode(api: liveAPI, mode: mode)
|
||
onChanged()
|
||
} catch {
|
||
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 {
|
||
let title: String
|
||
let value: String
|
||
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
|
||
Text(title)
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
.frame(width: 72, alignment: .leading)
|
||
Text(value)
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.multilineTextAlignment(.trailing)
|
||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var liveIsHTTPURL: Bool {
|
||
let lower = liveTrimmed.lowercased()
|
||
return lower.hasPrefix("http://") || lower.hasPrefix("https://")
|
||
}
|
||
}
|