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.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/android/build.gradle +36 -0
- package/android/src/main/AndroidManifest.xml +16 -0
- package/android/src/main/java/expo/modules/extendedvlcplayer/ExtendedVlcPlayerModule.kt +99 -0
- package/android/src/main/java/expo/modules/extendedvlcplayer/ExtendedVlcPlayerViewComponentView.kt +82 -0
- package/android/src/main/java/expo/modules/extendedvlcplayer/PlayerRegistry.kt +47 -0
- package/android/src/main/java/expo/modules/extendedvlcplayer/PlayerSession.kt +168 -0
- package/app.plugin.js +144 -0
- package/build/ExtendedVlcPlayerView.d.ts +8 -0
- package/build/ExtendedVlcPlayerView.d.ts.map +1 -0
- package/build/ExtendedVlcPlayerView.js +30 -0
- package/build/ExtendedVlcPlayerView.js.map +1 -0
- package/build/index.d.ts +12 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/types.d.ts +97 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/useExtendedVlcPlayer.d.ts +12 -0
- package/build/useExtendedVlcPlayer.d.ts.map +1 -0
- package/build/useExtendedVlcPlayer.js +137 -0
- package/build/useExtendedVlcPlayer.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioSessionConfigurator.swift +25 -0
- package/ios/ExtendedVlcPlayer.podspec +32 -0
- package/ios/ExtendedVlcPlayerModule.swift +130 -0
- package/ios/ExtendedVlcPlayerViewComponentView.h +19 -0
- package/ios/ExtendedVlcPlayerViewComponentView.mm +101 -0
- package/ios/PipBridge.swift +144 -0
- package/ios/PlayerRegistryBridge.swift +67 -0
- package/ios/PlayerSession.swift +335 -0
- package/package.json +65 -0
- package/src/ExtendedVlcPlayerView.tsx +68 -0
- package/src/index.ts +20 -0
- package/src/types.ts +100 -0
- 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
|
+
}
|