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:
agra
2026-05-23 15:57:15 +03:00
parent 96df891b9d
commit de4925adf9
28 changed files with 4243 additions and 14 deletions

View File

@@ -0,0 +1,55 @@
import CoreVideo
import Foundation
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// Single-slot latest-pixel-buffer sink that feeds a `FlutterTexture`.
/// Mirrors `PreviewSink` in `darwin/Camera/` receiver writes, the
/// engine reads via `copyPixelBuffer()`. We retain only the most
/// recent buffer; older ones get released the moment a new frame
/// arrives, bounding memory at one frame.
final class UxVideoPixelBufferSink: NSObject, FlutterTexture {
private weak var registry: FlutterTextureRegistry?
private var textureId: Int64 = -1
private let bufferQueue = DispatchQueue(
label: "ux.video.buffer",
qos: .userInitiated
)
private var latestPixelBuffer: CVPixelBuffer?
func register(with registry: FlutterTextureRegistry) -> Int64 {
self.registry = registry
textureId = registry.register(self)
return textureId
}
func unregister() {
registry?.unregisterTexture(textureId)
bufferQueue.sync { latestPixelBuffer = nil }
}
// MARK: - FlutterTexture
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
var pb: CVPixelBuffer?
bufferQueue.sync {
pb = latestPixelBuffer
latestPixelBuffer = nil
}
if let pb = pb { return Unmanaged.passRetained(pb) }
return nil
}
/// Receives a new frame from the AVPlayerItemVideoOutput pump.
/// Cheap just swaps the pointer + notifies the registry.
func deliver(pixelBuffer: CVPixelBuffer) {
bufferQueue.sync { latestPixelBuffer = pixelBuffer }
if let registry = registry {
registry.textureFrameAvailable(textureId)
}
}
}

View 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)
}
}

View File

@@ -0,0 +1,205 @@
import AVFoundation
import Foundation
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// `ux/video` + `ux/video/events` registrar. Routes channel calls to
/// per-handle [UxVideoPlayerInstance]s. Mirrors `CameraPlugin`'s shape.
public class UxVideoPlayerPlugin: NSObject, NativePlugin, FlutterStreamHandler {
private weak var textureRegistry: FlutterTextureRegistry?
private var instances: [Int: UxVideoPlayerInstance] = [:]
private var nextHandle: Int = 1
private var eventSink: FlutterEventSink?
public func register(with registrar: FlutterPluginRegistrar) {
textureRegistry = registrar.uxTextures
let methods = FlutterMethodChannel(
name: "ux/video",
binaryMessenger: registrar.uxMessenger,
)
methods.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
let events = FlutterEventChannel(
name: "ux/video/events",
binaryMessenger: registrar.uxMessenger,
)
events.setStreamHandler(self)
}
// MARK: - FlutterStreamHandler
public func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink,
) -> FlutterError? {
eventSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
// MARK: - Dispatch
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "create":
create(args: call.arguments, result: result)
case "initialize":
withInstance(call.arguments, result: result) { instance in
instance.prepare { error, size, durationMs, rotationQuarterTurns in
DispatchQueue.main.async {
if let error = error {
result(
FlutterError(
code: "decode_failed",
message: error.localizedDescription,
details: nil,
)
)
return
}
result(
[
"size": [
"width": Double(size.width),
"height": Double(size.height),
] as [String: Any],
"durationMs": durationMs,
"rotationQuarterTurns": rotationQuarterTurns,
] as [String: Any]
)
}
}
}
case "dispose":
withInstance(call.arguments, result: result) { instance in
self.instances.removeValue(forKey: instance.handle)
instance.dispose()
result(nil)
}
case "play":
withInstance(call.arguments, result: result) { instance in
instance.play()
result(nil)
}
case "pause":
withInstance(call.arguments, result: result) { instance in
instance.pause()
result(nil)
}
case "seekTo":
guard let args = call.arguments as? [String: Any],
let pos = (args["positionMs"] as? NSNumber)?.int64Value
else {
result(badArgs("seekTo"))
return
}
withInstance(args, result: result) { instance in
instance.seekTo(positionMs: pos)
result(nil)
}
case "setLooping":
guard let args = call.arguments as? [String: Any],
let loop = args["loop"] as? Bool
else {
result(badArgs("setLooping"))
return
}
withInstance(args, result: result) { instance in
instance.setLooping(loop)
result(nil)
}
case "setVolume":
guard let args = call.arguments as? [String: Any],
let volume = (args["volume"] as? NSNumber)?.floatValue
else {
result(badArgs("setVolume"))
return
}
withInstance(args, result: result) { instance in
instance.setVolume(volume)
result(nil)
}
case "setPlaybackSpeed":
guard let args = call.arguments as? [String: Any],
let rate = (args["rate"] as? NSNumber)?.floatValue
else {
result(badArgs("setPlaybackSpeed"))
return
}
withInstance(args, result: result) { instance in
instance.setPlaybackSpeed(rate)
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
private func create(args: Any?, result: @escaping FlutterResult) {
guard let dict = args as? [String: Any],
let uri = dict["uri"] as? String,
let registry = textureRegistry
else {
result(badArgs("create"))
return
}
guard let url = URL(string: uri) else {
result(badArgs("create uri"))
return
}
let handle = nextHandle
nextHandle += 1
let instance = UxVideoPlayerInstance(handle: handle, registry: registry)
instance.onEvent = { [weak self] payload in
DispatchQueue.main.async {
self?.eventSink?(payload)
}
}
instance.setSource(url: url)
instances[handle] = instance
result(
[
"handle": handle,
"textureId": instance.textureId,
] as [String: Any]
)
}
private func withInstance(
_ args: Any?,
result: @escaping FlutterResult,
body: (UxVideoPlayerInstance) -> Void,
) {
guard let dict = args as? [String: Any],
let handle = (dict["handle"] as? NSNumber)?.intValue
else {
result(badArgs("missing handle"))
return
}
guard let instance = instances[handle] else {
result(
FlutterError(
code: "disposed",
message: "no player for handle \(handle)",
details: nil,
)
)
return
}
body(instance)
}
private func badArgs(_ method: String) -> FlutterError {
FlutterError(code: "bad_args", message: method, details: nil)
}
}