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:
55
ios/Classes/Video-shared/VideoPixelBufferSink.swift
Normal file
55
ios/Classes/Video-shared/VideoPixelBufferSink.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
205
ios/Classes/Video-shared/VideoPlayerPlugin.swift
Normal file
205
ios/Classes/Video-shared/VideoPlayerPlugin.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user