243 lines
8.3 KiB
Swift
243 lines
8.3 KiB
Swift
//
|
||
// LiveUIComponents.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Kingfisher
|
||
import PhotosUI
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 直播 UI 格式化工具,集中处理 Android 页面使用的日期和时长文案。
|
||
enum LiveUIFormatter {
|
||
/// 格式化直播时间范围。
|
||
static func timeRange(start: Int64, end: Int64) -> String {
|
||
guard start > 0 || end > 0 else { return "" }
|
||
let startText = timestampText(start)
|
||
let endText = timestampText(end)
|
||
if startText.isEmpty { return endText }
|
||
if endText.isEmpty { return startText }
|
||
return "\(startText) - \(endText)"
|
||
}
|
||
|
||
/// 格式化秒级时长。
|
||
static func duration(_ seconds: Int64) -> String {
|
||
let value = max(seconds, 0)
|
||
let hour = value / 3600
|
||
let minute = (value % 3600) / 60
|
||
let second = value % 60
|
||
if hour > 0 {
|
||
return String(format: "%02lld:%02lld:%02lld", hour, minute, second)
|
||
}
|
||
return String(format: "%02lld:%02lld", minute, second)
|
||
}
|
||
|
||
/// 格式化日期。
|
||
static func date(_ date: Date?) -> String {
|
||
guard let date else { return "" }
|
||
return LiveAlbumListViewModel.dateString(date)
|
||
}
|
||
|
||
private static func timestampText(_ timestamp: Int64) -> String {
|
||
guard timestamp > 0 else { return "" }
|
||
let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||
let formatter = DateFormatter()
|
||
formatter.calendar = Calendar(identifier: .gregorian)
|
||
formatter.locale = Locale(identifier: "zh_CN")
|
||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||
return formatter.string(from: date)
|
||
}
|
||
}
|
||
|
||
/// 直播模块按钮,支持 Android 页面中的主按钮、暂停和危险操作色。
|
||
final class LiveActionButton: UIButton {
|
||
enum Style {
|
||
case primary
|
||
case warning
|
||
case danger
|
||
case disabled
|
||
}
|
||
|
||
var buttonStyle: Style = .primary {
|
||
didSet { updateAppearance() }
|
||
}
|
||
|
||
override var isEnabled: Bool {
|
||
didSet { updateAppearance() }
|
||
}
|
||
|
||
/// 初始化直播操作按钮。
|
||
init(title: String, image: UIImage? = nil) {
|
||
super.init(frame: .zero)
|
||
setTitle(title, for: .normal)
|
||
setImage(image, for: .normal)
|
||
titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||
layer.cornerRadius = 8
|
||
clipsToBounds = true
|
||
tintColor = .white
|
||
imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
|
||
updateAppearance()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
private func updateAppearance() {
|
||
let effectiveStyle: Style = isEnabled ? buttonStyle : .disabled
|
||
switch effectiveStyle {
|
||
case .primary:
|
||
backgroundColor = UIColor(hex: 0x0066FF)
|
||
setTitleColor(.white, for: .normal)
|
||
tintColor = .white
|
||
case .warning:
|
||
backgroundColor = AppColor.warning
|
||
setTitleColor(.white, for: .normal)
|
||
tintColor = .white
|
||
case .danger:
|
||
backgroundColor = AppColor.danger
|
||
setTitleColor(.white, for: .normal)
|
||
tintColor = .white
|
||
case .disabled:
|
||
backgroundColor = AppColor.inputBackground
|
||
setTitleColor(UIColor(hex: 0xB6BECA), for: .normal)
|
||
tintColor = UIColor(hex: 0xB6BECA)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 直播模块封面视图,统一 Kingfisher 加载和占位展示。
|
||
final class LiveCoverImageView: UIImageView {
|
||
/// 初始化封面图片视图。
|
||
init(cornerRadius: CGFloat = 8) {
|
||
super.init(frame: .zero)
|
||
contentMode = .scaleAspectFill
|
||
clipsToBounds = true
|
||
backgroundColor = UIColor(hex: 0xF4F4F4)
|
||
layer.cornerRadius = cornerRadius
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 应用网络图片地址。
|
||
func setURL(_ text: String) {
|
||
if let url = URL(string: text), !text.isEmpty {
|
||
kf.setImage(with: url, placeholder: placeholderImage())
|
||
} else {
|
||
image = placeholderImage()
|
||
}
|
||
}
|
||
|
||
private func placeholderImage() -> UIImage? {
|
||
UIImage(named: "img_square_loading_big") ?? UIImage(systemName: "photo")
|
||
}
|
||
}
|
||
|
||
/// 虚线上传区域,复用直播封面和相册素材上传。
|
||
final class LiveDashedUploadView: UIControl {
|
||
private let plusContainer = UIView()
|
||
private let plusImageView = UIImageView(image: UIImage(systemName: "plus"))
|
||
private let titleLabel = UILabel()
|
||
private let subtitleLabel = UILabel()
|
||
private var dashLayer: CAShapeLayer?
|
||
|
||
/// 初始化虚线上传区域。
|
||
init(title: String = "点击上传图片", subtitle: String? = nil) {
|
||
super.init(frame: .zero)
|
||
setup(title: title, subtitle: subtitle)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
dashLayer?.removeFromSuperlayer()
|
||
let layer = CAShapeLayer()
|
||
layer.strokeColor = AppColor.border.cgColor
|
||
layer.fillColor = UIColor.clear.cgColor
|
||
layer.lineWidth = 2
|
||
layer.lineDashPattern = [6, 6]
|
||
layer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 8).cgPath
|
||
self.layer.insertSublayer(layer, at: 0)
|
||
dashLayer = layer
|
||
}
|
||
|
||
private func setup(title: String, subtitle: String?) {
|
||
backgroundColor = .white
|
||
plusContainer.backgroundColor = AppColor.primary
|
||
plusContainer.layer.cornerRadius = 16
|
||
plusImageView.tintColor = .white
|
||
plusImageView.contentMode = .scaleAspectFit
|
||
titleLabel.text = title
|
||
titleLabel.font = .systemFont(ofSize: 14)
|
||
titleLabel.textColor = subtitle == nil ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x4B5563)
|
||
subtitleLabel.text = subtitle
|
||
subtitleLabel.font = .systemFont(ofSize: 12)
|
||
subtitleLabel.textColor = UIColor(hex: 0xB6BECA)
|
||
subtitleLabel.textAlignment = .center
|
||
|
||
addSubview(plusContainer)
|
||
plusContainer.addSubview(plusImageView)
|
||
addSubview(titleLabel)
|
||
addSubview(subtitleLabel)
|
||
|
||
plusContainer.snp.makeConstraints { make in
|
||
make.centerX.equalToSuperview()
|
||
make.centerY.equalToSuperview().offset(subtitle == nil ? -14 : -24)
|
||
make.size.equalTo(32)
|
||
}
|
||
plusImageView.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
make.size.equalTo(18)
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(plusContainer.snp.bottom).offset(12)
|
||
make.centerX.equalToSuperview()
|
||
}
|
||
subtitleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||
make.centerX.equalToSuperview()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 直播模块图片选择与上传数据加载工具。
|
||
enum LivePhotoPickerLoader {
|
||
/// 从 PHPicker 结果加载图片数据。
|
||
static func loadImage(from result: PHPickerResult) async throws -> LivePickedMedia {
|
||
try await withCheckedThrowingContinuation { continuation in
|
||
let provider = result.itemProvider
|
||
guard provider.canLoadObject(ofClass: UIImage.self) else {
|
||
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
|
||
return
|
||
}
|
||
provider.loadObject(ofClass: UIImage.self) { object, error in
|
||
if let error {
|
||
continuation.resume(throwing: error)
|
||
return
|
||
}
|
||
guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else {
|
||
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||
return
|
||
}
|
||
continuation.resume(
|
||
returning: LivePickedMedia(
|
||
data: data,
|
||
fileName: "image_\(UUID().uuidString).jpg",
|
||
size: Int64(data.count),
|
||
width: Int(image.size.width),
|
||
height: Int(image.size.height)
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|