feat: add live module and optimize report list

This commit is contained in:
2026-07-08 14:25:14 +08:00
parent 8ca8bcf948
commit 4a78a0c21a
14 changed files with 3712 additions and 102 deletions

View 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")
}
}