@tiledev/sdk-apptile-live-selling 0.2.5 → 0.3.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 (51) hide show
  1. package/README.md +77 -11
  2. package/dist/engine.native.d.ts.map +1 -1
  3. package/dist/engine.native.js +16 -6
  4. package/dist/engine.native.js.map +1 -1
  5. package/dist/index.d.ts +8 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +10 -3
  8. package/dist/index.js.map +1 -1
  9. package/dist/react/LiveVideoTarget.d.ts +28 -2
  10. package/dist/react/LiveVideoTarget.d.ts.map +1 -1
  11. package/dist/react/LiveVideoTarget.js +37 -4
  12. package/dist/react/LiveVideoTarget.js.map +1 -1
  13. package/dist/react/index.web.d.ts.map +1 -1
  14. package/dist/react/index.web.js +5 -0
  15. package/dist/react/index.web.js.map +1 -1
  16. package/dist/react/useLiveSession.d.ts.map +1 -1
  17. package/dist/react/useLiveSession.js +148 -20
  18. package/dist/react/useLiveSession.js.map +1 -1
  19. package/dist/session.d.ts +8 -0
  20. package/dist/session.d.ts.map +1 -1
  21. package/dist/session.js +37 -4
  22. package/dist/session.js.map +1 -1
  23. package/dist/sessionTypes.d.ts +57 -28
  24. package/dist/sessionTypes.d.ts.map +1 -1
  25. package/dist/sessionTypes.js.map +1 -1
  26. package/dist/streams.d.ts +6 -0
  27. package/dist/streams.d.ts.map +1 -1
  28. package/dist/streams.js +7 -6
  29. package/dist/streams.js.map +1 -1
  30. package/dist/types.d.ts +13 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js.map +1 -1
  33. package/package.json +9 -20
  34. package/android/build.gradle +0 -33
  35. package/android/src/main/AndroidManifest.xml +0 -7
  36. package/android/src/main/java/expo/modules/livepip/LivePipModule.kt +0 -310
  37. package/app.plugin.js +0 -3
  38. package/dist/pip.d.ts +0 -14
  39. package/dist/pip.d.ts.map +0 -1
  40. package/dist/pip.js +0 -15
  41. package/dist/pip.js.map +0 -1
  42. package/dist/pip.native.d.ts +0 -11
  43. package/dist/pip.native.d.ts.map +0 -1
  44. package/dist/pip.native.js +0 -47
  45. package/dist/pip.native.js.map +0 -1
  46. package/expo-module.config.json +0 -9
  47. package/ios/LivePip.podspec +0 -20
  48. package/ios/LivePipController.swift +0 -327
  49. package/ios/LivePipModule.swift +0 -35
  50. package/plugin/build/index.d.ts +0 -29
  51. package/plugin/build/index.js +0 -101
@@ -1,327 +0,0 @@
1
- import AVKit
2
- import ZegoExpressEngine
3
-
4
- /// Frame pipeline and PIP window for the live stream.
5
- ///
6
- /// Why this exists at all: the React Native Zego wrapper exposes no picture-in-picture, and iOS
7
- /// cannot put a live stream in a PIP window without a sample-buffer layer to hand AVKit. The
8
- /// engine's own `ZegoTextureView` is not one, so the frames have to be intercepted.
9
- ///
10
- /// The trick that keeps this small is `enableEngineRender`: the engine keeps drawing into the
11
- /// texture view exactly as before *and* calls us back with each frame. Without it, custom
12
- /// rendering replaces engine rendering outright and every surface in the app would have to be
13
- /// rewritten around this layer.
14
- final class LivePipController: NSObject {
15
- static let shared = LivePipController()
16
-
17
- /// `(eventName, payload)`. Set by the module; the controller never assumes a listener.
18
- var onEvent: ((String, [String: Any]) -> Void)?
19
-
20
- private var displayLayer: AVSampleBufferDisplayLayer?
21
- private var pipController: AVPictureInPictureController?
22
- private var hostView: UIView?
23
- private var streamID: String?
24
- private var configured = false
25
- private var armed = false
26
- /// Set between `restoreUserInterfaceForPictureInPictureStop` and `didStop`, which is the only
27
- /// way to tell "the user expanded the window" from "the user closed it" — both end in `didStop`.
28
- private var restoring = false
29
-
30
- // MARK: - Engine wiring
31
-
32
- /// `sharedEngine` is annotated non-null, so Swift imports it as non-optional — but it really does
33
- /// return nil until `createEngine` has run, and every entry point here can be reached before
34
- /// that. Laundering it through an optional restores the check the annotation removed.
35
- private static func engine() -> ZegoExpressEngine? {
36
- let candidate: ZegoExpressEngine? = ZegoExpressEngine.shared()
37
- return candidate
38
- }
39
-
40
- /// Turns on custom rendering. Must run after the engine exists and before the first
41
- /// `startPlayingStream`: the SDK only accepts this configuration while nothing is playing.
42
- func configure() throws {
43
- if configured { return }
44
- guard let engine = Self.engine() else {
45
- throw LivePipError.engineMissing
46
- }
47
-
48
- let config = ZegoCustomVideoRenderConfig()
49
- config.bufferType = .cvPixelBuffer
50
- config.frameFormatSeries = .RGB
51
- // The whole reason the app's existing video path survives this feature.
52
- config.enableEngineRender = true
53
-
54
- engine.enableCustomVideoRender(true, config: config)
55
- engine.setCustomVideoRenderHandler(self)
56
- configured = true
57
- }
58
-
59
- /// The stream whose frames feed the PIP window. Pass nil when playback stops.
60
- func setStream(_ id: String?) {
61
- if streamID == id { return }
62
- // Stop the old stream's callbacks before switching, or a cohost swap leaves both feeding.
63
- if let previous = streamID, let engine = Self.engine() {
64
- engine.enableRemoteVideoCustomVideoRender(false, streamID: previous)
65
- }
66
- streamID = id
67
- applyFrameCallbacks()
68
- }
69
-
70
- /// Per-stream rather than global: the frame callback copies every frame out of the engine, so it
71
- /// stays off until a PIP window is actually wanted.
72
- private func applyFrameCallbacks() {
73
- guard let id = streamID, let engine = Self.engine() else { return }
74
- engine.enableRemoteVideoCustomVideoRender(armed, streamID: id)
75
- }
76
-
77
- // MARK: - PIP lifecycle
78
-
79
- /// Arming is what makes backgrounding the app hand over to a system PIP window: iOS starts it
80
- /// itself through `canStartPictureInPictureAutomaticallyFromInline`, with no JavaScript running
81
- /// at that moment. Nothing else produces a seamless handoff — by the time a background
82
- /// notification reaches JS the transition has already been missed.
83
- func setArmed(_ next: Bool) {
84
- if armed == next { return }
85
- armed = next
86
- applyFrameCallbacks()
87
-
88
- onMain { [weak self] in
89
- guard let self else { return }
90
- if next {
91
- self.buildController()
92
- } else {
93
- self.teardownController()
94
- }
95
- }
96
- }
97
-
98
- func start() {
99
- onMain { [weak self] in
100
- guard let self, let controller = self.pipController else { return }
101
- if self.restoring { return }
102
- if controller.isPictureInPictureActive { return }
103
- controller.startPictureInPicture()
104
- }
105
- }
106
-
107
- func stop() {
108
- onMain { [weak self] in
109
- guard let self, let controller = self.pipController else { return }
110
- if controller.isPictureInPictureActive { controller.stopPictureInPicture() }
111
- }
112
- }
113
-
114
- private func buildController() {
115
- let layer = displayLayer ?? makeDisplayLayer()
116
- displayLayer = layer
117
-
118
- if pipController == nil {
119
- let source = AVPictureInPictureController.ContentSource(
120
- sampleBufferDisplayLayer: layer,
121
- playbackDelegate: self
122
- )
123
- let controller = AVPictureInPictureController(contentSource: source)
124
- controller.delegate = self
125
- controller.canStartPictureInPictureAutomaticallyFromInline = true
126
- pipController = controller
127
- } else {
128
- pipController?.canStartPictureInPictureAutomaticallyFromInline = true
129
- }
130
- }
131
-
132
- private func teardownController() {
133
- if let controller = pipController, controller.isPictureInPictureActive {
134
- controller.stopPictureInPicture()
135
- }
136
- pipController?.canStartPictureInPictureAutomaticallyFromInline = false
137
- pipController = nil
138
- displayLayer?.flushAndRemoveImage()
139
- displayLayer?.removeFromSuperlayer()
140
- displayLayer = nil
141
- hostView?.removeFromSuperview()
142
- hostView = nil
143
- restoring = false
144
- }
145
-
146
- /// AVKit will not start PIP for a layer that is not in a window, so the layer gets a 1×1 host in
147
- /// the corner of the key window, behind everything. It is a real, attached, on-screen layer —
148
- /// just too small to notice. Rendering it into the app's actual video view instead would draw a
149
- /// second copy of the stream over the engine's own.
150
- private func makeDisplayLayer() -> AVSampleBufferDisplayLayer {
151
- let layer = AVSampleBufferDisplayLayer()
152
- layer.videoGravity = .resizeAspect
153
- layer.frame = CGRect(x: 0, y: 0, width: 1, height: 1)
154
-
155
- let host = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
156
- host.isUserInteractionEnabled = false
157
- host.layer.addSublayer(layer)
158
- hostView = host
159
-
160
- if let window = Self.keyWindow() {
161
- window.insertSubview(host, at: 0)
162
- }
163
- return layer
164
- }
165
-
166
- private static func keyWindow() -> UIWindow? {
167
- UIApplication.shared.connectedScenes
168
- .compactMap { $0 as? UIWindowScene }
169
- .first { $0.activationState == .foregroundActive }?
170
- .windows.first { $0.isKeyWindow }
171
- ?? UIApplication.shared.connectedScenes
172
- .compactMap { $0 as? UIWindowScene }
173
- .flatMap { $0.windows }
174
- .first
175
- }
176
-
177
- private func onMain(_ work: @escaping () -> Void) {
178
- if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
179
- }
180
- }
181
-
182
- // MARK: - Frames
183
-
184
- extension LivePipController: ZegoCustomVideoRenderHandler {
185
- func onRemoteVideoFrameCVPixelBuffer(
186
- _ buffer: CVPixelBuffer,
187
- param: ZegoVideoFrameParam,
188
- streamID: String
189
- ) {
190
- guard armed, streamID == self.streamID, let layer = displayLayer else { return }
191
- guard let sample = Self.makeSampleBuffer(from: buffer) else { return }
192
-
193
- layer.enqueue(sample)
194
- // -11847 is "layer failed while detached"; it sticks until the layer is rebuilt, so a silent
195
- // black PIP window is the alternative to noticing it here.
196
- if layer.status == .failed {
197
- layer.flush()
198
- }
199
- }
200
-
201
- private static func makeSampleBuffer(from pixelBuffer: CVPixelBuffer) -> CMSampleBuffer? {
202
- var formatDescription: CMVideoFormatDescription?
203
- guard
204
- CMVideoFormatDescriptionCreateForImageBuffer(
205
- allocator: kCFAllocatorDefault,
206
- imageBuffer: pixelBuffer,
207
- formatDescriptionOut: &formatDescription
208
- ) == noErr,
209
- let format = formatDescription
210
- else { return nil }
211
-
212
- // A live stream has no timeline to seek, so the frames carry no timestamps and are shown as
213
- // they arrive; `kCMSampleAttachmentKey_DisplayImmediately` is what makes that legal.
214
- var timing = CMSampleTimingInfo(
215
- duration: .invalid,
216
- presentationTimeStamp: .invalid,
217
- decodeTimeStamp: .invalid
218
- )
219
- var sampleBuffer: CMSampleBuffer?
220
- guard
221
- CMSampleBufferCreateForImageBuffer(
222
- allocator: kCFAllocatorDefault,
223
- imageBuffer: pixelBuffer,
224
- dataReady: true,
225
- makeDataReadyCallback: nil,
226
- refcon: nil,
227
- formatDescription: format,
228
- sampleTiming: &timing,
229
- sampleBufferOut: &sampleBuffer
230
- ) == noErr,
231
- let sample = sampleBuffer
232
- else { return nil }
233
-
234
- if
235
- let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true),
236
- CFArrayGetCount(attachments) > 0
237
- {
238
- let raw = CFArrayGetValueAtIndex(attachments, 0)
239
- let dictionary = unsafeBitCast(raw, to: CFMutableDictionary.self)
240
- CFDictionarySetValue(
241
- dictionary,
242
- Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
243
- Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()
244
- )
245
- }
246
-
247
- return sample
248
- }
249
- }
250
-
251
- // MARK: - AVPictureInPictureControllerDelegate
252
-
253
- extension LivePipController: AVPictureInPictureControllerDelegate {
254
- func pictureInPictureControllerDidStartPictureInPicture(
255
- _ pictureInPictureController: AVPictureInPictureController
256
- ) {
257
- onEvent?("onPipStart", [:])
258
- }
259
-
260
- func pictureInPictureController(
261
- _ pictureInPictureController: AVPictureInPictureController,
262
- failedToStartPictureInPictureWithError error: Error
263
- ) {
264
- restoring = false
265
- onEvent?("onPipError", ["message": error.localizedDescription])
266
- }
267
-
268
- func pictureInPictureController(
269
- _ pictureInPictureController: AVPictureInPictureController,
270
- restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (
271
- Bool
272
- ) -> Void
273
- ) {
274
- restoring = true
275
- onEvent?("onPipRestore", [:])
276
- completionHandler(true)
277
- }
278
-
279
- func pictureInPictureControllerDidStopPictureInPicture(
280
- _ pictureInPictureController: AVPictureInPictureController
281
- ) {
282
- let restored = restoring
283
- restoring = false
284
- onEvent?("onPipStop", ["restored": restored])
285
- }
286
- }
287
-
288
- // MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
289
-
290
- extension LivePipController: AVPictureInPictureSampleBufferPlaybackDelegate {
291
- func pictureInPictureController(
292
- _ pictureInPictureController: AVPictureInPictureController,
293
- setPlaying playing: Bool
294
- ) {
295
- // A live stream cannot pause, so the play/pause button is a no-op rather than a lie about state.
296
- }
297
-
298
- func pictureInPictureControllerTimeRangeForPlayback(
299
- _ pictureInPictureController: AVPictureInPictureController
300
- ) -> CMTimeRange {
301
- // Infinite: this is what makes AVKit render live controls instead of a scrubber.
302
- CMTimeRange(start: .zero, duration: .positiveInfinity)
303
- }
304
-
305
- func pictureInPictureControllerIsPlaybackPaused(
306
- _ pictureInPictureController: AVPictureInPictureController
307
- ) -> Bool {
308
- false
309
- }
310
-
311
- func pictureInPictureController(
312
- _ pictureInPictureController: AVPictureInPictureController,
313
- didTransitionToRenderSize newRenderSize: CMVideoDimensions
314
- ) {}
315
-
316
- func pictureInPictureController(
317
- _ pictureInPictureController: AVPictureInPictureController,
318
- skipByInterval skipInterval: CMTime,
319
- completion completionHandler: @escaping () -> Void
320
- ) {
321
- completionHandler()
322
- }
323
- }
324
-
325
- enum LivePipError: Error {
326
- case engineMissing
327
- }
@@ -1,35 +0,0 @@
1
- import ExpoModulesCore
2
-
3
- public class LivePipModule: Module {
4
- public func definition() -> ModuleDefinition {
5
- Name("LivePip")
6
-
7
- Events("onPipStart", "onPipStop", "onPipRestore", "onPipError")
8
-
9
- OnCreate {
10
- LivePipController.shared.onEvent = { [weak self] name, payload in
11
- self?.sendEvent(name, payload)
12
- }
13
- }
14
-
15
- AsyncFunction("configure") {
16
- try LivePipController.shared.configure()
17
- }
18
-
19
- AsyncFunction("setStream") { (streamId: String?) in
20
- LivePipController.shared.setStream(streamId)
21
- }
22
-
23
- AsyncFunction("setArmed") { (armed: Bool) in
24
- LivePipController.shared.setArmed(armed)
25
- }
26
-
27
- AsyncFunction("start") {
28
- LivePipController.shared.start()
29
- }
30
-
31
- AsyncFunction("stop") {
32
- LivePipController.shared.stop()
33
- }
34
- }
35
- }
@@ -1,29 +0,0 @@
1
- import { type ConfigPlugin } from 'expo/config-plugins';
2
- /**
3
- * Everything a prebuilt project needs for live selling that a library cannot declare for itself.
4
- *
5
- * All four of these live in files `expo prebuild` regenerates — `android/build.gradle`,
6
- * `AndroidManifest.xml`, `Info.plist` — so an edit made by hand survives exactly until the next native
7
- * build. That is the worst kind of failure: it works on the machine that set it and silently stops
8
- * working in CI.
9
- */
10
- export interface LiveSellingPluginProps {
11
- /**
12
- * Whether to set up system picture-in-picture. Default true.
13
- *
14
- * Off still leaves the Zego repository and the permission strings, which the SDK needs regardless.
15
- */
16
- pip?: boolean;
17
- /** Shown verbatim by iOS when the camera is first opened. Co-hosting needs it. */
18
- cameraPermission?: string;
19
- microphonePermission?: string;
20
- /**
21
- * Add CAMERA and RECORD_AUDIO on Android. Default true.
22
- *
23
- * Worth keeping even on a viewer-only build: Zego links both APIs, and App Store validation rejects
24
- * the upload (ITMS-90683) on the linked symbols alone.
25
- */
26
- capturePermissions?: boolean;
27
- }
28
- declare const withLiveSelling: ConfigPlugin<LiveSellingPluginProps | void>;
29
- export default withLiveSelling;
@@ -1,101 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- const config_plugins_1 = require("expo/config-plugins");
4
- const DEFAULT_CAMERA = 'Used to appear on camera when you join a live show as a guest.';
5
- const DEFAULT_MICROPHONE = 'Used to be heard when you join a live show as a guest.';
6
- /** Idempotence marker: a second prebuild must not append the block again. */
7
- const ZEGO_MARKER = '// @generated by @tiledev/sdk-apptile-live-selling';
8
- /**
9
- * Zego's own Maven repository.
10
- *
11
- * `zego-express-engine-reactnative` declares `im.zego:express-video`, which lives only on
12
- * `storage.zego.im/maven` — not Google, Maven Central or JitPack. The library's own build.gradle names
13
- * that repo, but Gradle resolves a subproject's runtime dependencies with the *consuming* project's
14
- * repositories, so `:app:debugRuntimeClasspath` searches only what the root script declares and fails
15
- * at configuration time with "Could not find im.zego:express-video" on a clean checkout —
16
- * unconditionally, for whoever next runs `expo run:android`.
17
- */
18
- const ZEGO_REPO_BLOCK = `
19
- ${ZEGO_MARKER}
20
- allprojects {
21
- repositories {
22
- maven { url "https://storage.zego.im/maven" }
23
- }
24
- }
25
- `;
26
- const withZegoMavenRepo = (config) => (0, config_plugins_1.withProjectBuildGradle)(config, (inner) => {
27
- if (inner.modResults.language !== 'groovy') {
28
- throw new Error(`@tiledev/sdk-apptile-live-selling expects a Groovy android/build.gradle; got ${inner.modResults.language}`);
29
- }
30
- if (!inner.modResults.contents.includes(ZEGO_MARKER)) {
31
- inner.modResults.contents += ZEGO_REPO_BLOCK;
32
- }
33
- return inner;
34
- });
35
- /**
36
- * The configuration a PIP transition changes.
37
- *
38
- * Anything not listed here recreates the activity instead of resizing it — which means the React host
39
- * restarts on the way into the window, so the stream drops and the PIP window shows a fresh launch.
40
- * `smallestScreenSize` is the one prebuild does not already write, and it is the one that matters:
41
- * entering PIP is exactly a change of smallest width.
42
- */
43
- const REQUIRED_CONFIG_CHANGES = [
44
- 'screenSize',
45
- 'smallestScreenSize',
46
- 'screenLayout',
47
- 'orientation',
48
- ];
49
- /**
50
- * `android:supportsPictureInPicture` is an attribute of the *activity*, and the activity belongs to
51
- * the app — a library manifest cannot add it, which is why this is a plugin at all.
52
- */
53
- const withLivePipAndroid = (config) => (0, config_plugins_1.withAndroidManifest)(config, (inner) => {
54
- var _a;
55
- const activity = config_plugins_1.AndroidConfig.Manifest.getMainActivityOrThrow(inner.modResults);
56
- activity.$['android:supportsPictureInPicture'] = 'true';
57
- // Merged rather than replaced: prebuild's own list carries `keyboard`, `uiMode` and friends, and
58
- // dropping those would recreate the activity when the keyboard opens.
59
- const declared = ((_a = activity.$['android:configChanges']) !== null && _a !== void 0 ? _a : '').split('|').filter(Boolean);
60
- for (const change of REQUIRED_CONFIG_CHANGES) {
61
- if (!declared.includes(change))
62
- declared.push(change);
63
- }
64
- activity.$['android:configChanges'] = declared.join('|');
65
- return inner;
66
- });
67
- /**
68
- * `UIBackgroundModes: ["audio"]` is what keeps a system PIP window alive: without it iOS suspends the
69
- * app and the window freezes on its last frame within seconds.
70
- */
71
- const withLivePipIos = (config, props) => (0, config_plugins_1.withInfoPlist)(config, (inner) => {
72
- var _a, _b, _c, _d;
73
- const plist = inner.modResults;
74
- if (props.pip !== false) {
75
- const modes = new Set(Array.isArray(plist.UIBackgroundModes) ? plist.UIBackgroundModes : []);
76
- modes.add('audio');
77
- plist.UIBackgroundModes = Array.from(modes);
78
- }
79
- // Not overwritten when the app already sets its own — a host that has written brand-specific copy
80
- // means it, and Apple shows these strings verbatim.
81
- plist.NSCameraUsageDescription =
82
- (_b = (_a = plist.NSCameraUsageDescription) !== null && _a !== void 0 ? _a : props.cameraPermission) !== null && _b !== void 0 ? _b : DEFAULT_CAMERA;
83
- plist.NSMicrophoneUsageDescription =
84
- (_d = (_c = plist.NSMicrophoneUsageDescription) !== null && _c !== void 0 ? _c : props.microphonePermission) !== null && _d !== void 0 ? _d : DEFAULT_MICROPHONE;
85
- return inner;
86
- });
87
- const withCapturePermissions = (config) => config_plugins_1.AndroidConfig.Permissions.withPermissions(config, [
88
- 'android.permission.CAMERA',
89
- 'android.permission.RECORD_AUDIO',
90
- ]);
91
- const withLiveSelling = (config, props) => {
92
- const options = props !== null && props !== void 0 ? props : {};
93
- let next = withZegoMavenRepo(config);
94
- next = withLivePipIos(next, options);
95
- if (options.pip !== false)
96
- next = withLivePipAndroid(next);
97
- if (options.capturePermissions !== false)
98
- next = withCapturePermissions(next);
99
- return next;
100
- };
101
- exports.default = withLiveSelling;