Add Live module with stream management and album flows.
Migrate live stream management and live album from home placeholders, including alive album OSS upload, home routing, and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
519
suixinkan/Features/Live/Views/LiveAlbumViews.swift
Normal file
519
suixinkan/Features/Live/Views/LiveAlbumViews.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user