| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267 |
- //
- // LNUploadVideioView.swift
- // Gami
- //
- // Created by OneeChan on 2026/2/27.
- //
- import Foundation
- import UIKit
- import SnapKit
- import AVFoundation
- protocol LNVideoUploadViewDelegate: AnyObject {
- func onVideoUploadView(view: LNVideoUploadView, didUploadVideo url: String, imageUrl: String)
- func onVideoUploadViewUploadFailed(view: LNVideoUploadView)
- func onVideoUploadViewStartUpload(view: LNVideoUploadView)
- func onVideoUploadViewDidClickDelete(view: LNVideoUploadView)
- }
- extension LNVideoUploadViewDelegate {
- func onVideoUploadView(view: LNVideoUploadView, didUploadVideo url: String, imageUrl: String) {}
- func onVideoUploadViewUploadFailed(view: LNVideoUploadView) { }
- func onVideoUploadViewStartUpload(view: LNVideoUploadView) {}
- func onVideoUploadViewDidClickDelete(view: LNVideoUploadView) {}
- }
- class LNVideoUploadView: UIImageView {
- private var curTask: String?
- private(set) var sourceUrl: URL?
- private var cacheUrl: URL?
- private(set) var videoSize: CGSize?
- private(set) var videoUrl: String?
- private(set) var imageUrl: String?
-
- private let durationLabel = UILabel()
- private let playButton = UIButton()
- private let deleteButton = UIButton()
-
- weak var delegate: LNVideoUploadViewDelegate?
-
- override init(image: UIImage? = nil) {
- super.init(image: image)
-
- setupViews()
- }
-
- func preview(url: URL) -> CGSize {
- reset()
-
- guard FileManager.default.fileExists(atPath: url.path) else {
- return .zero
- }
-
- sourceUrl = url
-
- let asset = AVAsset(url: url)
- let videoDuration = CMTimeGetSeconds(asset.duration)
- durationLabel.text = videoDuration.timeCountDisplay
-
- DispatchQueue.global().async { [weak self] in
- guard let self else { return }
- guard sourceUrl == url else { return }
-
- // 创建缩略图生成器
- let imageGenerator = AVAssetImageGenerator(asset: asset)
- // 设置生成的图片比例(保持原视频比例)
- imageGenerator.appliesPreferredTrackTransform = true
-
- // 获取视频首帧作为预览图
- let time = CMTimeMakeWithSeconds(0, preferredTimescale: 600)
- do {
- // 生成指定时间点的图片
- let cgImage = try imageGenerator.copyCGImage(at: time, actualTime: nil)
- let thumbnailImage = UIImage(cgImage: cgImage)
- // 更新UI(必须在主线程)
- runOnMain {
- self.image = thumbnailImage
- }
- } catch {
- print("生成视频缩略图失败:\(error.localizedDescription)")
- // 生成失败时显示占位图
- runOnMain {
- self.image = UIImage(systemName: "film")
- }
- }
- }
-
- guard let videoTrack = asset.tracks(withMediaType: .video).first else {
- return .zero
- }
- // 3. 获取视频的自然尺寸(原始尺寸)
- let naturalSize = videoTrack.naturalSize
-
- // 4. 处理视频的旋转角度(避免尺寸方向错误)
- let transform = videoTrack.preferredTransform
- videoSize = naturalSize
-
- // 如果视频有旋转(90/270度),需要交换宽高
- if transform.a == 0 && transform.b == 1.0 && transform.c == -1.0 && transform.d == 0 {
- // 90度旋转
- videoSize = CGSize(width: naturalSize.height, height: naturalSize.width)
- } else if transform.a == 0 && transform.b == -1.0 && transform.c == 1.0 && transform.d == 0 {
- // 270度旋转
- videoSize = CGSize(width: naturalSize.height, height: naturalSize.width)
- }
- return videoSize!
- }
-
- func startUpload() {
- guard let sourceUrl, let image else {
- delegate?.onVideoUploadViewUploadFailed(view: self)
- return
- }
-
- delegate?.onVideoUploadViewStartUpload(view: self)
- if cacheUrl != nil {
- uploadVideo(image: image)
- } else {
- LNVideoCompressor.compressVideo(sourceURL: sourceUrl) { [weak self] url in
- guard let self else {
- return
- }
- cacheUrl = url
- uploadVideo(image: image)
- }
- }
- }
-
- func cancelUpload() {
- if let curTask {
- LNFileUploader.shared.cancelUpload(taskID: curTask)
- }
- }
-
- func reset() {
- cancelUpload()
-
- image = nil
- videoUrl = nil
- cacheUrl = nil
- imageUrl = nil
- sourceUrl = nil
- curTask = nil
- durationLabel.text = ""
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- }
- extension LNVideoUploadView {
- private func uploadVideo(image: UIImage) {
- guard let cacheUrl,
- let data = try? Data(contentsOf: cacheUrl),
- let imageData = image.jpegData(compressionQuality: 0.7)
- else {
- delegate?.onVideoUploadViewUploadFailed(view: self)
- return
- }
- DispatchQueue.global().async { [weak self] in
- guard let self else { return }
-
- let group = DispatchGroup()
- if videoUrl == nil {
- group.enter()
- let suffix = "\(Int(videoSize?.width ?? 0))x\(Int(videoSize?.height ?? 0)).mp4"
- curTask = LNFileUploader.shared.startUpload(
- type: .video, fileData: data,
- suffix: suffix, progressHandler: nil)
- { [weak self] url, err in
- if let self {
- curTask = nil
- if let url {
- videoUrl = url
- } else if let err {
- showToast(err)
- }
- }
- group.leave()
- }
- }
- if imageUrl == nil {
- group.enter()
- let suffix = "\(Int(image.size.width))x\(Int(image.size.height)).jpeg"
- LNFileUploader.shared.startUpload(
- type: .other, fileData: imageData,
- suffix: suffix, progressHandler: nil)
- { [weak self] url, err in
- if let self {
- if let url {
- imageUrl = url
- } else if let err {
- showToast(err)
- }
- }
- group.leave()
- }
- }
- group.notify(queue: .main) { [weak self] in
- guard let self else { return }
- if let videoUrl, let imageUrl {
- delegate?.onVideoUploadView(view: self, didUploadVideo: videoUrl, imageUrl: imageUrl)
- } else {
- delegate?.onVideoUploadViewUploadFailed(view: self)
- }
- }
- }
- }
- }
- extension LNVideoUploadView {
- private func setupViews() {
- isUserInteractionEnabled = true
- contentMode = .scaleAspectFill
- layer.cornerRadius = 7
- clipsToBounds = true
-
- let cover = UIView()
- cover.backgroundColor = .black.withAlphaComponent(0.2)
- addSubview(cover)
- cover.snp.makeConstraints { make in
- make.edges.equalToSuperview()
- }
-
- let clear = buildClearButton()
- addSubview(clear)
- clear.snp.makeConstraints { make in
- make.top.equalToSuperview().offset(6)
- make.trailing.equalToSuperview().offset(-6)
- make.width.height.equalTo(16)
- }
-
- durationLabel.font = .body_xs
- durationLabel.textColor = .text_1
- addSubview(durationLabel)
- durationLabel.snp.makeConstraints { make in
- make.leading.equalToSuperview().offset(6)
- make.bottom.equalToSuperview().offset(-6)
- }
-
- playButton.setImage(.icVoicePlayWithBlackBg, for: .normal)
- playButton.isUserInteractionEnabled = false
- addSubview(playButton)
- playButton.snp.makeConstraints { make in
- make.center.equalToSuperview()
- }
- }
-
- private func buildClearButton() -> UIView {
- let config = UIImage.SymbolConfiguration(pointSize: 7)
- deleteButton.setImage(.init(systemName: "xmark", withConfiguration: config), for: .normal)
- deleteButton.tintColor = .white
- deleteButton.backgroundColor = .black.withAlphaComponent(0.5)
- deleteButton.layer.cornerRadius = 8
- deleteButton.addAction(UIAction(handler: { [weak self] _ in
- guard let self else { return }
- reset()
-
- delegate?.onVideoUploadViewDidClickDelete(view: self)
- }), for: .touchUpInside)
-
- return deleteButton
- }
- }
|