video_player + insets: native playback backend + animated viewPadding
- video_player: ExoPlayer (Android) / AVPlayer (iOS/macOS) backend with PixelBufferSink, method-channel adapter, Dart-side XVideoPlayer + testing fake. - insets: XInsets singleton + XAnimatedInsets widget lerp the system viewPadding over 220ms so OS bar visibility toggles (immersiveSticky <-> edgeToEdge) slide bottom-/top-anchored UI into place instead of snapping by the nav-bar / status-bar height.
This commit is contained in:
431
ios/Classes/Video-shared/VideoPlayerInstance.swift
Normal file
431
ios/Classes/Video-shared/VideoPlayerInstance.swift
Normal file
@@ -0,0 +1,431 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import QuartzCore
|
||||
#if canImport(UIKit)
|
||||
import Flutter
|
||||
import UIKit
|
||||
#else
|
||||
import FlutterMacOS
|
||||
#endif
|
||||
|
||||
/// One per Dart-side `XVideoPlayerController`. Owns the `AVPlayer`,
|
||||
/// an `AVPlayerItemVideoOutput` that pumps `CVPixelBuffer`s into the
|
||||
/// Flutter texture, and a periodic timer that fires position +
|
||||
/// buffered events back to Dart.
|
||||
///
|
||||
/// Mirrors the shape of `CameraInstance` — main-thread methods,
|
||||
/// per-handle event payloads tagged with `handle`.
|
||||
final class UxVideoPlayerInstance: NSObject {
|
||||
let handle: Int
|
||||
let textureId: Int64
|
||||
|
||||
/// Pushed to Dart as `{event, handle, …}` payloads. Set by the
|
||||
/// plugin in `create`.
|
||||
var onEvent: ((/* payload */ [String: Any?]) -> Void)?
|
||||
|
||||
private weak var textureRegistry: FlutterTextureRegistry?
|
||||
private let sink: UxVideoPixelBufferSink
|
||||
private let player = AVPlayer()
|
||||
private var item: AVPlayerItem?
|
||||
private var videoOutput: AVPlayerItemVideoOutput?
|
||||
private var pumpTimer: Timer?
|
||||
private var positionTimer: Timer?
|
||||
private var prepareCompletion: ((Error?, CGSize, Int64, Int) -> Void)?
|
||||
|
||||
/// Quarter-turns of clockwise rotation the Flutter `Texture` needs
|
||||
/// so the rendered frame reads upright. AVPlayerItemVideoOutput
|
||||
/// delivers `CVPixelBuffer`s in the file's *natural* orientation —
|
||||
/// the file's `preferredTransform` rotation is NOT applied — while
|
||||
/// `item.presentationSize` already reflects the rotated dimensions.
|
||||
/// Without surfacing this to Dart, the gallery wraps a
|
||||
/// portrait-displayed-as-landscape buffer in a portrait
|
||||
/// `AspectRatio` and stretches it. Computed from the first video
|
||||
/// track's `preferredTransform` in [setSource]; reported back via
|
||||
/// [prepare]'s completion alongside the presentation size.
|
||||
private var rotationQuarterTurns: Int = 0
|
||||
|
||||
private var disposed = false
|
||||
private var lastReportedIsPlaying = false
|
||||
private var lastReportedIsBuffering = false
|
||||
private var lastReportedSize: CGSize = .zero
|
||||
private var lastReportedDurationMs: Int64 = -1
|
||||
private var notifiedCompleted = false
|
||||
|
||||
private static let pumpHz: TimeInterval = 1.0 / 60.0
|
||||
private static let positionHz: TimeInterval = 1.0 / 10.0
|
||||
|
||||
/// `kCVPixelFormatType_32BGRA` keeps the Flutter texture path
|
||||
/// happy — Flutter's iOS/macOS embedder expects BGRA8888.
|
||||
private static let pixelBufferAttributes: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary,
|
||||
]
|
||||
|
||||
init(handle: Int, registry: FlutterTextureRegistry) {
|
||||
self.handle = handle
|
||||
self.textureRegistry = registry
|
||||
self.sink = UxVideoPixelBufferSink()
|
||||
self.textureId = sink.register(with: registry)
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Source / prepare
|
||||
|
||||
func setSource(url: URL) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
rotationQuarterTurns = Self.rotationQuarterTurns(for: asset)
|
||||
let newItem = AVPlayerItem(asset: asset)
|
||||
let output = AVPlayerItemVideoOutput(
|
||||
pixelBufferAttributes: Self.pixelBufferAttributes,
|
||||
)
|
||||
newItem.add(output)
|
||||
|
||||
item = newItem
|
||||
videoOutput = output
|
||||
player.replaceCurrentItem(with: newItem)
|
||||
|
||||
addObservers(item: newItem)
|
||||
}
|
||||
|
||||
/// Maps the first video track's `preferredTransform` to quarter
|
||||
/// turns of clockwise rotation. `atan2(b, a)` recovers the rotation
|
||||
/// angle from the affine transform's rotation/scale components;
|
||||
/// rounding to the nearest 90° gives a stable enum-like value
|
||||
/// (`Int` mod 4) for the Dart-side `RotatedBox`.
|
||||
private static func rotationQuarterTurns(for asset: AVAsset) -> Int {
|
||||
guard let track = asset.tracks(withMediaType: .video).first else { return 0 }
|
||||
let t = track.preferredTransform
|
||||
let radians = atan2(t.b, t.a)
|
||||
let degrees = radians * 180.0 / .pi
|
||||
let normalized = (Int(degrees.rounded()) % 360 + 360) % 360
|
||||
return (normalized / 90) % 4
|
||||
}
|
||||
|
||||
func prepare(completion: @escaping (Error?, CGSize, Int64, Int) -> Void) {
|
||||
guard let item = item else {
|
||||
completion(
|
||||
NSError(
|
||||
domain: "ux.video",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "no item"],
|
||||
),
|
||||
.zero,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
return
|
||||
}
|
||||
prepareCompletion = completion
|
||||
// If we already passed .readyToPlay, fire immediately.
|
||||
if item.status == .readyToPlay {
|
||||
let dims = item.presentationSize
|
||||
let durMs = Self.toMs(item.duration)
|
||||
prepareCompletion = nil
|
||||
startPumping()
|
||||
startPositionTimer()
|
||||
completion(nil, dims, durMs, rotationQuarterTurns)
|
||||
}
|
||||
// Otherwise the KVO on `status` will resolve us when ready.
|
||||
}
|
||||
|
||||
// MARK: - Controls
|
||||
|
||||
func play() {
|
||||
guard !disposed else { return }
|
||||
player.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard !disposed else { return }
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func seekTo(positionMs: Int64) {
|
||||
guard !disposed else { return }
|
||||
let time = CMTime(value: max(0, positionMs), timescale: 1000)
|
||||
player.seek(
|
||||
to: time,
|
||||
toleranceBefore: .zero,
|
||||
toleranceAfter: .zero,
|
||||
)
|
||||
emitPosition(forcePositionMs: positionMs)
|
||||
}
|
||||
|
||||
func setLooping(_ loop: Bool) {
|
||||
guard !disposed else { return }
|
||||
loopingEnabled = loop
|
||||
}
|
||||
|
||||
func setVolume(_ volume: Float) {
|
||||
guard !disposed else { return }
|
||||
player.volume = max(0, min(1, volume))
|
||||
}
|
||||
|
||||
func setPlaybackSpeed(_ rate: Float) {
|
||||
guard !disposed else { return }
|
||||
let clamped = max(0.25, min(4.0, rate))
|
||||
defaultRate = clamped
|
||||
if player.rate != 0 {
|
||||
player.rate = clamped
|
||||
}
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
guard !disposed else { return }
|
||||
disposed = true
|
||||
|
||||
pumpTimer?.invalidate()
|
||||
pumpTimer = nil
|
||||
positionTimer?.invalidate()
|
||||
positionTimer = nil
|
||||
|
||||
if let item = item {
|
||||
removeObservers(item: item)
|
||||
}
|
||||
if let videoOutput = videoOutput, let item = item {
|
||||
item.remove(videoOutput)
|
||||
}
|
||||
videoOutput = nil
|
||||
item = nil
|
||||
player.replaceCurrentItem(with: nil)
|
||||
sink.unregister()
|
||||
onEvent = nil
|
||||
}
|
||||
|
||||
// MARK: - Observers / completion
|
||||
|
||||
private var loopingEnabled = false
|
||||
private var defaultRate: Float = 1.0
|
||||
private var statusContext = 0
|
||||
private var bufferEmptyContext = 1
|
||||
private var likelyToKeepUpContext = 2
|
||||
private var rateContext = 3
|
||||
|
||||
private func addObservers(item: AVPlayerItem) {
|
||||
item.addObserver(
|
||||
self,
|
||||
forKeyPath: "status",
|
||||
options: [.new, .initial],
|
||||
context: &statusContext,
|
||||
)
|
||||
item.addObserver(
|
||||
self,
|
||||
forKeyPath: "playbackBufferEmpty",
|
||||
options: [.new],
|
||||
context: &bufferEmptyContext,
|
||||
)
|
||||
item.addObserver(
|
||||
self,
|
||||
forKeyPath: "playbackLikelyToKeepUp",
|
||||
options: [.new],
|
||||
context: &likelyToKeepUpContext,
|
||||
)
|
||||
player.addObserver(
|
||||
self,
|
||||
forKeyPath: "rate",
|
||||
options: [.new],
|
||||
context: &rateContext,
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(playbackEnded(_:)),
|
||||
name: .AVPlayerItemDidPlayToEndTime,
|
||||
object: item,
|
||||
)
|
||||
}
|
||||
|
||||
private func removeObservers(item: AVPlayerItem) {
|
||||
item.removeObserver(self, forKeyPath: "status", context: &statusContext)
|
||||
item.removeObserver(self, forKeyPath: "playbackBufferEmpty", context: &bufferEmptyContext)
|
||||
item.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp", context: &likelyToKeepUpContext)
|
||||
player.removeObserver(self, forKeyPath: "rate", context: &rateContext)
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: .AVPlayerItemDidPlayToEndTime,
|
||||
object: item,
|
||||
)
|
||||
}
|
||||
|
||||
override func observeValue(
|
||||
forKeyPath keyPath: String?,
|
||||
of object: Any?,
|
||||
change: [NSKeyValueChangeKey: Any]?,
|
||||
context: UnsafeMutableRawPointer?,
|
||||
) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, !self.disposed else { return }
|
||||
self.handleObservation(keyPath: keyPath, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleObservation(keyPath: String?, context: UnsafeMutableRawPointer?) {
|
||||
if context == &statusContext, let item = item {
|
||||
switch item.status {
|
||||
case .readyToPlay:
|
||||
if let completion = prepareCompletion {
|
||||
prepareCompletion = nil
|
||||
let dims = item.presentationSize
|
||||
let durMs = Self.toMs(item.duration)
|
||||
lastReportedSize = dims
|
||||
lastReportedDurationMs = durMs
|
||||
startPumping()
|
||||
startPositionTimer()
|
||||
emitSizeChanged(dims)
|
||||
completion(nil, dims, durMs, rotationQuarterTurns)
|
||||
}
|
||||
case .failed:
|
||||
if let completion = prepareCompletion {
|
||||
prepareCompletion = nil
|
||||
completion(
|
||||
item.error
|
||||
?? NSError(
|
||||
domain: "ux.video",
|
||||
code: -2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "AVPlayerItem failed"],
|
||||
),
|
||||
.zero,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
} else if let error = item.error {
|
||||
emit([
|
||||
"event": "error",
|
||||
"code": "playback_failed",
|
||||
"description": error.localizedDescription,
|
||||
])
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else if context == &bufferEmptyContext, let item = item {
|
||||
let buffering = item.isPlaybackBufferEmpty
|
||||
if buffering != lastReportedIsBuffering {
|
||||
lastReportedIsBuffering = buffering
|
||||
emit([
|
||||
"event": "stateChanged",
|
||||
"isBuffering": buffering,
|
||||
"positionMs": currentPositionMs(),
|
||||
])
|
||||
}
|
||||
} else if context == &likelyToKeepUpContext, let item = item {
|
||||
if item.isPlaybackLikelyToKeepUp && lastReportedIsBuffering {
|
||||
lastReportedIsBuffering = false
|
||||
emit([
|
||||
"event": "stateChanged",
|
||||
"isBuffering": false,
|
||||
"positionMs": currentPositionMs(),
|
||||
])
|
||||
}
|
||||
} else if context == &rateContext {
|
||||
let playing = player.rate != 0
|
||||
if playing != lastReportedIsPlaying {
|
||||
lastReportedIsPlaying = playing
|
||||
emit([
|
||||
"event": "stateChanged",
|
||||
"isPlaying": playing,
|
||||
"positionMs": currentPositionMs(),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func playbackEnded(_ note: Notification) {
|
||||
if loopingEnabled {
|
||||
player.seek(to: .zero) { [weak self] _ in
|
||||
self?.player.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
if !notifiedCompleted {
|
||||
notifiedCompleted = true
|
||||
emit([
|
||||
"event": "completed",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pumps
|
||||
|
||||
private func startPumping() {
|
||||
pumpTimer?.invalidate()
|
||||
let timer = Timer(timeInterval: Self.pumpHz, repeats: true) { [weak self] _ in
|
||||
self?.pumpFrame()
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
pumpTimer = timer
|
||||
}
|
||||
|
||||
private func startPositionTimer() {
|
||||
positionTimer?.invalidate()
|
||||
let timer = Timer(timeInterval: Self.positionHz, repeats: true) { [weak self] _ in
|
||||
self?.emitPosition()
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
positionTimer = timer
|
||||
}
|
||||
|
||||
private func pumpFrame() {
|
||||
guard let videoOutput = videoOutput else { return }
|
||||
let host = videoOutput.itemTime(forHostTime: CACurrentMediaTime())
|
||||
guard videoOutput.hasNewPixelBuffer(forItemTime: host) else { return }
|
||||
guard let pixelBuffer = videoOutput.copyPixelBuffer(
|
||||
forItemTime: host,
|
||||
itemTimeForDisplay: nil,
|
||||
) else { return }
|
||||
sink.deliver(pixelBuffer: pixelBuffer)
|
||||
}
|
||||
|
||||
private func emitPosition(forcePositionMs: Int64? = nil) {
|
||||
let positionMs = forcePositionMs ?? currentPositionMs()
|
||||
let buffered = bufferedRanges()
|
||||
emit([
|
||||
"event": "stateChanged",
|
||||
"positionMs": positionMs,
|
||||
"buffered": buffered,
|
||||
])
|
||||
}
|
||||
|
||||
private func bufferedRanges() -> [[String: Int64]] {
|
||||
guard let item = item else { return [] }
|
||||
return item.loadedTimeRanges.map { value -> [String: Int64] in
|
||||
let r = value.timeRangeValue
|
||||
let startMs = Int64(CMTimeGetSeconds(r.start) * 1000.0)
|
||||
let endMs = Int64(CMTimeGetSeconds(r.start + r.duration) * 1000.0)
|
||||
return [
|
||||
"startMs": max(0, startMs),
|
||||
"endMs": max(0, endMs),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private func currentPositionMs() -> Int64 {
|
||||
let t = player.currentTime()
|
||||
return Int64(max(0, CMTimeGetSeconds(t)) * 1000.0)
|
||||
}
|
||||
|
||||
private func emitSizeChanged(_ size: CGSize) {
|
||||
emit([
|
||||
"event": "sizeChanged",
|
||||
"size": [
|
||||
"width": Double(size.width),
|
||||
"height": Double(size.height),
|
||||
] as [String: Any],
|
||||
])
|
||||
}
|
||||
|
||||
private func emit(_ extras: [String: Any?]) {
|
||||
var payload = extras
|
||||
payload["handle"] = handle
|
||||
onEvent?(payload)
|
||||
}
|
||||
|
||||
private static func toMs(_ t: CMTime) -> Int64 {
|
||||
guard t.isValid, !t.isIndefinite else { return 0 }
|
||||
let seconds = CMTimeGetSeconds(t)
|
||||
guard seconds.isFinite, !seconds.isNaN else { return 0 }
|
||||
return Int64(max(0, seconds) * 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user