新增直播模块,包含推流管理与相册流程

将直播推流管理与直播相册从首页占位页迁移为完整实现,包含 alive album OSS 上传、首页路由与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 14:38:52 +08:00
parent c39c3d3c75
commit fcb692b56a
23 changed files with 2811 additions and 11 deletions

View File

@ -0,0 +1,519 @@
//
// LiveAlbumViews.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
///
struct LiveAlbumView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(LiveAPI.self) private var liveAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel = LiveAlbumViewModel()
@State private var showStartPicker = false
@State private var showEndPicker = false
@State private var showCreatePage = false
@State private var deleteTarget: LiveAlbumFolderItem?
var body: some View {
VStack(spacing: 0) {
filterSection
ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.folders.isEmpty {
ContentUnavailableView("暂无素材", systemImage: "photo.stack")
.frame(maxWidth: .infinity, minHeight: 300)
} else {
ForEach(viewModel.folders) { folder in
LiveAlbumFolderCard(
folder: folder,
onDelete: { deleteTarget = folder },
preview: { file in
let startIndex = folder.items.firstIndex(of: file) ?? 0
LiveAlbumPreviewView(folderId: folder.id, startIndex: startIndex, summary: folder)
}
)
.onAppear {
guard folder.id == viewModel.folders.last?.id else { return }
Task { await viewModel.loadMore(api: liveAPI, scenicId: accountContext.currentScenic?.id) }
}
}
}
if viewModel.loadingMore {
ProgressView()
}
Spacer(minLength: 80)
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
Button {
showCreatePage = true
} label: {
Label("上传素材", systemImage: "square.and.arrow.up")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
.background(.white)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("直播相册")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await reload(showLoading: false) }
.task(id: accountContext.currentScenic?.id) {
await reload(showLoading: viewModel.folders.isEmpty)
}
.sheet(isPresented: $showStartPicker) {
LiveDatePickerSheet(title: "开始时间", initialDate: viewModel.startDate ?? Date()) { date in
applyStartDate(date)
}
}
.sheet(isPresented: $showEndPicker) {
LiveDatePickerSheet(title: "结束时间", initialDate: viewModel.endDate ?? Date()) { date in
applyEndDate(date)
}
}
.navigationDestination(isPresented: $showCreatePage) {
LiveAlbumCreateView {
showCreatePage = false
Task { await reload(showLoading: false) }
}
}
.confirmationDialog("删除相册", isPresented: deleteDialogBinding, presenting: deleteTarget) { folder in
Button("删除", role: .destructive) {
Task { await deleteFolder(folder) }
}
Button("取消", role: .cancel) {}
} message: { _ in
Text("确认删除该相册?删除后不可恢复。")
}
}
private var filterSection: some View {
HStack(spacing: AppMetrics.Spacing.small) {
filterButton(title: viewModel.startDate?.liveDayText ?? "开始时间") {
showStartPicker = true
}
filterButton(title: viewModel.endDate?.liveDayText ?? "结束时间") {
showEndPicker = true
}
Button {
viewModel.clearDateFilters()
Task { await reload(showLoading: true) }
} label: {
Image(systemName: "arrow.counterclockwise")
.frame(width: 36, height: 36)
}
.buttonStyle(.bordered)
.accessibilityLabel("重置筛选")
}
.padding(AppMetrics.Spacing.medium)
.background(.white)
}
private var deleteDialogBinding: Binding<Bool> {
Binding(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }
)
}
private func filterButton(title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Label(title, systemImage: "calendar")
.font(.system(size: AppMetrics.FontSize.caption))
.lineLimit(1)
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.bordered)
}
private func applyStartDate(_ date: Date) {
do {
try viewModel.setStartDate(date)
Task { await reload(showLoading: true) }
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func applyEndDate(_ date: Date) {
do {
try viewModel.setEndDate(date)
Task { await reload(showLoading: true) }
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
await viewModel.reload(api: liveAPI, scenicId: accountContext.currentScenic?.id, showLoading: false)
}
if let message = viewModel.errorMessage {
toastCenter.show(message)
}
}
private func deleteFolder(_ folder: LiveAlbumFolderItem) async {
do {
try await viewModel.deleteFolder(api: liveAPI, folderId: folder.id, scenicId: accountContext.currentScenic?.id)
deleteTarget = nil
toastCenter.show("相册已删除")
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
private struct LiveAlbumFolderCard<Preview: View>: View {
let folder: LiveAlbumFolderItem
let onDelete: () -> Void
@ViewBuilder let preview: (LiveAlbumFileItem) -> Preview
var body: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(folder.name.liveNonEmpty ?? "暂无标题")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
if !folder.creator.liveTrimmed.isEmpty {
Text(folder.creator)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
}
Spacer()
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash")
.frame(width: 34, height: 34)
}
}
if folder.items.isEmpty {
Text("暂无素材")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.frame(maxWidth: .infinity, minHeight: 72)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
} else {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppMetrics.Spacing.small) {
ForEach(folder.items) { file in
NavigationLink(destination: preview(file)) {
LiveAlbumThumb(file: file)
}
.buttonStyle(.plain)
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
private struct LiveAlbumThumb: View {
let file: LiveAlbumFileItem
var body: some View {
ZStack(alignment: .bottomTrailing) {
if file.isVideo && file.coverImg?.liveTrimmed.isEmpty != false {
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
.fill(Color(hex: 0x111827))
.overlay {
Image(systemName: "play.circle.fill")
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(.white)
}
} else {
RemoteImage(urlString: file.previewURL, contentMode: .fill) {
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
.fill(Color(hex: 0xEEF2FF))
}
}
if file.isVideo {
Image(systemName: "play.circle.fill")
.font(.system(size: 18))
.foregroundStyle(.white)
.padding(6)
}
}
.frame(height: 108)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
}
}
private struct LiveDatePickerSheet: View {
@Environment(\.dismiss) private var dismiss
let title: String
@State var initialDate: Date
let onConfirm: (Date) -> Void
var body: some View {
NavigationStack {
DatePicker("", selection: $initialDate, displayedComponents: .date)
.datePickerStyle(.graphical)
.padding()
.navigationTitle(title)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("确定") {
onConfirm(initialDate)
dismiss()
}
}
}
}
}
}
/// /
struct LiveAlbumCreateView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(LiveAPI.self) private var liveAPI
@Environment(OSSUploadService.self) private var uploadService
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.dismiss) private var dismiss
@State private var viewModel = LiveAlbumCreateViewModel()
@State private var pickerItems: [PhotosPickerItem] = []
let onCreated: () -> Void
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
TextField("请输入相册名称", text: $viewModel.name)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
PhotosPicker(selection: $pickerItems, maxSelectionCount: 20, matching: .any(of: [.images, .videos])) {
Label("选择图片或视频", systemImage: "photo.on.rectangle.angled")
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
}
.buttonStyle(.bordered)
localFileGrid
if viewModel.submitting {
ProgressView(value: Double(viewModel.uploadProgress), total: 100)
}
}
.padding(AppMetrics.Spacing.pageHorizontal)
}
Button {
Task { await submit() }
} label: {
Text(viewModel.submitting ? "提交中..." : "保存相册")
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.submitting)
.padding(AppMetrics.Spacing.pageHorizontal)
.background(.white)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("上传素材")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: pickerItems) { _, newItems in
Task { await importPickerItems(newItems) }
}
}
@ViewBuilder
private var localFileGrid: some View {
if viewModel.localFiles.isEmpty {
ContentUnavailableView("未选择素材", systemImage: "photo")
.frame(maxWidth: .infinity, minHeight: 180)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
} else {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.localFiles) { file in
ZStack(alignment: .topTrailing) {
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
.fill(file.isVideo ? Color(hex: 0x111827) : Color(hex: 0xEEF2FF))
.frame(height: 108)
.overlay {
VStack(spacing: 6) {
Image(systemName: file.isVideo ? "video.fill" : "photo.fill")
Text(file.fileName)
.font(.system(size: AppMetrics.FontSize.caption))
.lineLimit(1)
}
.foregroundStyle(file.isVideo ? .white : AppDesign.textSecondary)
.padding(8)
}
Button {
viewModel.removeLocalFile(id: file.id)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundStyle(Color(hex: 0xEF4444))
.padding(6)
}
}
}
}
}
}
private func importPickerItems(_ items: [PhotosPickerItem]) async {
let files = await LiveAlbumPickerLoader.loadFiles(from: items)
viewModel.addLocalFiles(files)
pickerItems = []
}
private func submit() async {
do {
try await viewModel.submit(scenicId: accountContext.currentScenic?.id, api: liveAPI, uploader: uploadService)
toastCenter.show("相册已创建")
onCreated()
dismiss()
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
///
struct LiveAlbumPreviewView: View {
@Environment(LiveAPI.self) private var liveAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.dismiss) private var dismiss
@State private var viewModel: LiveAlbumPreviewViewModel
@State private var showDeleteConfirm = false
init(folderId: Int, startIndex: Int, summary: LiveAlbumFolderItem?) {
_viewModel = State(initialValue: LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex, summary: summary))
}
var body: some View {
VStack(spacing: 0) {
if viewModel.files.isEmpty {
ContentUnavailableView("没有可预览的素材", systemImage: "photo")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
} else {
TabView(selection: $viewModel.currentIndex) {
ForEach(Array(viewModel.files.enumerated()), id: \.offset) { index, file in
LiveAlbumPreviewPage(file: file)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.background(Color.black)
}
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Text("删除素材")
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.files.isEmpty)
.padding(AppMetrics.Spacing.pageHorizontal)
.background(Color.black)
}
.navigationTitle("预览")
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.task { await viewModel.load(api: liveAPI) }
.confirmationDialog("删除素材", isPresented: $showDeleteConfirm) {
Button("删除", role: .destructive) {
Task { await deleteCurrentFile() }
}
Button("取消", role: .cancel) {}
} message: {
Text("确认删除该素材?删除后不可恢复。")
}
}
private func deleteCurrentFile() async {
do {
try await viewModel.deleteCurrentFile(api: liveAPI)
toastCenter.show("素材已删除")
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
private struct LiveAlbumPreviewPage: View {
let file: LiveAlbumFileItem
@Environment(ToastCenter.self) private var toastCenter
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)
} else {
RemoteImage(urlString: file.previewURL, contentMode: .fit) {
ProgressView()
.tint(.white)
}
}
if file.isVideo {
if let url = URL(string: file.url) {
Link("打开视频地址", destination: url)
.foregroundStyle(.white)
}
Button("复制视频地址") {
UIPasteboard.general.string = file.url
toastCenter.show("视频地址已复制")
}
.foregroundStyle(.white)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(AppMetrics.Spacing.medium)
.background(Color.black)
}
}
/// PhotosPicker
enum LiveAlbumPickerLoader {
static func loadFiles(from items: [PhotosPickerItem]) async -> [LiveAlbumLocalUploadFile] {
var files: [LiveAlbumLocalUploadFile] = []
for item in items {
guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
let fileType = item.supportedContentTypes.contains(where: { $0.conforms(to: .movie) }) ? 2 : 1
let ext = preferredExtension(for: item, fileType: fileType)
files.append(
LiveAlbumLocalUploadFile(
data: data,
fileName: "live_album_\(UUID().uuidString.replacingOccurrences(of: "-", with: "")).\(ext)",
fileType: fileType
)
)
}
return files
}
private static func preferredExtension(for item: PhotosPickerItem, fileType: Int) -> String {
if let type = item.supportedContentTypes.first(where: { fileType == 2 ? $0.conforms(to: .movie) : $0.conforms(to: .image) }),
let ext = type.preferredFilenameExtension {
return ext
}
return fileType == 2 ? "mp4" : "jpg"
}
}

View File

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