// // LiveManageViewController.swift // suixinkan // import Kingfisher import PhotosUI import SnapKit import UIKit /// 直播管理页,对齐 Android `AliveManageScreen`。 final class LiveManageViewController: BaseViewController, UITableViewDelegate { private enum Section { case main } private let viewModel = LiveManageViewModel() private let liveAPI: any LiveServing private let uploader: any LiveOSSUploading 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 addButton = AppButton(title: "添加") private var dataSource: UITableViewDiffableDataSource! /// 初始化直播管理页。 @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 tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.delegate = self tableView.refreshControl = refreshControl tableView.register(LiveItemCell.self, forCellReuseIdentifier: LiveItemCell.reuseIdentifier) tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in guard let cell = tableView.dequeueReusableCell(withIdentifier: LiveItemCell.reuseIdentifier, for: indexPath) as? LiveItemCell else { return UITableViewCell() } cell.apply(item) cell.onCopy = { [weak self] in self?.copyPushURL(item) } cell.onControl = { [weak self] in self?.controlLive(item) } cell.onFinish = { [weak self] in self?.confirmFinish(item) } return cell } emptyLabel.text = "暂无直播" emptyLabel.font = .systemFont(ofSize: 14) emptyLabel.textColor = UIColor(hex: 0x4B5563) emptyLabel.textAlignment = .center emptyLabel.isHidden = true loadingIndicator.hidesWhenStopped = true loadingIndicator.color = AppColor.primary bottomBar.backgroundColor = .white view.addSubview(tableView) view.addSubview(emptyLabel) view.addSubview(loadingIndicator) view.addSubview(bottomBar) bottomBar.addSubview(addButton) } override func setupConstraints() { bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } addButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) make.height.equalTo(48) } tableView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) 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) } } viewModel.onLiveCreated = { [weak self] in Task { @MainActor in self?.dismiss(animated: true) } } refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) addButton.addTarget(self, action: #selector(showAddLiveSheet), for: .touchUpInside) } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.loadInitial(api: liveAPI) } } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard let item = dataSource.itemIdentifier(for: indexPath) else { return } navigationController?.pushViewController(LiveDetailViewController(liveId: item.id), animated: true) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { UITableView.automaticDimension } 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 showAddLiveSheet() { let controller = AddLiveSheetViewController(uploader: uploader) controller.onConfirm = { [weak self] title, cover in guard let self else { return } Task { await self.viewModel.createLive(title: title, coverUrl: cover, api: self.liveAPI) } } controller.onMessage = { [weak self] message in self?.showToast(message) } if let sheet = controller.sheetPresentationController { sheet.detents = [.medium(), .large()] sheet.prefersGrabberVisible = true } present(controller, animated: true) } private func applyViewModel() { if viewModel.isRefreshing { if !refreshControl.isRefreshing { refreshControl.beginRefreshing() } } else { refreshControl.endRefreshing() } viewModel.isLoading && viewModel.items.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() emptyLabel.isHidden = viewModel.isLoading || !viewModel.items.isEmpty var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(viewModel.items) dataSource.apply(snapshot, animatingDifferences: true) } private func copyPushURL(_ item: LiveItem) { UIPasteboard.general.string = viewModel.copyPushURL(item) showToast("已复制") } private func controlLive(_ item: LiveItem) { Task { await viewModel.controlLive(item, api: liveAPI) } } private func confirmFinish(_ item: LiveItem) { showAlert(title: "确认结束", message: "是否确认结束该直播?") { [weak self] in guard let self else { return } Task { await self.viewModel.finishLive(item, api: self.liveAPI) } } } } /// 直播列表项 Cell,对齐 Android `AliveItem`。 private final class LiveItemCell: UITableViewCell { static let reuseIdentifier = "LiveItemCell" var onCopy: (() -> Void)? var onControl: (() -> Void)? var onFinish: (() -> Void)? private let container = UIView() private let coverImageView = LiveCoverImageView() private let statusOverlay = UIView() private let statusDot = UIView() private let statusLabel = UILabel() private let titleLabel = UILabel() private let timeLabel = UILabel() private let durationLabel = UILabel() private let copyButton = UIButton(type: .system) private let controlButton = LiveActionButton(title: "开始", image: UIImage(systemName: "play.fill")) private let finishButton = LiveActionButton(title: "结束", image: UIImage(systemName: "stop.fill")) 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() onCopy = nil onControl = nil onFinish = nil } /// 应用直播列表项。 func apply(_ item: LiveItem) { coverImageView.setURL(item.coverImg) statusLabel.text = item.statusLabel statusDot.backgroundColor = item.liveStatus == .living ? UIColor(hex: 0xF91616) : .white titleLabel.text = item.displayTitle timeLabel.text = "直播时间:\(LiveUIFormatter.timeRange(start: item.startTime, end: item.endTime))" durationLabel.text = "时长:\(LiveUIFormatter.duration(item.duration))" controlButton.setTitle(item.controlTitle, for: .normal) controlButton.setImage(UIImage(systemName: item.liveStatus == .living ? "pause.fill" : "play.fill"), for: .normal) controlButton.buttonStyle = item.liveStatus == .living ? .warning : .primary controlButton.isEnabled = item.liveStatus.isOperable finishButton.buttonStyle = .danger finishButton.isEnabled = item.liveStatus.isOperable } private func setup() { selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear container.backgroundColor = .white statusOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.35) statusOverlay.layer.cornerRadius = 8 statusOverlay.clipsToBounds = true statusDot.layer.cornerRadius = 3 statusLabel.font = .systemFont(ofSize: 12) statusLabel.textColor = .white titleLabel.font = .systemFont(ofSize: 16, weight: .medium) titleLabel.textColor = AppColor.textPrimary titleLabel.numberOfLines = 1 timeLabel.font = .systemFont(ofSize: 12) timeLabel.textColor = AppColor.textPrimary durationLabel.font = .systemFont(ofSize: 12) durationLabel.textColor = AppColor.textPrimary copyButton.setTitle("复制地址", for: .normal) copyButton.titleLabel?.font = .systemFont(ofSize: 12) copyButton.setTitleColor(.white, for: .normal) copyButton.backgroundColor = AppColor.primary copyButton.layer.cornerRadius = 4 finishButton.buttonStyle = .danger contentView.addSubview(container) container.addSubview(coverImageView) coverImageView.addSubview(statusOverlay) statusOverlay.addSubview(statusDot) statusOverlay.addSubview(statusLabel) container.addSubview(titleLabel) container.addSubview(timeLabel) container.addSubview(durationLabel) container.addSubview(copyButton) container.addSubview(controlButton) container.addSubview(finishButton) container.snp.makeConstraints { make in make.top.bottom.equalToSuperview() make.leading.trailing.equalToSuperview() } coverImageView.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(16) make.size.equalTo(CGSize(width: 120, height: 68)) } statusOverlay.snp.makeConstraints { make in make.leading.bottom.equalToSuperview().inset(6) make.height.equalTo(20) } statusDot.snp.makeConstraints { make in make.leading.equalToSuperview().offset(6) make.centerY.equalToSuperview() make.size.equalTo(6) } statusLabel.snp.makeConstraints { make in make.leading.equalTo(statusDot.snp.trailing).offset(2) make.trailing.equalToSuperview().inset(6) make.centerY.equalToSuperview() } titleLabel.snp.makeConstraints { make in make.top.equalTo(coverImageView) make.leading.equalTo(coverImageView.snp.trailing).offset(12) make.trailing.equalToSuperview().inset(16) } timeLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(4) make.leading.trailing.equalTo(titleLabel) } durationLabel.snp.makeConstraints { make in make.leading.equalTo(titleLabel) make.bottom.equalTo(coverImageView) } copyButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) make.centerY.equalTo(durationLabel) make.height.equalTo(22) make.width.equalTo(64) } controlButton.snp.makeConstraints { make in make.top.equalTo(coverImageView.snp.bottom).offset(20) make.leading.equalToSuperview().offset(16) make.height.equalTo(40) make.bottom.equalToSuperview().inset(16) } finishButton.snp.makeConstraints { make in make.leading.equalTo(controlButton.snp.trailing).offset(8) make.trailing.equalToSuperview().inset(16) make.top.bottom.width.equalTo(controlButton) } copyButton.addTarget(self, action: #selector(copyTapped), for: .touchUpInside) controlButton.addTarget(self, action: #selector(controlTapped), for: .touchUpInside) finishButton.addTarget(self, action: #selector(finishTapped), for: .touchUpInside) } @objc private func copyTapped() { onCopy?() } @objc private func controlTapped() { onControl?() } @objc private func finishTapped() { onFinish?() } } /// 新增直播底部弹窗,对齐 Android `AddAliveModal`。 private final class AddLiveSheetViewController: UIViewController, PHPickerViewControllerDelegate { var onConfirm: ((String, String) -> Void)? var onMessage: ((String) -> Void)? private let uploader: any LiveOSSUploading private let titleLabel = UILabel() private let titleField = UITextField() private let coverTitleLabel = UILabel() private let uploadView = LiveDashedUploadView() private let coverImageView = LiveCoverImageView() private let confirmButton = AppButton(title: "确认") private let loadingIndicator = UIActivityIndicatorView(style: .medium) private var coverURL = "" /// 初始化新增直播弹窗。 init(uploader: any LiveOSSUploading) { self.uploader = uploader super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() setup() } 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 uploadCover(media) } catch { onMessage?(error.localizedDescription) } } } private func setup() { view.backgroundColor = .white let headingLabel = UILabel() headingLabel.text = "新增直播" headingLabel.font = .systemFont(ofSize: 18, weight: .semibold) headingLabel.textColor = AppColor.textPrimary headingLabel.textAlignment = .center titleLabel.text = "直播标题" titleLabel.font = .systemFont(ofSize: 16, weight: .medium) titleLabel.textColor = AppColor.textPrimary titleField.placeholder = "请输入直播标题" titleField.font = .systemFont(ofSize: 14) titleField.borderStyle = .none titleField.backgroundColor = .white 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 coverTitleLabel.text = "封面图片" coverTitleLabel.font = .systemFont(ofSize: 16, weight: .medium) coverTitleLabel.textColor = AppColor.textPrimary coverImageView.isHidden = true loadingIndicator.hidesWhenStopped = true loadingIndicator.color = AppColor.primary view.addSubview(headingLabel) view.addSubview(titleLabel) view.addSubview(titleField) view.addSubview(coverTitleLabel) view.addSubview(uploadView) view.addSubview(coverImageView) view.addSubview(loadingIndicator) view.addSubview(confirmButton) headingLabel.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.leading.trailing.equalToSuperview().inset(16) } titleLabel.snp.makeConstraints { make in make.top.equalTo(headingLabel.snp.bottom).offset(18) make.leading.trailing.equalToSuperview().inset(16) } titleField.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(44) } coverTitleLabel.snp.makeConstraints { make in make.top.equalTo(titleField.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(16) } uploadView.snp.makeConstraints { make in make.top.equalTo(coverTitleLabel.snp.bottom).offset(8) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(uploadView.snp.width).multipliedBy(9.0 / 16.0) } coverImageView.snp.makeConstraints { make in make.edges.equalTo(uploadView) } loadingIndicator.snp.makeConstraints { make in make.center.equalTo(uploadView) } confirmButton.snp.makeConstraints { make in make.top.equalTo(uploadView.snp.bottom).offset(32) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(48) make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(16) } uploadView.addTarget(self, action: #selector(pickCover), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) } @objc private func pickCover() { 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() { onConfirm?(titleField.text ?? "", coverURL) } private func uploadCover(_ media: LivePickedMedia) async { loadingIndicator.startAnimating() defer { loadingIndicator.stopAnimating() } do { let scenicId = AppStore.shared.session.currentScenicId let url = try await uploader.uploadLiveCover( data: media.data, fileName: media.fileName, scenicId: scenicId, onProgress: { _ in } ) coverURL = url coverImageView.image = UIImage(data: media.data) coverImageView.isHidden = false uploadView.isHidden = true } catch { onMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription) } } }