extended-vlc-player 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/android/build.gradle +36 -0
  4. package/android/src/main/AndroidManifest.xml +16 -0
  5. package/android/src/main/java/expo/modules/extendedvlcplayer/ExtendedVlcPlayerModule.kt +99 -0
  6. package/android/src/main/java/expo/modules/extendedvlcplayer/ExtendedVlcPlayerViewComponentView.kt +82 -0
  7. package/android/src/main/java/expo/modules/extendedvlcplayer/PlayerRegistry.kt +47 -0
  8. package/android/src/main/java/expo/modules/extendedvlcplayer/PlayerSession.kt +168 -0
  9. package/app.plugin.js +144 -0
  10. package/build/ExtendedVlcPlayerView.d.ts +8 -0
  11. package/build/ExtendedVlcPlayerView.d.ts.map +1 -0
  12. package/build/ExtendedVlcPlayerView.js +30 -0
  13. package/build/ExtendedVlcPlayerView.js.map +1 -0
  14. package/build/index.d.ts +12 -0
  15. package/build/index.d.ts.map +1 -0
  16. package/build/index.js +11 -0
  17. package/build/index.js.map +1 -0
  18. package/build/types.d.ts +97 -0
  19. package/build/types.d.ts.map +1 -0
  20. package/build/types.js +2 -0
  21. package/build/types.js.map +1 -0
  22. package/build/useExtendedVlcPlayer.d.ts +12 -0
  23. package/build/useExtendedVlcPlayer.d.ts.map +1 -0
  24. package/build/useExtendedVlcPlayer.js +137 -0
  25. package/build/useExtendedVlcPlayer.js.map +1 -0
  26. package/expo-module.config.json +9 -0
  27. package/ios/AudioSessionConfigurator.swift +25 -0
  28. package/ios/ExtendedVlcPlayer.podspec +32 -0
  29. package/ios/ExtendedVlcPlayerModule.swift +130 -0
  30. package/ios/ExtendedVlcPlayerViewComponentView.h +19 -0
  31. package/ios/ExtendedVlcPlayerViewComponentView.mm +101 -0
  32. package/ios/PipBridge.swift +144 -0
  33. package/ios/PlayerRegistryBridge.swift +67 -0
  34. package/ios/PlayerSession.swift +335 -0
  35. package/package.json +65 -0
  36. package/src/ExtendedVlcPlayerView.tsx +68 -0
  37. package/src/index.ts +20 -0
  38. package/src/types.ts +100 -0
  39. package/src/useExtendedVlcPlayer.ts +160 -0
@@ -0,0 +1,101 @@
1
+ #import "ExtendedVlcPlayerViewComponentView.h"
2
+ #import "ExtendedVlcPlayer-Swift.h"
3
+
4
+ #import <React/RCTConversions.h>
5
+ #import <React/RCTLog.h>
6
+
7
+ @interface ExtendedVlcPlayerViewComponentView () <ExtendedVlcPlayerViewEventReceiver>
8
+ @property (nonatomic, strong) NSNumber *playerId;
9
+ /// Strong refs to the event blocks the JS side passes via props. Keeping
10
+ /// strong references prevents them from being collected by the runtime.
11
+ @property (nonatomic, copy) RCTBubblingEventBlock onLoadBlock;
12
+ @property (nonatomic, copy) RCTBubblingEventBlock onProgressBlock;
13
+ @property (nonatomic, copy) RCTBubblingEventBlock onPlayingBlock;
14
+ @property (nonatomic, copy) RCTBubblingEventBlock onPausedBlock;
15
+ @property (nonatomic, copy) RCTBubblingEventBlock onEndedBlock;
16
+ @property (nonatomic, copy) RCTBubblingEventBlock onErrorBlock;
17
+ @property (nonatomic, copy) RCTBubblingEventBlock onBufferingBlock;
18
+ @property (nonatomic, copy) RCTBubblingEventBlock onPictureInPictureStartBlock;
19
+ @property (nonatomic, copy) RCTBubblingEventBlock onPictureInPictureStopBlock;
20
+ @end
21
+
22
+ @implementation ExtendedVlcPlayerViewComponentView
23
+
24
+ RCT_EXPORT_VIEW_PROPERTY(player, NSNumber)
25
+ RCT_EXPORT_VIEW_PROPERTY(contentFit, NSDictionary)
26
+ RCT_EXPORT_VIEW_PROPERTY(onLoad, RCTBubblingEventBlock)
27
+ RCT_EXPORT_VIEW_PROPERTY(onProgress, RCTBubblingEventBlock)
28
+ RCT_EXPORT_VIEW_PROPERTY(onPlaying, RCTBubblingEventBlock)
29
+ RCT_EXPORT_VIEW_PROPERTY(onPaused, RCTBubblingEventBlock)
30
+ RCT_EXPORT_VIEW_PROPERTY(onEnded, RCTBubblingEventBlock)
31
+ RCT_EXPORT_VIEW_PROPERTY(onError, RCTBubblingEventBlock)
32
+ RCT_EXPORT_VIEW_PROPERTY(onBuffering, RCTBubblingEventBlock)
33
+ RCT_EXPORT_VIEW_PROPERTY(onPictureInPictureStart, RCTBubblingEventBlock)
34
+ RCT_EXPORT_VIEW_PROPERTY(onPictureInPictureStop, RCTBubblingEventBlock)
35
+
36
+ + (ComponentDescriptorProvider)componentDescriptorProvider
37
+ {
38
+ return [RCTViewComponentView new];
39
+ }
40
+
41
+ - (instancetype)initWithFrame:(CGRect)frame
42
+ {
43
+ if (self = [super initWithFrame:frame]) {
44
+ self.contentView.backgroundColor = UIColor.blackColor;
45
+ }
46
+ return self;
47
+ }
48
+
49
+ - (void)didMoveToWindow
50
+ {
51
+ [super didMoveToWindow];
52
+ if (self.window != nil && self.playerId == nil) {
53
+ NSNumber *newId = [EXVLCPlayerRegistryBridge createSession];
54
+ self.playerId = newId;
55
+ NSValue *boxed = [EXVLCPlayerRegistryBridge sessionForId:newId];
56
+ PlayerSession *session = (PlayerSession *)[boxed nonretainedObjectValue];
57
+ if (session != nil) {
58
+ session.drawable.frame = self.contentView.bounds;
59
+ session.drawable.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
60
+ [self.contentView addSubview:session.drawable];
61
+ [EXVLCPlayerRegistryBridge attachEventSinks:newId view:self];
62
+ }
63
+ }
64
+ }
65
+
66
+ - (void)dealloc
67
+ {
68
+ if (self.playerId != nil) {
69
+ [EXVLCPlayerRegistryBridge destroySession:self.playerId];
70
+ }
71
+ }
72
+
73
+ - (void)updateLayout
74
+ {
75
+ [super updateLayout];
76
+ if (self.playerId != nil) {
77
+ NSValue *boxed = [EXVLCPlayerRegistryBridge sessionForId:self.playerId];
78
+ PlayerSession *session = (PlayerSession *)[boxed nonretainedObjectValue];
79
+ if (session != nil) {
80
+ session.drawable.frame = self.contentView.bounds;
81
+ }
82
+ }
83
+ }
84
+
85
+ #pragma mark - ExtendedVlcPlayerViewEventReceiver
86
+
87
+ - (void)exvlcEmit:(NSString *)name payload:(NSDictionary *)payload
88
+ {
89
+ NSDictionary *p = payload ?: @{};
90
+ if ([name isEqualToString:@"onLoad"] && self.onLoadBlock) { self.onLoadBlock(p); return; }
91
+ if ([name isEqualToString:@"onProgress"] && self.onProgressBlock) { self.onProgressBlock(p); return; }
92
+ if ([name isEqualToString:@"onPlaying"] && self.onPlayingBlock) { self.onPlayingBlock(p); return; }
93
+ if ([name isEqualToString:@"onPaused"] && self.onPausedBlock) { self.onPausedBlock(p); return; }
94
+ if ([name isEqualToString:@"onEnded"] && self.onEndedBlock) { self.onEndedBlock(p); return; }
95
+ if ([name isEqualToString:@"onError"] && self.onErrorBlock) { self.onErrorBlock(p); return; }
96
+ if ([name isEqualToString:@"onBuffering"] && self.onBufferingBlock) { self.onBufferingBlock(p); return; }
97
+ if ([name isEqualToString:@"onPictureInPictureStart"] && self.onPictureInPictureStartBlock) { self.onPictureInPictureStartBlock(p); return; }
98
+ if ([name isEqualToString:@"onPictureInPictureStop"] && self.onPictureInPictureStopBlock) { self.onPictureInPictureStopBlock(p); return; }
99
+ }
100
+
101
+ @end
@@ -0,0 +1,144 @@
1
+ import AVFoundation
2
+ import CoreImage
3
+ import CoreVideo
4
+ import Foundation
5
+ import UIKit
6
+
7
+ /// Bridges UIImage snapshots (from VLC) to a stream of CMSampleBuffers
8
+ /// that are enqueued onto an `AVSampleBufferDisplayLayer`.
9
+ ///
10
+ /// MobileVLCKit does not expose decoded frames as `CVPixelBufferRef`. The
11
+ /// only public way to get a rendered frame is the snapshot API, which
12
+ /// returns a `UIImage`. This bridge converts that UIImage into a pooled
13
+ /// `CVPixelBuffer` and a `CMSampleBuffer`, then enqueues it. The
14
+ /// `AVSampleBufferDisplayLayer` is then used as the source for
15
+ /// `AVPictureInPictureController.ContentSource` (set up by the
16
+ /// `PlayerSession`).
17
+ final class PipBridge {
18
+ private let displayLayer: AVSampleBufferDisplayLayer
19
+ private let ciContext = CIContext(options: [.useSoftwareRenderer: false])
20
+ private var pixelBufferPool: CVPixelBufferPool?
21
+ private let poolAttributes: [String: Any] = [
22
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
23
+ kCVPixelBufferWidthKey as String: 640,
24
+ kCVPixelBufferHeightKey as String: 360,
25
+ kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary,
26
+ ]
27
+ private let bufferAttributes: [String: Any] = [
28
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
29
+ kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary,
30
+ ]
31
+
32
+ /// Tracks the most recent enqueue's host time so we can compute the
33
+ /// presentation timestamp of the next sample buffer.
34
+ private var lastEnqueueTime: CFTimeInterval = 0
35
+ /// Frame counter used to make the format description identifier stable
36
+ /// across re-enqueues; AVPictureInPictureContentSource complains if the
37
+ /// format description changes between enqueues.
38
+ private var formatDescription: CMFormatDescription?
39
+ private var formatDescriptionWidth: Int = 0
40
+ private var formatDescriptionHeight: Int = 0
41
+
42
+ init(displayLayer: AVSampleBufferDisplayLayer) {
43
+ self.displayLayer = displayLayer
44
+ }
45
+
46
+ /// Call with a fresh UIImage from VLC. Performs all conversion on the
47
+ /// caller's queue (expected: main thread, since UIImage decoding on
48
+ /// other queues is expensive and can produce memory pressure spikes).
49
+ func feed(image: UIImage) {
50
+ guard let cgImage = image.cgImage else { return }
51
+ let width = cgImage.width
52
+ let height = cgImage.height
53
+ if width <= 0 || height <= 0 { return }
54
+
55
+ if pixelBufferPool == nil {
56
+ pixelBufferPool = makePool(width: width, height: height)
57
+ }
58
+ guard let pool = pixelBufferPool else { return }
59
+
60
+ var pixelBuffer: CVPixelBuffer?
61
+ let status = CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBuffer)
62
+ guard status == kCVReturnSuccess, let pb = pixelBuffer else { return }
63
+
64
+ // Lock + draw the image into the pixel buffer using Core Graphics.
65
+ CVPixelBufferLockBaseAddress(pb, [])
66
+ defer { CVPixelBufferUnlockBaseAddress(pb, []) }
67
+ let baseAddress = CVPixelBufferGetBaseAddress(pb)
68
+ let bytesPerRow = CVPixelBufferGetBytesPerRow(pb)
69
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
70
+ guard let ctx = CGContext(
71
+ data: baseAddress,
72
+ width: width,
73
+ height: height,
74
+ bitsPerComponent: 8,
75
+ bytesPerRow: bytesPerRow,
76
+ space: colorSpace,
77
+ bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
78
+ ) else { return }
79
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
80
+
81
+ // Build / update the format description if dimensions changed.
82
+ if formatDescription == nil || width != formatDescriptionWidth || height != formatDescriptionHeight {
83
+ var desc: CMFormatDescription?
84
+ CMVideoFormatDescriptionCreateForImageBuffer(
85
+ allocator: kCFAllocatorDefault,
86
+ imageBuffer: pb,
87
+ formatDescriptionOut: &desc
88
+ )
89
+ formatDescription = desc
90
+ formatDescriptionWidth = width
91
+ formatDescriptionHeight = height
92
+ }
93
+ guard let desc = formatDescription else { return }
94
+
95
+ // Build a CMSampleBuffer that wraps the pixel buffer.
96
+ var timing = CMSampleTimingInfo(
97
+ duration: CMTime(value: 1, timescale: 30),
98
+ presentationTimeStamp: CMTime(seconds: CACurrentMediaTime(), preferredTimescale: 600),
99
+ decodeTimeStamp: .invalid
100
+ )
101
+ var sample: CMSampleBuffer?
102
+ let createStatus = CMSampleBufferCreateForImageBuffer(
103
+ allocator: kCFAllocatorDefault,
104
+ imageBuffer: pb,
105
+ dataReady: true,
106
+ makeDataReadyCallback: nil,
107
+ refcon: nil,
108
+ formatDescription: desc,
109
+ sampleTiming: &timing,
110
+ sampleBufferOut: &sample
111
+ )
112
+ guard createStatus == kCVReturnSuccess, let sb = sample else { return }
113
+
114
+ // Force the display layer to render this sample immediately, so the
115
+ // PiP overlay never shows a stale frame.
116
+ if let attachments = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: true),
117
+ let dict = CFArrayGetValueAtIndex(attachments, 0) {
118
+ let dict = unsafeBitCast(dict, to: CFMutableDictionary.self)
119
+ CFDictionarySetValue(
120
+ dict,
121
+ Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
122
+ Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()
123
+ )
124
+ }
125
+ displayLayer.enqueue(sb)
126
+ }
127
+
128
+ private func makePool(width: Int, height: Int) -> CVPixelBufferPool? {
129
+ var attrs: [String: Any] = [
130
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
131
+ kCVPixelBufferWidthKey as String: width,
132
+ kCVPixelBufferHeightKey as String: height,
133
+ kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary,
134
+ ]
135
+ var pool: CVPixelBufferPool?
136
+ let status = CVPixelBufferPoolCreate(
137
+ kCFAllocatorDefault,
138
+ nil,
139
+ attrs as CFDictionary,
140
+ &pool
141
+ )
142
+ return status == kCVReturnSuccess ? pool : nil
143
+ }
144
+ }
@@ -0,0 +1,67 @@
1
+ import Foundation
2
+
3
+ /// Exposes the Swift-only `PlayerRegistry` to the Obj-C++ Fabric component
4
+ /// view. The class methods are `@objc` so they show up in the generated
5
+ /// `ExtendedVlcPlayer-Swift.h` header.
6
+ @objc(EXVLCPlayerRegistryBridge)
7
+ final class PlayerRegistryBridge: NSObject {
8
+ @objc static func createSession() -> NSNumber {
9
+ let (id, _) = PlayerRegistry.shared.next()
10
+ return NSNumber(value: id)
11
+ }
12
+
13
+ /// Returns a NSNumber-wrapped PlayerSession opaque pointer. The Obj-C++
14
+ /// view keeps this opaque reference and uses it to call closure setters
15
+ /// on the session.
16
+ @objc static func session(forId id: NSNumber) -> NSValue? {
17
+ guard let session = PlayerRegistry.shared.session(for: id.intValue) else { return nil }
18
+ return NSValue(nonretainedObject: session)
19
+ }
20
+
21
+ @objc static func destroySession(_ id: NSNumber) {
22
+ PlayerRegistry.shared.remove(id: id.intValue)
23
+ }
24
+
25
+ /// Forwards a JS event payload into the Fabric event dispatcher. The
26
+ /// Obj-C++ view's event blocks call into this and we surface them as
27
+ /// normal Fabric component events.
28
+ @objc static func attachEventSinks(_ id: NSNumber, _ view: UIView) {
29
+ guard let session = PlayerRegistry.shared.session(for: id.intValue) else { return }
30
+ let view = view
31
+ session.onLoad = { [weak view] payload in
32
+ (view as? ExtendedVlcPlayerViewEventReceiver)?.exvlcEmit("onLoad", payload)
33
+ }
34
+ session.onProgress = { [weak view] payload in
35
+ (view as? ExtendedVlcPlayerViewEventReceiver)?.exvlcEmit("onProgress", payload)
36
+ }
37
+ session.onPlaying = { [weak view] payload in
38
+ (view as? ExtendedVlcPlayerViewEventReceiver)?.exvlcEmit("onPlaying", payload)
39
+ }
40
+ session.onPaused = { [weak view] payload in
41
+ (view as? ExtendedVlcPlayerViewEventReceiver)?.exvlcEmit("onPaused", payload)
42
+ }
43
+ session.onEnded = { [weak view] in
44
+ (view as? ExtendedVlcPlayerViewEventReceiver)?.exvlcEmit("onEnded", [:])
45
+ }
46
+ session.onError = { [weak view] payload in
47
+ (view as? ExtendedVlcPlayerViewEventReceiver)?.exvlcEmit("onError", payload)
48
+ }
49
+ session.onBuffering = { [weak view] payload in
50
+ (view as? ExtendedVlcPlayerViewEventReceiver)?.exvlcEmit("onBuffering", payload)
51
+ }
52
+ session.onPictureInPictureStart = { [weak view] in
53
+ (view as? ExtendedVlcPlayerViewEventReceiver)?.exvlcEmit("onPictureInPictureStart", [:])
54
+ }
55
+ session.onPictureInPictureStop = { [weak view] in
56
+ (view as? ExtendedVlcPlayerViewEventReceiver)?.exvlcEmit("onPictureInPictureStop", [:])
57
+ }
58
+ }
59
+ }
60
+
61
+ /// Implemented by the Fabric component view (in Obj-C++) so that the
62
+ /// Swift registry bridge can forward events to the view, which in turn
63
+ /// surfaces them via the standard Fabric event system. This indirection
64
+ /// keeps Swift-only types out of the Obj-C++ source.
65
+ @objc protocol ExtendedVlcPlayerViewEventReceiver {
66
+ func exvlcEmit(_ name: String, _ payload: [AnyHashable: Any])
67
+ }
@@ -0,0 +1,335 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import MobileVLCKit
4
+ import UIKit
5
+
6
+ /// Owns the runtime state of a single VLC media player plus the
7
+ /// PiP-related infrastructure. There is one instance per JS player.
8
+ ///
9
+ /// Thread-safety: all UIKit/VLC work happens on the main thread.
10
+ final class PlayerSession: NSObject {
11
+ let id: Int
12
+ let mediaPlayer = VLCMediaPlayer(options: [
13
+ "network-caching": 1500,
14
+ "live-caching": 1500,
15
+ "file-caching": 1500,
16
+ ])
17
+
18
+ /// The UIView that hosts the `VLCMediaPlayer.drawable`. Created on demand
19
+ /// so a session that is only used for headless PiP does not allocate a
20
+ /// drawable. The Fabric component view owns and retains it.
21
+ lazy var drawable: UIView = {
22
+ let view = UIView()
23
+ view.backgroundColor = .black
24
+ view.translatesAutoresizingMaskIntoConstraints = false
25
+ mediaPlayer.drawable = view
26
+ return view
27
+ }()
28
+
29
+ /// Off-screen layer that backs the PiP overlay.
30
+ private lazy var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer = {
31
+ let layer = AVSampleBufferDisplayLayer()
32
+ layer.videoGravity = .resizeAspect
33
+ layer.backgroundColor = UIColor.black.cgColor
34
+ return layer
35
+ }()
36
+
37
+ private lazy var pipBridge: PipBridge = PipBridge(displayLayer: sampleBufferDisplayLayer)
38
+ private var _pipController: AVPictureInPictureController?
39
+ private var pipController: AVPictureInPictureController? {
40
+ get { _pipController }
41
+ set {
42
+ _pipController?.removeObserver(self, forKeyPath: "pictureInPictureActive", context: nil)
43
+ _pipController = newValue
44
+ _pipController?.addObserver(self, forKeyPath: "pictureInPictureActive", options: [.new], context: nil)
45
+ }
46
+ }
47
+
48
+ private lazy var pipSampleBufferDelegate: SampleBufferPlaybackDelegate = {
49
+ SampleBufferPlaybackDelegate(session: self)
50
+ }()
51
+
52
+ private var displayLink: CADisplayLink?
53
+ private var snapshotCounter: UInt64 = 0
54
+
55
+ /// Closure-based event sinks. The Fabric component view sets these when
56
+ /// the session is attached; the session calls them on player events.
57
+ var onLoad: (([String: Any]) -> Void)?
58
+ var onProgress: (([String: Any]) -> Void)?
59
+ var onPlaying: (([String: Any]) -> Void)?
60
+ var onPaused: (([String: Any]) -> Void)?
61
+ var onEnded: (() -> Void)?
62
+ var onError: (([String: Any]) -> Void)?
63
+ var onBuffering: (([String: Any]) -> Void)?
64
+ var onPictureInPictureStart: (() -> Void)?
65
+ var onPictureInPictureStop: (() -> Void)?
66
+
67
+ init(id: Int) {
68
+ self.id = id
69
+ super.init()
70
+ mediaPlayer.delegate = self
71
+ }
72
+
73
+ deinit {
74
+ displayLink?.invalidate()
75
+ mediaPlayer.stop()
76
+ }
77
+
78
+ // MARK: - Player control
79
+
80
+ func play() { mediaPlayer.play() }
81
+ func pause() { mediaPlayer.pause() }
82
+ func stop() { mediaPlayer.stop() }
83
+
84
+ func seek(to seconds: Double) {
85
+ let vt = max(0, seconds)
86
+ mediaPlayer.time = VLCTime(int: Int32(vt * 1000))
87
+ }
88
+
89
+ func setRate(_ rate: Double) {
90
+ mediaPlayer.rate = Float(max(0.1, rate))
91
+ }
92
+
93
+ func setVolume(_ volume: Float) {
94
+ mediaPlayer.volume = Int32(volume * 200)
95
+ }
96
+
97
+ func setAudioTrack(index: Int) {
98
+ if index < 0 {
99
+ mediaPlayer.currentAudioTrackIndex = -1
100
+ } else {
101
+ let tracks = mediaPlayer.audioTrackIndexes as? [Int32] ?? []
102
+ if index < tracks.count {
103
+ mediaPlayer.currentAudioTrackIndex = tracks[index]
104
+ }
105
+ }
106
+ }
107
+
108
+ func setSubtitleTrack(index: Int) {
109
+ if index < 0 {
110
+ mediaPlayer.currentVideoSubTitleIndex = -1
111
+ } else {
112
+ let tracks = mediaPlayer.videoSubTitlesIndexes as? [Int32] ?? []
113
+ if index < tracks.count {
114
+ mediaPlayer.currentVideoSubTitleIndex = tracks[index]
115
+ }
116
+ }
117
+ }
118
+
119
+ func replace(media: VLCMedia) {
120
+ mediaPlayer.media = media
121
+ mediaPlayer.play()
122
+ }
123
+
124
+ // MARK: - Picture-in-Picture
125
+
126
+ @discardableResult
127
+ func startPictureInPicture() -> Bool {
128
+ guard AVPictureInPictureController.isPictureInPictureSupported() else { return false }
129
+ guard let drawable = mediaPlayer.drawable else { return false }
130
+
131
+ if pipController != nil {
132
+ pipController?.startPictureInPicture()
133
+ startSnapshotBridge()
134
+ return true
135
+ }
136
+
137
+ if sampleBufferDisplayLayer.superlayer == nil {
138
+ drawable.layer.addSublayer(sampleBufferDisplayLayer)
139
+ sampleBufferDisplayLayer.frame = .zero
140
+ }
141
+
142
+ let contentSource = AVPictureInPictureController.ContentSource(
143
+ sampleBufferDisplayLayer: sampleBufferDisplayLayer,
144
+ playbackDelegate: pipSampleBufferDelegate
145
+ )
146
+ let controller = AVPictureInPictureController(contentSource: contentSource)
147
+ controller.delegate = self
148
+ controller.canStartPictureInPictureAutomaticallyFromInline = false
149
+ pipController = controller
150
+
151
+ startSnapshotBridge()
152
+ controller.startPictureInPicture()
153
+ return true
154
+ }
155
+
156
+ @discardableResult
157
+ func stopPictureInPicture() -> Bool {
158
+ let wasActive = pipController?.isPictureInPictureActive ?? false
159
+ pipController?.stopPictureInPicture()
160
+ stopSnapshotBridge()
161
+ return wasActive
162
+ }
163
+
164
+ // MARK: - Snapshot bridge
165
+
166
+ private func startSnapshotBridge() {
167
+ stopSnapshotBridge()
168
+ let link = CADisplayLink(target: self, selector: #selector(snapshotTick))
169
+ link.add(to: .main, forMode: .common)
170
+ displayLink = link
171
+ }
172
+
173
+ private func stopSnapshotBridge() {
174
+ displayLink?.invalidate()
175
+ displayLink = nil
176
+ }
177
+
178
+ @objc private func snapshotTick() {
179
+ snapshotCounter &+= 1
180
+ let tempDir = NSTemporaryDirectory()
181
+ let path = (tempDir as NSString).appendingPathComponent("exvlc-\(id)-\(snapshotCounter).jpg")
182
+ mediaPlayer.saveVideoSnapshot(at: path, withWidth: 0, andHeight: 0)
183
+ DispatchQueue.main.async { [weak self] in
184
+ self?.consumeSnapshot(at: path)
185
+ }
186
+ }
187
+
188
+ private func consumeSnapshot(at path: String) {
189
+ guard FileManager.default.fileExists(atPath: path) else { return }
190
+ defer { try? FileManager.default.removeItem(atPath: path) }
191
+ guard let image = UIImage(contentsOfFile: path) else { return }
192
+ pipBridge.feed(image: image)
193
+ }
194
+ }
195
+
196
+ // MARK: - VLCMediaPlayerDelegate
197
+
198
+ extension PlayerSession: VLCMediaPlayerDelegate {
199
+ func mediaPlayerStateChanged(_ aNotification: Notification!) {
200
+ switch mediaPlayer.state {
201
+ case .opening:
202
+ onLoad?([
203
+ "duration": Double(mediaPlayer.length.intValue) / 1000.0,
204
+ "audioTracks": audioTracksPayload(),
205
+ "textTracks": textTracksPayload(),
206
+ ])
207
+ case .playing:
208
+ onPlaying?(["duration": Double(mediaPlayer.length.intValue) / 1000.0])
209
+ case .paused:
210
+ onPaused?([:])
211
+ case .stopped:
212
+ onEnded?()
213
+ case .ended:
214
+ onEnded?()
215
+ case .error:
216
+ onError?([
217
+ "message": "VLCMediaPlayer reported error state",
218
+ "code": "VLC_ERROR",
219
+ "domain": "MobileVLCKit",
220
+ ])
221
+ case .buffering:
222
+ onBuffering?(["isBuffering": true])
223
+ @unknown default:
224
+ break
225
+ }
226
+ }
227
+
228
+ func mediaPlayerTimeChanged(_ aNotification: Notification!) {
229
+ let current = Double(mediaPlayer.time.intValue) / 1000.0
230
+ let total = Double(mediaPlayer.length.intValue) / 1000.0
231
+ let position = total > 0 ? current / total : 0
232
+ onProgress?([
233
+ "currentTime": current,
234
+ "duration": total,
235
+ "position": position,
236
+ ])
237
+ }
238
+
239
+ private func audioTracksPayload() -> [[String: Any]] {
240
+ let indexes = (mediaPlayer.audioTrackIndexes as? [Int32]) ?? []
241
+ return indexes.enumerated().map { (i, _) -> [String: Any] in
242
+ ["index": i]
243
+ }
244
+ }
245
+
246
+ private func textTracksPayload() -> [[String: Any]] {
247
+ let indexes = (mediaPlayer.videoSubTitlesIndexes as? [Int32]) ?? []
248
+ return indexes.enumerated().map { (i, _) -> [String: Any] in
249
+ ["index": i]
250
+ }
251
+ }
252
+ }
253
+
254
+ // MARK: - AVPictureInPictureControllerDelegate
255
+
256
+ extension PlayerSession: AVPictureInPictureControllerDelegate {
257
+ func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {}
258
+
259
+ func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
260
+ onPictureInPictureStart?()
261
+ }
262
+
263
+ func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
264
+ stopSnapshotBridge()
265
+ onPictureInPictureStop?()
266
+ }
267
+
268
+ func pictureInPictureController(
269
+ _ pictureInPictureController: AVPictureInPictureController,
270
+ failedToStartPictureInPictureWithError error: Error
271
+ ) {
272
+ stopSnapshotBridge()
273
+ onError?([
274
+ "message": "Picture-in-Picture failed to start: \(error.localizedDescription)",
275
+ "code": "PIP_FAILED",
276
+ "domain": "AVKit",
277
+ ])
278
+ }
279
+
280
+ func pictureInPictureController(
281
+ _ pictureInPictureController: AVPictureInPictureController,
282
+ restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
283
+ ) {
284
+ completionHandler(true)
285
+ }
286
+
287
+ override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
288
+ if keyPath == "pictureInPictureActive" {
289
+ if let active = change?[.newKey] as? Bool, !active {
290
+ stopSnapshotBridge()
291
+ }
292
+ } else {
293
+ super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
294
+ }
295
+ }
296
+ }
297
+
298
+ // MARK: - SampleBufferPlaybackDelegate
299
+
300
+ final class SampleBufferPlaybackDelegate: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate {
301
+ weak var session: PlayerSession?
302
+
303
+ init(session: PlayerSession) {
304
+ self.session = session
305
+ super.init()
306
+ }
307
+
308
+ func setPlaying(_ playing: Bool) {}
309
+ func setPlaybackRate(_ playbackRate: Float) { session?.mediaPlayer.rate = playbackRate }
310
+ func playbackTimeRange() -> CMTimeRange {
311
+ let start = session?.mediaPlayer.time ?? VLCTime(int: 0)
312
+ let length = session?.mediaPlayer.length ?? VLCTime(int: 0)
313
+ let startSec = Double(start.intValue) / 1000.0
314
+ let lengthSec = Double(length.intValue) / 1000.0
315
+ if lengthSec <= 0 { return .invalid }
316
+ return CMTimeRange(
317
+ start: CMTime(seconds: startSec, preferredTimescale: 600),
318
+ duration: CMTime(seconds: lengthSec, preferredTimescale: 600)
319
+ )
320
+ }
321
+ func isPlaybackPaused() -> Bool { return !(session?.mediaPlayer.isPlaying ?? false) }
322
+ func isPlaybackBufferEmpty() -> Bool { return !(session?.mediaPlayer.isPlaying ?? false) }
323
+ func isPlaybackLikelyToKeepUp() -> Bool { return session?.mediaPlayer.isPlaying ?? false }
324
+ func didPlayToEnd() -> Bool { return session?.mediaPlayer.state == .ended }
325
+ func currentTime() -> CMTime {
326
+ let t = session?.mediaPlayer.time ?? VLCTime(int: 0)
327
+ return CMTime(seconds: Double(t.intValue) / 1000.0, preferredTimescale: 600)
328
+ }
329
+ func seek(to time: CMTime, completionHandler: @escaping (Bool) -> Void) {
330
+ let secs = CMTimeGetSeconds(time)
331
+ session?.seek(to: secs)
332
+ completionHandler(true)
333
+ }
334
+ func recommendedPlaybackRate() -> Float { return session?.mediaPlayer.rate ?? 1.0 }
335
+ }