// // LiveAlbumViewControllers.swift // suixinkan // import Kingfisher import PhotosUI import SnapKit import UIKit /// 直播相册列表页,对齐 Android `AliveAlbumScreen`。 final class LiveAlbumListViewController: BaseViewController, UITableViewDelegate { private enum Section { case main } private let viewModel = LiveAlbumListViewModel() private let liveAPI: any LiveServing private let filterCard = UIView() private let startButton = UIButton(type: .system) private let endButton = UIButton(type: .system) private let tableView = UITableView(frame: .zero, style: .plain) private let refreshControl = UIRefreshControl() private let emptyLabel = UILabel() private let loadingIndicator = UIActivityIndicatorView(style: .medium) private let bottomBar = UIView() private let uploadButton = AppButton(title: "上传素材") private var dataSource: UITableViewDiffableDataSource! /// 初始化直播相册列表页。 @MainActor init(liveAPI: (any LiveServing)? = nil) { self.liveAPI = liveAPI ?? NetworkServices.shared.liveAPI super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "直播相册" } override func setupUI() { view.backgroundColor = AppColor.pageBackground filterCard.backgroundColor = .white filterCard.layer.cornerRadius = 12 configureDateButton(startButton, title: "开始时间") configureDateButton(endButton, title: "结束时间") tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.delegate = self tableView.refreshControl = refreshControl tableView.register(LiveAlbumFolderCell.self, forCellReuseIdentifier: LiveAlbumFolderCell.reuseIdentifier) tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, folder in guard let cell = tableView.dequeueReusableCell(withIdentifier: LiveAlbumFolderCell.reuseIdentifier, for: indexPath) as? LiveAlbumFolderCell else { return UITableViewCell() } cell.apply(folder) cell.onDelete = { [weak self] in self?.confirmDelete(folder) } cell.onImageSelected = { [weak self] index in self?.navigationController?.pushViewController( LiveAlbumPreviewViewController(folderId: folder.id, startIndex: index), animated: true ) } return cell } emptyLabel.text = "暂无素材" emptyLabel.font = .systemFont(ofSize: 14) emptyLabel.textColor = UIColor(hex: 0x4B5563) emptyLabel.isHidden = true loadingIndicator.hidesWhenStopped = true loadingIndicator.color = AppColor.primary bottomBar.backgroundColor = .white view.addSubview(filterCard) filterCard.addSubview(startButton) filterCard.addSubview(endButton) view.addSubview(tableView) view.addSubview(emptyLabel) view.addSubview(loadingIndicator) view.addSubview(bottomBar) bottomBar.addSubview(uploadButton) } override func setupConstraints() { filterCard.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.height.equalTo(59) } startButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.height.equalTo(35) } endButton.snp.makeConstraints { make in make.leading.equalTo(startButton.snp.trailing).offset(8) make.trailing.equalToSuperview().inset(12) make.centerY.width.height.equalTo(startButton) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } uploadButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(48) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } tableView.snp.makeConstraints { make in make.top.equalTo(filterCard.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } emptyLabel.snp.makeConstraints { make in make.center.equalTo(tableView) } loadingIndicator.snp.makeConstraints { make in make.center.equalTo(tableView) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) startButton.addTarget(self, action: #selector(selectStartDate), for: .touchUpInside) endButton.addTarget(self, action: #selector(selectEndDate), for: .touchUpInside) uploadButton.addTarget(self, action: #selector(openUpload), for: .touchUpInside) } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.loadInitial(api: liveAPI) } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) Task { await viewModel.refresh(api: liveAPI) } } func scrollViewDidScroll(_ scrollView: UIScrollView) { guard let last = tableView.indexPathsForVisibleRows?.map(\.row).max() else { return } Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: last, api: liveAPI) } } @objc private func refreshTriggered() { Task { await viewModel.refresh(api: liveAPI) } } @objc private func selectStartDate() { presentDatePicker(initialDate: viewModel.startDate ?? Date()) { [weak self] date in guard let self else { return } Task { await self.viewModel.setStartDate(date, api: self.liveAPI) } } } @objc private func selectEndDate() { presentDatePicker(initialDate: viewModel.endDate ?? Date()) { [weak self] date in guard let self else { return } Task { await self.viewModel.setEndDate(date, api: self.liveAPI) } } } @objc private func openUpload() { navigationController?.pushViewController(LiveAlbumAddViewController(), animated: true) } private func applyViewModel() { let startText = LiveUIFormatter.date(viewModel.startDate) let endText = LiveUIFormatter.date(viewModel.endDate) setDateButtonTitle(startButton, text: startText.isEmpty ? "开始时间" : startText) setDateButtonTitle(endButton, text: endText.isEmpty ? "结束时间" : endText) if viewModel.isRefreshing { if !refreshControl.isRefreshing { refreshControl.beginRefreshing() } } else { refreshControl.endRefreshing() } viewModel.isLoading && viewModel.folders.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() emptyLabel.isHidden = viewModel.isLoading || !viewModel.folders.isEmpty var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(viewModel.folders) dataSource.apply(snapshot, animatingDifferences: true) } private func confirmDelete(_ folder: LiveAlbumFolder) { showAlert(title: "确认删除", message: "是否确认删除该相册?删除后将无法恢复。") { [weak self] in guard let self else { return } Task { await self.viewModel.deleteFolder(folder, api: self.liveAPI) } } } private func configureDateButton(_ button: UIButton, title: String) { button.backgroundColor = UIColor(hex: 0xF9FAFB) button.layer.cornerRadius = 4 button.setTitleColor(AppColor.textPrimary, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 13) button.setImage(UIImage(systemName: "calendar"), for: .normal) button.tintColor = AppColor.textPrimary button.setConfigurationImagePlacement(.leading, padding: 4) setDateButtonTitle(button, text: title) } private func setDateButtonTitle(_ button: UIButton, text: String) { button.setTitle(text, for: .normal) } private func presentDatePicker(initialDate: Date, onConfirm: @escaping (Date) -> Void) { let alert = UIAlertController(title: nil, message: "\n\n\n\n\n\n\n\n", preferredStyle: .actionSheet) let picker = UIDatePicker() picker.datePickerMode = .date picker.preferredDatePickerStyle = .wheels picker.locale = Locale(identifier: "zh_CN") picker.date = initialDate alert.view.addSubview(picker) picker.snp.makeConstraints { make in make.top.equalToSuperview().offset(8) make.leading.trailing.equalToSuperview().inset(8) make.height.equalTo(216) } alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in onConfirm(picker.date) }) present(alert, animated: true) } } /// 直播相册文件夹 Cell,对齐 Android `AliveAlbumFolderView`。 private final class LiveAlbumFolderCell: UITableViewCell { static let reuseIdentifier = "LiveAlbumFolderCell" var onDelete: (() -> Void)? var onImageSelected: ((Int) -> Void)? private let container = UIView() private let titleLabel = UILabel() private let deleteButton = UIButton(type: .system) private let gridStack = UIStackView() private var imageViews: [LiveCoverImageView] = [] override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setup() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() onDelete = nil onImageSelected = nil imageViews.forEach { $0.removeFromSuperview() } imageViews.removeAll() gridStack.arrangedSubviews.forEach { $0.removeFromSuperview() } } /// 应用直播相册文件夹。 func apply(_ folder: LiveAlbumFolder) { titleLabel.text = folder.displayName buildGrid(items: folder.items) } private func setup() { selectionStyle = .none backgroundColor = .clear container.backgroundColor = .white titleLabel.font = .systemFont(ofSize: 16, weight: .medium) titleLabel.textColor = AppColor.textPrimary deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) deleteButton.tintColor = AppColor.danger gridStack.axis = .vertical gridStack.spacing = 12 contentView.addSubview(container) container.addSubview(titleLabel) container.addSubview(deleteButton) container.addSubview(gridStack) container.snp.makeConstraints { make in make.top.bottom.equalToSuperview() make.leading.trailing.equalToSuperview() } titleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(16) make.trailing.lessThanOrEqualTo(deleteButton.snp.leading).offset(-8) } deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(8) make.size.equalTo(44) } gridStack.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(12) make.bottom.equalToSuperview().inset(12) } deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } private func buildGrid(items: [LiveAlbumFile]) { let rows = stride(from: 0, to: items.count, by: 2).map { start in Array(items[start ..< min(start + 2, items.count)]) } for (rowIndex, rowItems) in rows.enumerated() { let rowStack = UIStackView() rowStack.axis = .horizontal rowStack.spacing = 12 rowStack.distribution = .fillEqually gridStack.addArrangedSubview(rowStack) for (columnIndex, item) in rowItems.enumerated() { let imageView = LiveCoverImageView(cornerRadius: 12) imageView.setURL(item.url) imageView.isUserInteractionEnabled = true imageView.tag = rowIndex * 2 + columnIndex imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:)))) rowStack.addArrangedSubview(imageView) imageViews.append(imageView) imageView.snp.makeConstraints { make in make.height.equalTo(imageView.snp.width).multipliedBy(9.0 / 16.0) } } if rowItems.count == 1 { rowStack.addArrangedSubview(UIView()) } } } @objc private func deleteTapped() { onDelete?() } @objc private func imageTapped(_ recognizer: UITapGestureRecognizer) { guard let view = recognizer.view else { return } onImageSelected?(view.tag) } } /// 上传直播相册素材页,对齐 Android `AliveAlbumAddScreen`。 final class LiveAlbumAddViewController: BaseViewController, PHPickerViewControllerDelegate { private let viewModel = LiveAlbumAddViewModel() private let liveAPI: any LiveServing private let uploader: any LiveOSSUploading private let scrollView = UIScrollView() private let cardView = UIView() private let stackView = UIStackView() private let titleField = UITextField() private let uploadView = LiveDashedUploadView(title: "点击上传图片", subtitle: "支持 jpg、png、mp4 格式") private let filesStack = UIStackView() private let bottomBar = UIView() private let confirmButton = AppButton(title: "确认") /// 初始化上传直播相册素材页。 @MainActor init( liveAPI: (any LiveServing)? = nil, uploader: (any LiveOSSUploading)? = nil ) { self.liveAPI = liveAPI ?? NetworkServices.shared.liveAPI self.uploader = uploader ?? NetworkServices.shared.ossUploadService super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "上传素材" } override func setupUI() { view.backgroundColor = AppColor.pageBackground cardView.backgroundColor = .white cardView.layer.cornerRadius = 12 stackView.axis = .vertical stackView.spacing = 8 filesStack.axis = .vertical filesStack.spacing = 12 titleField.placeholder = "请输入作品名称" titleField.font = .systemFont(ofSize: 14) titleField.layer.borderWidth = 1 titleField.layer.borderColor = AppColor.border.cgColor titleField.layer.cornerRadius = 8 titleField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) titleField.leftViewMode = .always bottomBar.backgroundColor = .white view.addSubview(scrollView) scrollView.addSubview(cardView) cardView.addSubview(stackView) addRequiredTitle("直播标题", to: stackView) stackView.addArrangedSubview(titleField) addRequiredTitle("封面图片", suffix: "(单次最多可上传 20 张图片)", to: stackView) stackView.addArrangedSubview(uploadView) stackView.addArrangedSubview(filesStack) view.addSubview(bottomBar) bottomBar.addSubview(confirmButton) } override func setupConstraints() { bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } confirmButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(48) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalTo(bottomBar.snp.top) } cardView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) make.width.equalTo(scrollView).offset(-32) } stackView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } titleField.snp.makeConstraints { make in make.height.equalTo(44) } uploadView.snp.makeConstraints { make in make.height.equalTo(120) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } viewModel.onCreateSuccess = { [weak self] in Task { @MainActor in self?.navigationController?.popViewController(animated: true) } } titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged) uploadView.addTarget(self, action: #selector(pickImage), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let result = results.first else { return } Task { do { let media = try await LivePhotoPickerLoader.loadImage(from: result) await viewModel.addLocalMedia(media, uploader: uploader) } catch { showToast(error.localizedDescription) } } } @objc private func titleChanged() { viewModel.updateTitle(titleField.text ?? "") if titleField.text != viewModel.title { titleField.text = viewModel.title } } @objc private func pickImage() { var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.filter = .images configuration.selectionLimit = 1 let picker = PHPickerViewController(configuration: configuration) picker.delegate = self present(picker, animated: true) } @objc private func confirmTapped() { Task { await viewModel.create(api: liveAPI) } } private func applyViewModel() { viewModel.isLoading ? showLoading() : hideLoading() filesStack.arrangedSubviews.forEach { $0.removeFromSuperview() } for (index, file) in viewModel.files.enumerated() { let view = LiveAlbumUploadedFileView(file: file) view.onDelete = { [weak self] in self?.confirmDeleteLocalFile(at: index) } filesStack.addArrangedSubview(view) view.snp.makeConstraints { make in make.height.equalTo(view.snp.width).multipliedBy(9.0 / 16.0) } } } private func confirmDeleteLocalFile(at index: Int) { showAlert(title: "确认删除", message: "是否确认删除该相册?删除后将无法恢复。") { [weak self] in self?.viewModel.deleteLocalFile(at: index) } } private func addRequiredTitle(_ title: String, suffix: String? = nil, to stack: UIStackView) { let label = UILabel() let text = NSMutableAttributedString( string: "\(title) *", attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .medium), .foregroundColor: AppColor.textPrimary] ) text.addAttribute(.foregroundColor, value: UIColor.red, range: NSRange(location: title.count + 1, length: 1)) if let suffix { text.append(NSAttributedString(string: suffix, attributes: [.font: UIFont.systemFont(ofSize: 14), .foregroundColor: AppColor.textPrimary])) } label.attributedText = text stack.addArrangedSubview(label) } } /// 已上传直播素材预览行。 private final class LiveAlbumUploadedFileView: UIView { var onDelete: (() -> Void)? private let imageView = LiveCoverImageView() private let deleteButton = UIButton(type: .system) /// 初始化已上传素材视图。 init(file: LiveAlbumFile) { super.init(frame: .zero) imageView.setURL(file.url) deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) deleteButton.tintColor = .gray addSubview(imageView) addSubview(deleteButton) imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(4) make.size.equalTo(28) } deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func deleteTapped() { onDelete?() } } /// 直播相册预览页,对齐 Android `AliveAlbumPreviewScreen`。 final class LiveAlbumPreviewViewController: BaseViewController, UIScrollViewDelegate { private let viewModel: LiveAlbumPreviewViewModel private let liveAPI: any LiveServing private let pagingScrollView = UIScrollView() private let pageControl = UIPageControl() private let emptyLabel = UILabel() private let bottomBar = UIView() private let deleteButton = LiveActionButton(title: "删除素材") /// 初始化直播相册预览页。 @MainActor init(folderId: Int, startIndex: Int, liveAPI: (any LiveServing)? = nil) { viewModel = LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex) self.liveAPI = liveAPI ?? NetworkServices.shared.liveAPI super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "预览" navigationController?.navigationBar.barTintColor = .black navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.white] navigationController?.navigationBar.tintColor = .white } override func setupUI() { view.backgroundColor = .black pagingScrollView.isPagingEnabled = true pagingScrollView.showsHorizontalScrollIndicator = false pagingScrollView.delegate = self pageControl.currentPageIndicatorTintColor = .white pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.4) emptyLabel.text = "没有可预览的图片" emptyLabel.textColor = .white emptyLabel.font = .systemFont(ofSize: 16) emptyLabel.textAlignment = .center bottomBar.backgroundColor = .black deleteButton.buttonStyle = .danger view.addSubview(pagingScrollView) view.addSubview(pageControl) view.addSubview(emptyLabel) view.addSubview(bottomBar) bottomBar.addSubview(deleteButton) } override func setupConstraints() { bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } deleteButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(48) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } pagingScrollView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalTo(bottomBar.snp.top) } pageControl.snp.makeConstraints { make in make.centerX.equalToSuperview() make.bottom.equalTo(pagingScrollView).inset(8) } emptyLabel.snp.makeConstraints { make in make.center.equalTo(pagingScrollView) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } deleteButton.addTarget(self, action: #selector(confirmDelete), for: .touchUpInside) } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.load(api: liveAPI) } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationController?.navigationBar.barTintColor = nil navigationController?.navigationBar.titleTextAttributes = nil navigationController?.navigationBar.tintColor = nil } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() layoutPages() } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { let index = Int(round(scrollView.contentOffset.x / max(scrollView.bounds.width, 1))) viewModel.updateCurrentIndex(index) } private func applyViewModel() { viewModel.isLoading && viewModel.files.isEmpty ? showLoading() : hideLoading() pageControl.numberOfPages = viewModel.files.count pageControl.currentPage = viewModel.currentIndex emptyLabel.isHidden = !viewModel.files.isEmpty deleteButton.isEnabled = !viewModel.files.isEmpty buildPages() } private func buildPages() { pagingScrollView.subviews.forEach { $0.removeFromSuperview() } for (index, file) in viewModel.files.enumerated() { let imageView = LiveCoverImageView(cornerRadius: 0) imageView.contentMode = .scaleAspectFit imageView.backgroundColor = .black imageView.setURL(file.url) imageView.tag = index pagingScrollView.addSubview(imageView) } layoutPages() } private func layoutPages() { let size = pagingScrollView.bounds.size guard size.width > 0 else { return } for (index, view) in pagingScrollView.subviews.enumerated() { view.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height) } pagingScrollView.contentSize = CGSize(width: size.width * CGFloat(viewModel.files.count), height: size.height) pagingScrollView.setContentOffset(CGPoint(x: CGFloat(viewModel.currentIndex) * size.width, y: 0), animated: false) } @objc private func confirmDelete() { showAlert(title: "确认删除", message: "是否确认删除该素材?删除后将无法恢复。") { [weak self] in guard let self else { return } Task { await self.viewModel.deleteCurrentFile(api: self.liveAPI) } } } }