新增运营区域与飞手认证模块,并完善直播推流就绪流程
将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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 提交给创建相册接口。
|
||||
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播请求实体。
|
||||
|
||||
132
suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift
Normal file
132
suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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