// // LiveAlbumViews.swift // suixinkan // // Created by Codex on 2026/6/25. // import AVKit 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 { 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: 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 @State private var playbackViewModel: LivePlaybackViewModel init(file: LiveAlbumFileItem) { self.file = file _playbackViewModel = State(initialValue: 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" } }