Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

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

View File

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

View 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))"
}
}