feat: add live module and optimize report list
This commit is contained in:
361
suixinkan/UI/Live/LiveDetailViewController.swift
Normal file
361
suixinkan/UI/Live/LiveDetailViewController.swift
Normal file
@ -0,0 +1,361 @@
|
||||
//
|
||||
// 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.contentEdgeInsets = UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user