| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268 |
- //
- // 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)
- }
- }
- }
- }
|