// // LNVideoPlayerView.swift // Gami // // Created by OneeChan on 2026/3/2. // import Foundation import UIKit import AVKit import SnapKit import Combine protocol LNVideoPlayerViewDelegate: NSObject { func onVideoDidLoad(view: LNVideoPlayerView) func onVideoDidStart(view: LNVideoPlayerView) func onVideoDidStop(view: LNVideoPlayerView) func onVideoDidPause(view: LNVideoPlayerView) func onVideoMutedChanged(view: LNVideoPlayerView) func onVideoProgressChanged(view: LNVideoPlayerView, cur: Float64, total: Float64) } extension LNVideoPlayerViewDelegate { func onVideoDidLoad(view: LNVideoPlayerView) { } func onVideoDidStart(view: LNVideoPlayerView) { } func onVideoDidStop(view: LNVideoPlayerView) { } func onVideoDidPause(view: LNVideoPlayerView) { } func onVideoMutedChanged(view: LNVideoPlayerView) { } func onVideoProgressChanged(view: LNVideoPlayerView, cur: Float64, total: Float64) { } } class LNVideoPlayerView: UIView { static let updateInterval: Double = 0.05 private let videoPlayer = AVPlayer() private let videoLayer = AVPlayerLayer() private let coverImageView = UIImageView() private let touchView = UIView() private let playButton = UIButton() private var timeObserver: Any? var playButtonSize: CGFloat = 42 { didSet { playButton.snp.updateConstraints { make in make.width.height.equalTo(playButtonSize) } } } var loop: Bool = false weak var delegate: LNVideoPlayerViewDelegate? private(set) var duration: TimeInterval = 0 private(set) var videoSize: CGSize = .zero private(set) var curSource: String? var showCover: Bool = true { didSet { coverImageView.isHidden = !showCover } } var isPlaying: Bool { videoPlayer.rate > 0 && videoPlayer.error == nil && videoPlayer.currentItem?.status == .readyToPlay } var isMuted: Bool { videoPlayer.isMuted } override init(frame: CGRect) { super.init(frame: frame) setupViews() setupObservsers() } func loadVideo(_ url: String, coverUrl: String?) { if curSource == url { return } curSource = url stop() guard let videoUrl = URL(string: url) else { duration = 0 videoSize = .zero coverImageView.image = UIImage(systemName: "film") videoPlayer.replaceCurrentItem(with: nil) return } DispatchQueue.global().async { [weak self] in guard let self else { return } guard curSource == url else { return } let asset = AVAsset(url: videoUrl) let videoDuration = CMTimeGetSeconds(asset.duration) duration = TimeInterval(videoDuration) if let coverUrl { coverImageView.sd_setImage(with: URL(string: coverUrl)) } else { 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) DispatchQueue.main.async { self.coverImageView.image = thumbnailImage } } catch { DispatchQueue.main.async { self.coverImageView.image = UIImage(systemName: "film") } } } videoPlayer.pause() let item = AVPlayerItem(url: videoUrl) videoPlayer.replaceCurrentItem(with: item) guard let videoTrack = asset.tracks(withMediaType: .video).first else { return } let naturalSize = videoTrack.naturalSize let transform = videoTrack.preferredTransform videoSize = naturalSize 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) } DispatchQueue.main.async { [weak self] in guard let self else { return } guard curSource == url else { return } delegate?.onVideoDidLoad(view: self) } } } func start() { videoPlayer.play() playButton.isHidden = true coverImageView.isHidden = true delegate?.onVideoDidStart(view: self) } func pause() { videoPlayer.pause() playButton.isHidden = false delegate?.onVideoDidPause(view: self) } func seekTo(_ to: Float, handler: ((Bool) -> Void)? = nil) { let time = CMTimeMakeWithSeconds(Float64(to), preferredTimescale: 1) videoPlayer.seek(to: time) { finish in handler?(finish) } } func stop() { videoPlayer.pause() videoPlayer.seek(to: .zero) coverImageView.isHidden = !showCover playButton.isHidden = false delegate?.onVideoDidStop(view: self) } func mute() { videoPlayer.isMuted = true delegate?.onVideoMutedChanged(view: self) } func unmute() { videoPlayer.isMuted = false delegate?.onVideoMutedChanged(view: self) } func setScaleMode(_ model: AVLayerVideoGravity) { videoLayer.videoGravity = model if model == .resizeAspect { coverImageView.contentMode = .scaleAspectFit } else { coverImageView.contentMode = .scaleAspectFill } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension LNVideoPlayerView { private func setupViews() { backgroundColor = .black clipsToBounds = true videoLayer.player = videoPlayer videoLayer.videoGravity = .resizeAspect layer.addSublayer(videoLayer) publisher(for: \.bounds).removeDuplicates().sink { [weak self] newValue in guard let self else { return } videoLayer.frame = newValue }.store(in: &cancellables) touchView.onTap { [weak self] in guard let self else { return } if isPlaying { pause() } else { start() } } addSubview(touchView) touchView.snp.makeConstraints { make in make.edges.equalToSuperview() } coverImageView.contentMode = .scaleAspectFit addSubview(coverImageView) coverImageView.snp.makeConstraints { make in make.edges.equalToSuperview() } playButton.setBackgroundImage(.icVoicePlayWithBlackBg, for: .normal) playButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } if isPlaying { pause() } else { start() } }), for: .touchUpInside) addSubview(playButton) playButton.snp.makeConstraints { make in make.center.equalToSuperview() make.width.height.equalTo(playButtonSize) } } private func setupObservsers() { let time = CMTime(seconds: Self.updateInterval, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserver = videoPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] currentTime in guard let self, let playerItem = videoPlayer.currentItem else { return } let currentSeconds = CMTimeGetSeconds(currentTime) let totalSeconds = CMTimeGetSeconds(playerItem.duration) guard !currentSeconds.isNaN, !totalSeconds.isNaN, totalSeconds > 0 else { return } if currentSeconds == totalSeconds { if loop { // 循环播放 videoPlayer.pause() videoPlayer.seek(to: .zero) videoPlayer.play() } else { // 自动停止 stop() } } else { delegate?.onVideoProgressChanged(view: self, cur: currentSeconds, total: totalSeconds) } } } }