Files
App/ios/Runner/Views/VideoPlayerView.swift

201 lines
7.9 KiB
Swift

import Flutter
import UIKit
import AVKit
// Factory to create the native video player view
class VideoPlayerViewFactory: NSObject, FlutterPlatformViewFactory {
private var messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
return VideoPlayerView(
frame: frame,
viewIdentifier: viewId,
arguments: args,
binaryMessenger: messenger)
}
// This is required by the protocol, but we don't need to implement it for this case
public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
return FlutterStandardMessageCodec.sharedInstance()
}
}
// The actual PlatformView that holds the AVPlayerViewController
class VideoPlayerView: NSObject, FlutterPlatformView {
private var playerViewController: AVPlayerViewController?
private var player: AVPlayer?
private var activityIndicator: UIActivityIndicatorView!
private var progressLabel: UILabel!
private var progressStack: UIStackView!
// KVO contexts
private var playerStatusContext = 0
private var playerLoadedTimeRangesContext = 0
init(
frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?,
binaryMessenger messenger: FlutterBinaryMessenger?
) {
super.init()
// Ensure we have a valid URL from Flutter
guard let args = args as? [String: Any],
let videoUrlString = args["videoUrl"] as? String,
let videoUrl = URL(string: videoUrlString) else {
// Initialize playerViewController even if URL is invalid
playerViewController = AVPlayerViewController()
playerViewController!.showsPlaybackControls = false // Hide controls for invalid URL
let label = UILabel()
label.text = "Invalid video URL"
label.textAlignment = .center
label.textColor = .white
label.translatesAutoresizingMaskIntoConstraints = false
playerViewController!.contentOverlayView?.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: playerViewController!.contentOverlayView!.centerXAnchor),
label.centerYAnchor.constraint(equalTo: playerViewController!.contentOverlayView!.centerYAnchor)
])
return
}
// --- Player ---
player = AVPlayer(url: videoUrl)
// --- PlayerViewController ---
playerViewController = AVPlayerViewController()
playerViewController!.player = player
playerViewController!.view.frame = frame // Set the frame directly
playerViewController!.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
playerViewController!.showsPlaybackControls = true
// --- Loading Indicator (spinner) ---
activityIndicator = UIActivityIndicatorView(style: .large)
activityIndicator.color = .white
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
activityIndicator.isUserInteractionEnabled = false // Allow touches to pass through
// --- Progress Percentage Label ---
progressLabel = UILabel()
progressLabel.textColor = .white
progressLabel.textAlignment = .center
progressLabel.translatesAutoresizingMaskIntoConstraints = false
progressLabel.isUserInteractionEnabled = false // Allow touches to pass through
playerViewController!.contentOverlayView?.addSubview(activityIndicator)
playerViewController!.contentOverlayView?.addSubview(progressLabel)
// Center the activity indicator and place the progress label below it
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: playerViewController!.contentOverlayView!.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: playerViewController!.contentOverlayView!.centerYAnchor),
progressLabel.topAnchor.constraint(equalTo: activityIndicator.bottomAnchor, constant: 16),
progressLabel.centerXAnchor.constraint(equalTo: playerViewController!.contentOverlayView!.centerXAnchor),
])
// Add Key-Value Observers
addObservers()
activityIndicator.startAnimating()
}
func view() -> UIView {
return playerViewController!.view
}
private func addObservers() {
player?.addObserver(self, forKeyPath: #keyPath(AVPlayer.status), options: [.new, .initial], context: &playerStatusContext)
player?.currentItem?.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.loadedTimeRanges), options: [.new], context: &playerLoadedTimeRangesContext)
}
private func removeObservers() {
// Check if observers are registered before removing them to avoid crashes.
// A simple way is to use a flag or check the player object, but for KVO,
// it's often safer to just ensure they are added once and removed once.
// Given the lifecycle here, direct removal in deinit is okay.
player?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.status), context: &playerStatusContext)
player?.currentItem?.removeObserver(self, forKeyPath: #keyPath(AVPlayerItem.loadedTimeRanges), context: &playerLoadedTimeRangesContext)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
// Dispatch to main queue to ensure thread safety
DispatchQueue.main.async {
guard self.player != nil else {
return
}
if context == &self.playerStatusContext {
self.handlePlayerStatusChange(change: change)
} else if context == &self.playerLoadedTimeRangesContext {
self.handleLoadedTimeRangesChange()
}
}
}
private func handlePlayerStatusChange(change: [NSKeyValueChangeKey : Any]?) {
guard let statusValue = change?[.newKey] as? Int, let status = AVPlayer.Status(rawValue: statusValue) else { return }
DispatchQueue.main.async {
switch status {
case .readyToPlay:
self.activityIndicator.stopAnimating()
self.progressLabel.isHidden = true
self.player?.play()
case .failed:
self.activityIndicator.stopAnimating()
// Optionally: show an error message to the user
let label = UILabel()
label.text = "Failed to load video"
label.textColor = .white
label.textAlignment = .center
label.frame = self.playerViewController!.view.bounds
self.playerViewController!.view.addSubview(label)
case .unknown:
self.activityIndicator.startAnimating()
@unknown default:
break
}
}
}
private func handleLoadedTimeRangesChange() {
guard let playerItem = player?.currentItem,
let timeRange = playerItem.loadedTimeRanges.first?.timeRangeValue,
!CMTIME_IS_INDEFINITE(playerItem.duration) else {
return
}
let startSeconds = CMTimeGetSeconds(timeRange.start)
let durationSeconds = CMTimeGetSeconds(timeRange.duration)
let totalBuffer = startSeconds + durationSeconds
let totalDuration = CMTimeGetSeconds(playerItem.duration)
let progress = Float(totalBuffer / totalDuration)
DispatchQueue.main.async {
self.progressLabel.text = "\(Int(progress * 100))%"
if progress >= 0.99 {
self.progressLabel.isHidden = true
}
}
}
deinit {
removeObservers()
}
}