Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
159
suixinkan_ios/Features/Live/API/LiveAPI.swift
Normal file
159
suixinkan_ios/Features/Live/API/LiveAPI.swift
Normal file
@ -0,0 +1,159 @@
|
||||
//
|
||||
// LiveAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 直播服务协议,定义直播管理和直播相册接口能力。
|
||||
@MainActor
|
||||
protocol LiveServing {
|
||||
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse
|
||||
func liveDetail(liveId: Int) async throws -> LiveEntity
|
||||
func liveCreate(_ request: LiveCreateRequest) async throws
|
||||
func liveStart(liveId: Int) async throws
|
||||
func liveStop(liveId: Int) async throws
|
||||
func liveFinish(liveId: Int) async throws
|
||||
func liveSetPushMode(liveId: Int, mode: Int) async throws
|
||||
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
|
||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws
|
||||
func liveAlbumDeleteFolder(folderId: Int) async throws
|
||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem
|
||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 直播 API,封装手动直播和直播相册网络请求。
|
||||
final class LiveAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化直播 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取直播列表。
|
||||
func liveList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> LiveListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/manual-live/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取直播详情。
|
||||
func liveDetail(liveId: Int) async throws -> LiveEntity {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/manual-live/detail",
|
||||
queryItems: [URLQueryItem(name: "live_id", value: String(liveId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建直播。
|
||||
func liveCreate(_ request: LiveCreateRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/create", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 开始直播。
|
||||
func liveStart(liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/start", body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 暂停直播。
|
||||
func liveStop(liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/stop", body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 结束直播。
|
||||
func liveFinish(liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/finish", body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 切换直播推流模式。
|
||||
func liveSetPushMode(liveId: Int, mode: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/set-push-mode", body: LivePushModeRequest(liveId: liveId, manualPushMode: mode))
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取直播相册列表。
|
||||
func liveAlbumList(
|
||||
scenicId: Int,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
) async throws -> LiveAlbumFolderListResponse {
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
if let startTime = startTime?.liveTrimmed, !startTime.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime = endTime?.liveTrimmed, !endTime.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/view-album/folders", queryItems: queryItems)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建直播相册。
|
||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/view-album/create-folder", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除直播相册。
|
||||
func liveAlbumDeleteFolder(folderId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/view-album/delete-folder", body: LiveAlbumDeleteFolderRequest(folderId: folderId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取直播相册详情。
|
||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/view-album/folder-detail",
|
||||
queryItems: [URLQueryItem(name: "folder_id", value: String(folderId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除直播相册内的素材。
|
||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/view-album/delete-files",
|
||||
body: LiveAlbumDeleteFilesRequest(folderId: folderId, fileIds: fileIds)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveAPI: LiveServing {}
|
||||
27
suixinkan_ios/Features/Live/Live.md
Normal file
27
suixinkan_ios/Features/Live/Live.md
Normal file
@ -0,0 +1,27 @@
|
||||
# 直播模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`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`:进入相册预览时加载相册详情,并支持删除单个素材。
|
||||
|
||||
## 业务边界
|
||||
|
||||
直播详情会从 `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 提交给创建相册接口。
|
||||
415
suixinkan_ios/Features/Live/Models/LiveModels.swift
Normal file
415
suixinkan_ios/Features/Live/Models/LiveModels.swift
Normal file
@ -0,0 +1,415 @@
|
||||
//
|
||||
// LiveModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 直播列表响应实体。
|
||||
struct LiveListResponse: Decodable {
|
||||
let items: [LiveEntity]
|
||||
let total: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case total
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
|
||||
init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) {
|
||||
self.items = items
|
||||
self.total = total
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? []
|
||||
total = try container.liveDecodeLossyInt(forKey: .total) ?? 0
|
||||
page = try container.liveDecodeLossyInt(forKey: .page) ?? 1
|
||||
pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播实体,表示一场手动直播。
|
||||
struct LiveEntity: Decodable, Identifiable, Equatable {
|
||||
let id: Int
|
||||
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
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
let manualPushMode: Int
|
||||
let manualPushState: Int
|
||||
let viewsCount: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
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
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
case manualPushMode = "manaul_push_mode"
|
||||
case manualPushState = "manaul_push_state"
|
||||
case viewsCount = "views_count"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
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,
|
||||
status: Int = 0,
|
||||
statusLabel: String = "",
|
||||
manualPushMode: Int = 1,
|
||||
manualPushState: Int = 0,
|
||||
viewsCount: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
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
|
||||
self.status = status
|
||||
self.statusLabel = statusLabel
|
||||
self.manualPushMode = manualPushMode
|
||||
self.manualPushState = manualPushState
|
||||
self.viewsCount = viewsCount
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
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)
|
||||
status = try container.liveDecodeLossyInt(forKey: .status) ?? 0
|
||||
statusLabel = try container.liveDecodeLossyString(forKey: .statusLabel)
|
||||
manualPushMode = try container.liveDecodeLossyInt(forKey: .manualPushMode) ?? 1
|
||||
manualPushState = try container.liveDecodeLossyInt(forKey: .manualPushState) ?? 0
|
||||
viewsCount = try container.liveDecodeLossyInt(forKey: .viewsCount) ?? 0
|
||||
}
|
||||
|
||||
var displayStatus: String {
|
||||
statusLabel.liveNonEmpty ?? "状态\(status)"
|
||||
}
|
||||
|
||||
var playbackURLCandidates: [String] {
|
||||
[playUrl, pullUrl, hlsUrl, liveUrl, flvUrl]
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播请求实体。
|
||||
struct LiveCreateRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let title: String
|
||||
let coverImg: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case title
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播控制请求实体。
|
||||
struct LiveControlRequest: Encodable, Equatable {
|
||||
let liveId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播推流模式切换请求实体。
|
||||
struct LivePushModeRequest: Encodable, Equatable {
|
||||
let liveId: Int
|
||||
let manualPushMode: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
case manualPushMode = "manual_push_mode"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册列表响应实体。
|
||||
struct LiveAlbumFolderListResponse: Decodable {
|
||||
let items: [LiveAlbumFolderItem]
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let total: Int
|
||||
let totalPages: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
case total
|
||||
case totalPages = "total_pages"
|
||||
}
|
||||
|
||||
init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
|
||||
self.items = items
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
self.total = total
|
||||
self.totalPages = totalPages
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? []
|
||||
page = try container.liveDecodeLossyInt(forKey: .page) ?? 1
|
||||
pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10
|
||||
total = try container.liveDecodeLossyInt(forKey: .total) ?? 0
|
||||
totalPages = try container.liveDecodeLossyInt(forKey: .totalPages) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册文件夹实体。
|
||||
struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
||||
let id: Int
|
||||
let albumId: Int
|
||||
let name: String
|
||||
let creator: String
|
||||
let items: [LiveAlbumFileItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case albumId = "album_id"
|
||||
case name
|
||||
case creator
|
||||
case items
|
||||
}
|
||||
|
||||
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) {
|
||||
self.id = id
|
||||
self.albumId = albumId
|
||||
self.name = name
|
||||
self.creator = creator
|
||||
self.items = items
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
albumId = try container.liveDecodeLossyInt(forKey: .albumId) ?? 0
|
||||
name = try container.liveDecodeLossyString(forKey: .name)
|
||||
creator = try container.liveDecodeLossyString(forKey: .creator)
|
||||
items = (try? container.decode([LiveAlbumFileItem].self, forKey: .items)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册素材实体。
|
||||
struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let url: String
|
||||
let type: Int
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case url
|
||||
case type
|
||||
case size
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
|
||||
init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) {
|
||||
self.id = id
|
||||
self.url = url
|
||||
self.type = type
|
||||
self.size = size
|
||||
self.coverImg = coverImg
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
url = try container.liveDecodeLossyString(forKey: .url)
|
||||
type = try container.liveDecodeLossyInt(forKey: .type) ?? 1
|
||||
size = Int64(try container.liveDecodeLossyInt(forKey: .size) ?? 0)
|
||||
coverImg = try? container.decodeIfPresent(String.self, forKey: .coverImg)
|
||||
}
|
||||
|
||||
var isVideo: Bool { type == 2 }
|
||||
|
||||
var previewURL: String {
|
||||
if let cover = coverImg?.liveTrimmed, !cover.isEmpty {
|
||||
return cover
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播相册请求实体。
|
||||
struct LiveAlbumCreateFolderRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let name: String
|
||||
let items: [LiveAlbumCreateFileItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
case items
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播相册素材请求实体。
|
||||
struct LiveAlbumCreateFileItem: Encodable, Equatable {
|
||||
let url: String
|
||||
let type: Int
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url
|
||||
case type
|
||||
case size
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册请求实体。
|
||||
struct LiveAlbumDeleteFolderRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册素材请求实体。
|
||||
struct LiveAlbumDeleteFilesRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
let fileIds: [Int]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
case fileIds = "file_ids"
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地待上传直播相册素材实体。
|
||||
struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let data: Data
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let size: Int64
|
||||
var uploadedURL: String?
|
||||
|
||||
init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) {
|
||||
self.id = id
|
||||
self.data = data
|
||||
self.fileName = fileName
|
||||
self.fileType = fileType
|
||||
self.size = size ?? Int64(data.count)
|
||||
self.uploadedURL = uploadedURL
|
||||
}
|
||||
|
||||
var isVideo: Bool { fileType == 2 }
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func liveDecodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "1" : "0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func liveDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(trimmed) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(trimmed) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// 直播模块内部使用的去空白字符串。
|
||||
var liveTrimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// 直播模块内部使用的非空字符串兜底。
|
||||
var liveNonEmpty: String? {
|
||||
let value = liveTrimmed
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
//
|
||||
// LiveViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 直播管理页。
|
||||
final class LiveManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = LiveManagementViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "直播管理"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(
|
||||
title: item.title,
|
||||
subtitle: item.statusLabel,
|
||||
detail: String(item.startTime)
|
||||
)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveManagementViewModel: ViewModelBindable {}
|
||||
|
||||
/// 直播相册页。
|
||||
final class LiveAlbumViewController: ModuleTableViewController {
|
||||
private let viewModel = LiveAlbumViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "直播相册"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count) 张")
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveAlbumViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,131 @@
|
||||
//
|
||||
// LivePlaybackViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// 直播播放地址解析器,避免把 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
|
||||
/// 直播播放器 ViewModel,管理系统播放器的 URL、播放和释放状态。
|
||||
final class LivePlaybackViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var state: LivePlaybackState = .empty { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private(set) var player: AVPlayer? { didSet { onChange?() } }
|
||||
|
||||
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,291 @@
|
||||
//
|
||||
// LivePushReadinessViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// 直播推流权限状态。
|
||||
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 {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
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
|
||||
/// 推流准备 ViewModel,检查权限、网络和默认 SDK 可用性。
|
||||
final class LivePushReadinessViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var cameraPermission: LivePushPermissionState = .unknown { didSet { onChange?() } }
|
||||
var microphonePermission: LivePushPermissionState = .unknown { didSet { onChange?() } }
|
||||
var networkState: LivePushNetworkState = .unknown { didSet { onChange?() } }
|
||||
var prepared = false { didSet { onChange?() } }
|
||||
var running = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
let adapterName: String
|
||||
let sdkStatusText: String
|
||||
|
||||
private let permissionProvider: any LivePermissionProviding
|
||||
private let networkMonitor: any LiveNetworkMonitoring
|
||||
private let adapter: any LivePushAdapter
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
466
suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift
Normal file
466
suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift
Normal file
@ -0,0 +1,466 @@
|
||||
//
|
||||
// LiveViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 直播管理 ViewModel,负责直播列表、详情、创建和控制动作。
|
||||
final class LiveManagementViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var items: [LiveEntity] = [] { didSet { onChange?() } }
|
||||
var detail: LiveEntity? { didSet { onChange?() } }
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var loadingMore = false { didSet { onChange?() } }
|
||||
var hasMore = false { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
private var total = 0
|
||||
|
||||
/// 进行中的直播数量。
|
||||
var liveRunningCount: Int {
|
||||
items.filter { $0.status == 2 }.count
|
||||
}
|
||||
|
||||
/// 已结束的直播数量。
|
||||
var liveFinishedCount: Int {
|
||||
items.filter { $0.status == 3 }.count
|
||||
}
|
||||
|
||||
/// 重新加载直播列表。
|
||||
func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if showLoading { loading = true }
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, page: 1)
|
||||
} catch {
|
||||
clearListAndDetail()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页直播列表。
|
||||
func loadMore(api: any LiveServing, scenicId: Int?) async {
|
||||
guard let scenicId, hasMore, !loadingMore else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, page: page + 1)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播并刷新列表。
|
||||
func create(api: any LiveServing, scenicId: Int?, title: String, coverURL: String) async throws {
|
||||
guard let scenicId else {
|
||||
throw LiveValidationError.missingScenic
|
||||
}
|
||||
let normalizedTitle = title.liveTrimmed
|
||||
let normalizedCover = coverURL.liveTrimmed
|
||||
guard !normalizedTitle.isEmpty else {
|
||||
throw LiveValidationError.emptyTitle
|
||||
}
|
||||
guard normalizedCover.liveIsHTTPURL else {
|
||||
throw LiveValidationError.invalidCoverURL
|
||||
}
|
||||
try await api.liveCreate(LiveCreateRequest(scenicId: String(scenicId), title: normalizedTitle, coverImg: normalizedCover))
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
/// 加载直播详情,失败时清空旧详情。
|
||||
func loadDetail(api: any LiveServing, liveId: Int) async {
|
||||
do {
|
||||
detail = try await api.liveDetail(liveId: liveId)
|
||||
} catch {
|
||||
detail = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始或暂停直播,并刷新列表。
|
||||
func control(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws {
|
||||
if item.status == 2 {
|
||||
try await api.liveStop(liveId: item.id)
|
||||
} else if item.status != 3 {
|
||||
try await api.liveStart(liveId: item.id)
|
||||
}
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
/// 结束直播并刷新列表。
|
||||
func finish(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws {
|
||||
try await api.liveFinish(liveId: item.id)
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||
let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize)
|
||||
if page == 1 {
|
||||
items = response.items
|
||||
} else {
|
||||
let incomingById = Dictionary(uniqueKeysWithValues: response.items.map { ($0.id, $0) })
|
||||
let kept = items.filter { incomingById[$0.id] == nil }
|
||||
items = kept + response.items
|
||||
}
|
||||
self.page = page
|
||||
total = response.total
|
||||
hasMore = items.count < total
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
clearListAndDetail()
|
||||
loading = false
|
||||
loadingMore = false
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func clearListAndDetail() {
|
||||
items = []
|
||||
detail = nil
|
||||
page = 1
|
||||
total = 0
|
||||
hasMore = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 直播详情 ViewModel,负责直播详情页动作后的详情刷新。
|
||||
final class LiveDetailViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var detail: LiveEntity { didSet { onChange?() } }
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var actionInFlight = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
init(detail: LiveEntity) {
|
||||
self.detail = detail
|
||||
}
|
||||
|
||||
/// 刷新直播详情。
|
||||
func refresh(api: any LiveServing, showLoading: Bool = true) async {
|
||||
if showLoading { loading = true }
|
||||
defer { loading = false }
|
||||
do {
|
||||
detail = try await api.liveDetail(liveId: detail.id)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始或暂停当前直播。
|
||||
func control(api: any LiveServing) async throws {
|
||||
guard detail.status != 3 else { return }
|
||||
actionInFlight = true
|
||||
defer { actionInFlight = false }
|
||||
if detail.status == 2 {
|
||||
try await api.liveStop(liveId: detail.id)
|
||||
} else {
|
||||
try await api.liveStart(liveId: detail.id)
|
||||
}
|
||||
await refresh(api: api, showLoading: false)
|
||||
}
|
||||
|
||||
/// 结束当前直播。
|
||||
func finish(api: any LiveServing) async throws {
|
||||
guard detail.status != 3 else { return }
|
||||
actionInFlight = true
|
||||
defer { actionInFlight = false }
|
||||
try await api.liveFinish(liveId: detail.id)
|
||||
await refresh(api: api, showLoading: false)
|
||||
}
|
||||
|
||||
/// 切换推流模式。
|
||||
func setPushMode(api: any LiveServing, mode: Int) async throws {
|
||||
guard detail.manualPushMode != mode else { return }
|
||||
actionInFlight = true
|
||||
defer { actionInFlight = false }
|
||||
try await api.liveSetPushMode(liveId: detail.id, mode: mode)
|
||||
await refresh(api: api, showLoading: false)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 直播相册 ViewModel,负责相册列表、筛选、新建和删除。
|
||||
final class LiveAlbumViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var folders: [LiveAlbumFolderItem] = [] { didSet { onChange?() } }
|
||||
var startDate: Date? { didSet { onChange?() } }
|
||||
var endDate: Date? { didSet { onChange?() } }
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var loadingMore = false { didSet { onChange?() } }
|
||||
var hasMore = false { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
private var total = 0
|
||||
|
||||
/// 设置开始时间并校验时间顺序。
|
||||
func setStartDate(_ date: Date) throws {
|
||||
if let endDate, Calendar.current.startOfDay(for: date) > Calendar.current.startOfDay(for: endDate) {
|
||||
throw LiveValidationError.invalidDateRange
|
||||
}
|
||||
startDate = date
|
||||
}
|
||||
|
||||
/// 设置结束时间并校验时间顺序。
|
||||
func setEndDate(_ date: Date) throws {
|
||||
if let startDate, Calendar.current.startOfDay(for: date) < Calendar.current.startOfDay(for: startDate) {
|
||||
throw LiveValidationError.invalidDateRange
|
||||
}
|
||||
endDate = date
|
||||
}
|
||||
|
||||
/// 清除日期筛选。
|
||||
func clearDateFilters() {
|
||||
startDate = nil
|
||||
endDate = nil
|
||||
}
|
||||
|
||||
/// 重新加载直播相册列表。
|
||||
func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if showLoading { loading = true }
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, page: 1)
|
||||
} catch {
|
||||
clearFolders()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页直播相册。
|
||||
func loadMore(api: any LiveServing, scenicId: Int?) async {
|
||||
guard let scenicId, hasMore, !loadingMore else { return }
|
||||
loadingMore = true
|
||||
let nextPage = page + 1
|
||||
defer { loadingMore = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, page: nextPage)
|
||||
} catch {
|
||||
page = max(1, nextPage - 1)
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册并刷新。
|
||||
func deleteFolder(api: any LiveServing, folderId: Int, scenicId: Int?) async throws {
|
||||
try await api.liveAlbumDeleteFolder(folderId: folderId)
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||
let response = try await api.liveAlbumList(
|
||||
scenicId: scenicId,
|
||||
startTime: startDate?.liveDayText,
|
||||
endTime: endDate?.liveDayText,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
if page == 1 {
|
||||
folders = response.items
|
||||
} else {
|
||||
let incomingById = Set(response.items.map(\.id))
|
||||
folders = folders.filter { !incomingById.contains($0.id) } + response.items
|
||||
}
|
||||
self.page = page
|
||||
total = response.total
|
||||
hasMore = folders.count < total
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
clearFolders()
|
||||
loading = false
|
||||
loadingMore = false
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func clearFolders() {
|
||||
folders = []
|
||||
page = 1
|
||||
total = 0
|
||||
hasMore = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 新建直播相册 ViewModel,负责本地素材上传和创建相册。
|
||||
final class LiveAlbumCreateViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var name = "" { didSet { onChange?() } }
|
||||
var localFiles: [LiveAlbumLocalUploadFile] = [] { didSet { onChange?() } }
|
||||
var submitting = false { didSet { onChange?() } }
|
||||
var uploadProgress = 0 { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 添加本地待上传素材。
|
||||
func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) {
|
||||
localFiles.append(contentsOf: files)
|
||||
}
|
||||
|
||||
/// 删除本地待上传素材。
|
||||
func removeLocalFile(id: UUID) {
|
||||
localFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 上传本地素材并创建直播相册。
|
||||
func submit(scenicId: Int?, api: any LiveServing, uploader: any OSSUploadServing) async throws {
|
||||
guard let scenicId else { throw LiveValidationError.missingScenic }
|
||||
let normalizedName = name.liveTrimmed
|
||||
guard !normalizedName.isEmpty else { throw LiveValidationError.emptyAlbumName }
|
||||
guard !localFiles.isEmpty else { throw LiveValidationError.emptyAlbumFiles }
|
||||
guard !submitting else { return }
|
||||
|
||||
submitting = true
|
||||
uploadProgress = 0
|
||||
defer { submitting = false }
|
||||
|
||||
var uploadedItems: [LiveAlbumCreateFileItem] = []
|
||||
let count = max(localFiles.count, 1)
|
||||
do {
|
||||
for index in localFiles.indices {
|
||||
let file = localFiles[index]
|
||||
let url = try await uploader.uploadAliveAlbumFile(
|
||||
data: file.data,
|
||||
fileName: file.fileName,
|
||||
fileType: file.fileType,
|
||||
scenicId: scenicId
|
||||
) { progress in
|
||||
Task { @MainActor in
|
||||
let base = Double(index) / Double(count)
|
||||
let step = Double(progress) / Double(count)
|
||||
self.uploadProgress = min(99, Int((base + step / 100) * 100))
|
||||
}
|
||||
}
|
||||
localFiles[index].uploadedURL = url
|
||||
uploadedItems.append(
|
||||
LiveAlbumCreateFileItem(url: url, type: file.fileType, size: file.size, coverImg: nil)
|
||||
)
|
||||
}
|
||||
try await api.liveAlbumCreateFolder(
|
||||
LiveAlbumCreateFolderRequest(scenicId: String(scenicId), name: normalizedName, items: uploadedItems)
|
||||
)
|
||||
name = ""
|
||||
localFiles = []
|
||||
uploadProgress = 100
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 直播相册预览 ViewModel,负责加载相册详情和删除当前素材。
|
||||
final class LiveAlbumPreviewViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
let folderId: Int
|
||||
var folder: LiveAlbumFolderItem? { didSet { onChange?() } }
|
||||
var files: [LiveAlbumFileItem] = [] { didSet { onChange?() } }
|
||||
var currentIndex: Int { didSet { onChange?() } }
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
|
||||
self.folderId = folderId
|
||||
currentIndex = max(startIndex, 0)
|
||||
folder = summary
|
||||
files = summary?.items ?? []
|
||||
}
|
||||
|
||||
/// 加载相册详情。
|
||||
func load(api: any LiveServing) async {
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
do {
|
||||
let detail = try await api.liveAlbumFolderDetail(folderId: folderId)
|
||||
folder = detail
|
||||
files = detail.items
|
||||
if currentIndex >= files.count {
|
||||
currentIndex = max(files.count - 1, 0)
|
||||
}
|
||||
} catch {
|
||||
files = []
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除当前预览素材并刷新详情。
|
||||
func deleteCurrentFile(api: any LiveServing) async throws {
|
||||
guard files.indices.contains(currentIndex) else { return }
|
||||
let file = files[currentIndex]
|
||||
try await api.liveAlbumDeleteFiles(folderId: folderId, fileIds: [file.id])
|
||||
await load(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播模块校验错误。
|
||||
enum LiveValidationError: LocalizedError, Equatable {
|
||||
case missingScenic
|
||||
case emptyTitle
|
||||
case invalidCoverURL
|
||||
case invalidDateRange
|
||||
case emptyAlbumName
|
||||
case emptyAlbumFiles
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingScenic:
|
||||
"当前账号缺少景区信息"
|
||||
case .emptyTitle:
|
||||
"请输入直播标题"
|
||||
case .invalidCoverURL:
|
||||
"封面图地址需以 http:// 或 https:// 开头"
|
||||
case .invalidDateRange:
|
||||
"开始时间不能大于结束时间"
|
||||
case .emptyAlbumName:
|
||||
"请输入相册名称"
|
||||
case .emptyAlbumFiles:
|
||||
"请上传素材"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
/// 直播接口使用的日期格式。
|
||||
var liveDayText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var liveIsHTTPURL: Bool {
|
||||
let lower = liveTrimmed.lowercased()
|
||||
return lower.hasPrefix("http://") || lower.hasPrefix("https://")
|
||||
}
|
||||
}
|
||||
|
||||
extension Int64 {
|
||||
/// 直播时长展示文案。
|
||||
var liveDurationText: String {
|
||||
let totalSeconds = Swift.max(Int(self), 0)
|
||||
let hours = totalSeconds / 3600
|
||||
let minutes = (totalSeconds % 3600) / 60
|
||||
let seconds = totalSeconds % 60
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user