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

@ -2,18 +2,26 @@
## 模块职责
`Features/Live` 承接首页 `live_stream_management``live_album` 权限入口,负责手动直播管理和直播相册素材管理。
`Features/Live` 承接首页 `live_stream_management``live_album` 权限入口,负责手动直播管理和直播相册素材管理。旧 iOS 工程未接入真推流 SDK本模块按旧 iOS 对齐,不新增腾讯 TRTC、RTMP 或其他推流 SDK 依赖。
## 代码结构
- `LiveAPI`:封装 `/api/app/manual-live/...``/api/app/view-album/...` 接口。
- `LiveManagementViewModel`:管理直播列表分页、创建、开始/暂停、结束和详情失败清理。
- `LivePlaybackViewModel`:管理直播详情和直播相册视频的系统播放器 URL、播放、暂停和释放状态。
- `LivePushReadinessViewModel`:诊断直播推流地址、相机/麦克风权限、网络状态和当前推流 SDK 接入状态,不触发本机采集推流。
- `LiveAlbumViewModel`:管理直播相册日期筛选、分页和删除相册。
- `LiveAlbumCreateViewModel`:管理本地图片/视频上传到 OSS 后创建直播相册。
- `LiveAlbumPreviewViewModel`:进入相册预览时加载相册详情,并支持删除单个素材。
## 业务边界
本模块不接入播放器或推流 SDK。直播详情展示推流地址、复制和控制能力视频素材预览优先展示接口返回的封面缺少封面时展示视频占位并提供打开或复制原始 URL
直播详情会从 `play_url``pull_url``hls_url``live_url``flv_url` 中筛选系统播放器可处理的 http/https 播放地址;`push_url` 是给 OBS 或第三方工具使用的外部推流地址,不作为播放地址使用。只有 RTMP 推流地址时,页面展示封面、提示和复制入口
直播相册视频预览使用系统 `VideoPlayer` 播放可支持的视频地址;图片仍使用 `RemoteImage` 展示。视频地址不可播放时,页面保留打开和复制原始 URL 的操作。
推流专项按旧 iOS 对齐,只保留诊断层和适配协议,默认 `UnsupportedLivePushAdapter` 明确提示未接入真推流 SDK不引入腾讯/TRTC、Agora、HaishinKit、Zego 或 Android `youfun_control` 的飞控/抓娃娃直播接口。直播详情的“开始/暂停/结束”和“推流模式”只调用 `manual-live` 业务接口,不启动本机摄像头、麦克风或后台推流。
后续若要接入真推流 SDK可参考 Android `youfun_control` 中腾讯 TRTC 的实现路线但需要单独确认后端房间参数、SDK 版本、真机权限、推流源和人工联调范围。
直播封面创建沿用旧 iOS 的 URL 输入。直播相册素材通过 `PhotosPicker` 选择后,使用 `OSSUploadService.uploadAliveAlbumFile` 上传到 `live_albums/yyyyMMdd/scenicId/...`,再把最终 URL 提交给创建相册接口。

View File

@ -43,6 +43,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
let title: String
let coverImg: String
let pushUrl: String
let playUrl: String
let pullUrl: String
let hlsUrl: String
let flvUrl: String
let liveUrl: String
let startTime: Int64
let endTime: Int64
let duration: Int64
@ -57,6 +62,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
case title
case coverImg = "cover_img"
case pushUrl = "push_url"
case playUrl = "play_url"
case pullUrl = "pull_url"
case hlsUrl = "hls_url"
case flvUrl = "flv_url"
case liveUrl = "live_url"
case startTime = "start_time"
case endTime = "end_time"
case duration
@ -72,6 +82,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
title: String = "",
coverImg: String = "",
pushUrl: String = "",
playUrl: String = "",
pullUrl: String = "",
hlsUrl: String = "",
flvUrl: String = "",
liveUrl: String = "",
startTime: Int64 = 0,
endTime: Int64 = 0,
duration: Int64 = 0,
@ -85,6 +100,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
self.title = title
self.coverImg = coverImg
self.pushUrl = pushUrl
self.playUrl = playUrl
self.pullUrl = pullUrl
self.hlsUrl = hlsUrl
self.flvUrl = flvUrl
self.liveUrl = liveUrl
self.startTime = startTime
self.endTime = endTime
self.duration = duration
@ -101,6 +121,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
title = try container.liveDecodeLossyString(forKey: .title)
coverImg = try container.liveDecodeLossyString(forKey: .coverImg)
pushUrl = try container.liveDecodeLossyString(forKey: .pushUrl)
playUrl = try container.liveDecodeLossyString(forKey: .playUrl)
pullUrl = try container.liveDecodeLossyString(forKey: .pullUrl)
hlsUrl = try container.liveDecodeLossyString(forKey: .hlsUrl)
flvUrl = try container.liveDecodeLossyString(forKey: .flvUrl)
liveUrl = try container.liveDecodeLossyString(forKey: .liveUrl)
startTime = Int64(try container.liveDecodeLossyInt(forKey: .startTime) ?? 0)
endTime = Int64(try container.liveDecodeLossyInt(forKey: .endTime) ?? 0)
duration = Int64(try container.liveDecodeLossyInt(forKey: .duration) ?? 0)
@ -114,6 +139,10 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
var displayStatus: String {
statusLabel.liveNonEmpty ?? "状态\(status)"
}
var playbackURLCandidates: [String] {
[playUrl, pullUrl, hlsUrl, liveUrl, flvUrl]
}
}
///

View File

@ -0,0 +1,132 @@
//
// LivePlaybackViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import AVFoundation
import Foundation
import Observation
/// RTMP
enum LivePlaybackURLResolver {
static func playableURL(from live: LiveEntity) -> URL? {
playableURL(from: live.playbackURLCandidates)
}
static func playableURL(from candidates: [String]) -> URL? {
for candidate in candidates {
if let url = playableURL(from: candidate) {
return url
}
}
return nil
}
static func playableURL(from rawValue: String) -> URL? {
let value = rawValue.liveTrimmed
guard !value.isEmpty, let url = URL(string: value) else { return nil }
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil }
if url.pathExtension.lowercased() == "flv" {
return nil
}
return url
}
}
///
enum LivePlaybackState: Equatable {
case empty
case ready(URL)
case playing(URL)
case failed(String)
}
@MainActor
@Observable
/// ViewModel URL
final class LivePlaybackViewModel {
var state: LivePlaybackState = .empty
var errorMessage: String?
@ObservationIgnored private(set) var player: AVPlayer?
var playableURL: URL? {
switch state {
case .ready(let url), .playing(let url):
url
case .empty, .failed:
nil
}
}
var isPlaying: Bool {
if case .playing = state { return true }
return false
}
init(live: LiveEntity? = nil, urlString: String? = nil) {
if let live {
load(live: live)
} else if let urlString {
load(urlString: urlString)
}
}
func load(live: LiveEntity) {
load(url: LivePlaybackURLResolver.playableURL(from: live))
}
func load(urlString: String) {
load(url: LivePlaybackURLResolver.playableURL(from: urlString))
}
func play() {
guard let url = playableURL else { return }
if player == nil {
player = AVPlayer(url: url)
}
player?.play()
state = .playing(url)
}
func pause() {
guard let url = playableURL else { return }
player?.pause()
state = .ready(url)
}
func reload() {
guard let url = playableURL else { return }
releasePlayer()
player = AVPlayer(url: url)
state = .ready(url)
}
func release() {
releasePlayer()
if let url = playableURL {
state = .ready(url)
} else {
state = .empty
}
}
private func load(url: URL?) {
releasePlayer()
guard let url else {
errorMessage = "暂无可播放地址"
state = .empty
return
}
errorMessage = nil
player = AVPlayer(url: url)
state = .ready(url)
}
private func releasePlayer() {
player?.pause()
player = nil
}
}

View File

@ -0,0 +1,290 @@
//
// 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
}
}
}

View File

@ -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)
}
}
}
}

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 {