Files
suixinkan_uikit/suixinkan/UI/Live/LiveDetailViewController.swift
汉秋 efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

364 lines
14 KiB
Swift
Raw Permalink 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.

//
// LiveDetailViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `AliveDetailsScreen`
final class LiveDetailViewController: BaseViewController {
private let viewModel: LiveDetailViewModel
private let liveAPI: any LiveServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let coverImageView = LiveCoverImageView(cornerRadius: 0)
private let infoCard = LiveInfoCardView()
private let settingsCard = LiveSettingsCardView()
private let bottomBar = UIView()
private let controlButton = LiveActionButton(title: "开始", image: UIImage(systemName: "play.fill"))
private let finishButton = LiveActionButton(title: "结束", image: UIImage(systemName: "stop.fill"))
///
@MainActor
init(liveId: Int, liveAPI: (any LiveServing)? = nil) {
viewModel = LiveDetailViewModel(liveId: liveId)
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
contentStack.axis = .vertical
contentStack.spacing = 16
scrollView.backgroundColor = AppColor.pageBackground
bottomBar.backgroundColor = .white
finishButton.buttonStyle = .danger
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(coverImageView)
contentStack.addArrangedSubview(infoCard)
contentStack.addArrangedSubview(settingsCard)
view.addSubview(bottomBar)
bottomBar.addSubview(controlButton)
bottomBar.addSubview(finishButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
controlButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).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)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalTo(scrollView)
}
coverImageView.snp.makeConstraints { make in
make.height.equalTo(coverImageView.snp.width).multipliedBy(9.0 / 16.0)
}
infoCard.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
}
settingsCard.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
}
}
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) }
}
controlButton.addTarget(self, action: #selector(controlTapped), for: .touchUpInside)
finishButton.addTarget(self, action: #selector(finishTapped), for: .touchUpInside)
settingsCard.onCopy = { [weak self] text in
UIPasteboard.general.string = text
self?.showToast("已复制")
}
settingsCard.onModeSelected = { [weak self] mode in
guard let self else { return }
Task { await self.viewModel.changeMode(mode, api: self.liveAPI) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.load(api: liveAPI) }
}
private func applyViewModel() {
viewModel.isLoading && viewModel.item == nil ? showLoading() : hideLoading()
guard let item = viewModel.item else { return }
coverImageView.setURL(item.coverImg)
infoCard.apply(item)
settingsCard.apply(item: item, pushMode: viewModel.pushMode)
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.isEnabled = item.liveStatus.isOperable
}
@objc private func controlTapped() {
Task { await viewModel.controlLive(api: liveAPI) }
}
@objc private func finishTapped() {
showAlert(title: "确认结束", message: "是否确认结束该直播?") { [weak self] in
guard let self else { return }
Task { await self.viewModel.finishLive(api: self.liveAPI) }
}
}
}
///
private final class LiveInfoCardView: UIView {
private let titleLabel = UILabel()
private let durationRow = LiveIconTextRow(iconName: "clock", text: "")
private let watchRow = LiveIconTextRow(iconName: "eye", text: "")
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ item: LiveItem) {
durationRow.text = "直播时长: \(LiveUIFormatter.duration(item.duration))"
watchRow.text = "在线观众: \(item.viewsCount)"
}
private func setup() {
backgroundColor = .white
layer.cornerRadius = 12
titleLabel.text = "直播信息"
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
addSubview(titleLabel)
addSubview(durationRow)
addSubview(watchRow)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
durationRow.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(6)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(22)
}
watchRow.snp.makeConstraints { make in
make.top.equalTo(durationRow.snp.bottom).offset(4)
make.leading.trailing.bottom.equalToSuperview().inset(16)
make.height.equalTo(22)
}
}
}
///
private final class LiveSettingsCardView: UIView {
var onCopy: ((String) -> Void)?
var onModeSelected: ((LivePushMode) -> Void)?
private let titleLabel = UILabel()
private let addressTitleLabel = UILabel()
private let addressContainer = UIView()
private let addressLabel = UILabel()
private let copyButton = UIButton(type: .system)
private let modeTitleLabel = UILabel()
private let clarityButton = UIButton(type: .system)
private let smoothButton = UIButton(type: .system)
private var pushURL = ""
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(item: LiveItem, pushMode: LivePushMode) {
pushURL = item.pushUrl
addressLabel.text = item.pushUrl
setMode(pushMode)
clarityButton.isEnabled = item.liveStatus.isOperable
smoothButton.isEnabled = item.liveStatus.isOperable
}
private func setup() {
backgroundColor = .white
layer.cornerRadius = 12
titleLabel.text = "推流设置"
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
addressTitleLabel.text = "推流地址"
addressTitleLabel.font = .systemFont(ofSize: 14)
addressTitleLabel.textColor = UIColor(hex: 0x4B5563)
addressContainer.backgroundColor = AppColor.inputBackground
addressContainer.layer.borderWidth = 1
addressContainer.layer.borderColor = AppColor.border.cgColor
addressContainer.layer.cornerRadius = 4
addressLabel.font = .systemFont(ofSize: 14)
addressLabel.textColor = AppColor.textPrimary
addressLabel.lineBreakMode = .byTruncatingMiddle
copyButton.setTitle("复制", for: .normal)
copyButton.titleLabel?.font = .systemFont(ofSize: 14)
copyButton.backgroundColor = AppColor.primary
copyButton.setTitleColor(.white, for: .normal)
copyButton.layer.cornerRadius = 4
modeTitleLabel.text = "直播模式"
modeTitleLabel.font = .systemFont(ofSize: 14)
modeTitleLabel.textColor = UIColor(hex: 0x4B5563)
[clarityButton, smoothButton].forEach { button in
button.titleLabel?.font = .systemFont(ofSize: 14)
button.layer.cornerRadius = 4
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12)
)
}
clarityButton.setTitle(LivePushMode.clarityFirst.title, for: .normal)
smoothButton.setTitle(LivePushMode.smoothFirst.title, for: .normal)
addSubview(titleLabel)
addSubview(addressTitleLabel)
addSubview(addressContainer)
addressContainer.addSubview(addressLabel)
addSubview(copyButton)
addSubview(modeTitleLabel)
addSubview(clarityButton)
addSubview(smoothButton)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
addressTitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
addressContainer.snp.makeConstraints { make in
make.top.equalTo(addressTitleLabel.snp.bottom).offset(4)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(28)
}
addressLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(8)
make.centerY.equalToSuperview()
}
copyButton.snp.makeConstraints { make in
make.leading.equalTo(addressContainer.snp.trailing).offset(4)
make.trailing.equalToSuperview().inset(16)
make.top.bottom.equalTo(addressContainer)
make.width.equalTo(52)
}
modeTitleLabel.snp.makeConstraints { make in
make.top.equalTo(addressContainer.snp.bottom).offset(8)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(16)
}
smoothButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.centerY.equalTo(modeTitleLabel)
make.height.equalTo(28)
}
clarityButton.snp.makeConstraints { make in
make.trailing.equalTo(smoothButton.snp.leading).offset(-4)
make.centerY.height.equalTo(smoothButton)
}
copyButton.addTarget(self, action: #selector(copyTapped), for: .touchUpInside)
clarityButton.addTarget(self, action: #selector(clarityTapped), for: .touchUpInside)
smoothButton.addTarget(self, action: #selector(smoothTapped), for: .touchUpInside)
}
private func setMode(_ mode: LivePushMode) {
updateModeButton(clarityButton, selected: mode == .clarityFirst)
updateModeButton(smoothButton, selected: mode == .smoothFirst)
}
private func updateModeButton(_ button: UIButton, selected: Bool) {
button.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground
button.setTitleColor(selected ? .white : UIColor(hex: 0xB6BECA), for: .normal)
}
@objc private func copyTapped() {
onCopy?(pushURL)
}
@objc private func clarityTapped() {
onModeSelected?(.clarityFirst)
}
@objc private func smoothTapped() {
onModeSelected?(.smoothFirst)
}
}
///
private final class LiveIconTextRow: UIView {
var text: String = "" {
didSet { label.text = text }
}
private let iconView = UIImageView()
private let label = UILabel()
///
init(iconName: String, text: String) {
super.init(frame: .zero)
iconView.image = UIImage(systemName: iconName)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
label.text = text
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x4B5563)
addSubview(iconView)
addSubview(label)
iconView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.size.equalTo(16)
}
label.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(8)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}