Files
suixinkan_ios_new/suixinkan/Features/Live/Views/LiveAlbumViews.swift
汉秋 26f4d0e671 Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.
Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

552 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// LiveAlbumViews.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import AVKit
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
///
struct LiveAlbumView: View {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.liveAPI) private var liveAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@StateObject 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 {
AppContentUnavailableView("暂无素材", 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 {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.liveAPI) private var liveAPI
@Environment(\.ossUploadService) private var uploadService
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss
@StateObject 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 {
AppContentUnavailableView("未选择素材", 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) private var liveAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: LiveAlbumPreviewViewModel
@State private var showDeleteConfirm = false
init(folderId: Int, startIndex: Int, summary: LiveAlbumFolderItem?) {
_viewModel = StateObject(wrappedValue: LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex, summary: summary))
}
var body: some View {
VStack(spacing: 0) {
if viewModel.files.isEmpty {
AppContentUnavailableView("没有可预览的素材", 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
@EnvironmentObject private var toastCenter: ToastCenter
@StateObject private var playbackViewModel: LivePlaybackViewModel
init(file: LiveAlbumFileItem) {
self.file = file
_playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(urlString: file.url))
}
var body: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
if file.isVideo {
videoContent
} 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)
.onDisappear {
playbackViewModel.release()
}
}
@ViewBuilder
private var videoContent: some View {
if let player = playbackViewModel.player, playbackViewModel.playableURL != nil {
VideoPlayer(player: player)
.aspectRatio(16.0 / 9.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
} else if file.coverImg?.liveTrimmed.isEmpty == false {
RemoteImage(urlString: file.previewURL, contentMode: .fit) {
ProgressView()
.tint(.white)
}
} else {
VStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "play.slash.fill")
.font(.system(size: 62, weight: .semibold))
.foregroundStyle(.white)
Text("当前视频地址暂不可播放")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(.white)
}
}
}
}
/// 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"
}
}