@tiledev/sdk-apptile-live-selling 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 (149) hide show
  1. package/README.md +322 -0
  2. package/android/build.gradle +33 -0
  3. package/android/src/main/AndroidManifest.xml +7 -0
  4. package/android/src/main/java/expo/modules/livepip/LivePipModule.kt +310 -0
  5. package/app.plugin.js +3 -0
  6. package/dist/auctions.d.ts +3 -0
  7. package/dist/auctions.d.ts.map +1 -0
  8. package/dist/auctions.js +152 -0
  9. package/dist/auctions.js.map +1 -0
  10. package/dist/client.d.ts +63 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +154 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/clips.d.ts +3 -0
  15. package/dist/clips.d.ts.map +1 -0
  16. package/dist/clips.js +53 -0
  17. package/dist/clips.js.map +1 -0
  18. package/dist/commentLinks.d.ts +46 -0
  19. package/dist/commentLinks.d.ts.map +1 -0
  20. package/dist/commentLinks.js +120 -0
  21. package/dist/commentLinks.js.map +1 -0
  22. package/dist/engine.d.ts +13 -0
  23. package/dist/engine.d.ts.map +1 -0
  24. package/dist/engine.js +40 -0
  25. package/dist/engine.js.map +1 -0
  26. package/dist/engine.native.d.ts +3 -0
  27. package/dist/engine.native.d.ts.map +1 -0
  28. package/dist/engine.native.js +815 -0
  29. package/dist/engine.native.js.map +1 -0
  30. package/dist/flags.d.ts +5 -0
  31. package/dist/flags.d.ts.map +1 -0
  32. package/dist/flags.js +61 -0
  33. package/dist/flags.js.map +1 -0
  34. package/dist/guest.d.ts +5 -0
  35. package/dist/guest.d.ts.map +1 -0
  36. package/dist/guest.js +89 -0
  37. package/dist/guest.js.map +1 -0
  38. package/dist/index.d.ts +31 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +80 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/liveSelling.d.ts +3 -0
  43. package/dist/liveSelling.d.ts.map +1 -0
  44. package/dist/liveSelling.js +44 -0
  45. package/dist/liveSelling.js.map +1 -0
  46. package/dist/nativeViewTag.d.ts +15 -0
  47. package/dist/nativeViewTag.d.ts.map +1 -0
  48. package/dist/nativeViewTag.js +23 -0
  49. package/dist/nativeViewTag.js.map +1 -0
  50. package/dist/pip.d.ts +14 -0
  51. package/dist/pip.d.ts.map +1 -0
  52. package/dist/pip.js +15 -0
  53. package/dist/pip.js.map +1 -0
  54. package/dist/pip.native.d.ts +11 -0
  55. package/dist/pip.native.d.ts.map +1 -0
  56. package/dist/pip.native.js +47 -0
  57. package/dist/pip.native.js.map +1 -0
  58. package/dist/polls.d.ts +11 -0
  59. package/dist/polls.d.ts.map +1 -0
  60. package/dist/polls.js +135 -0
  61. package/dist/polls.js.map +1 -0
  62. package/dist/react/LiveSellingProvider.d.ts +16 -0
  63. package/dist/react/LiveSellingProvider.d.ts.map +1 -0
  64. package/dist/react/LiveSellingProvider.js +49 -0
  65. package/dist/react/LiveSellingProvider.js.map +1 -0
  66. package/dist/react/LiveVideoTarget.d.ts +22 -0
  67. package/dist/react/LiveVideoTarget.d.ts.map +1 -0
  68. package/dist/react/LiveVideoTarget.js +73 -0
  69. package/dist/react/LiveVideoTarget.js.map +1 -0
  70. package/dist/react/index.d.ts +19 -0
  71. package/dist/react/index.d.ts.map +1 -0
  72. package/dist/react/index.js +43 -0
  73. package/dist/react/index.js.map +1 -0
  74. package/dist/react/useAuction.d.ts +28 -0
  75. package/dist/react/useAuction.d.ts.map +1 -0
  76. package/dist/react/useAuction.js +66 -0
  77. package/dist/react/useAuction.js.map +1 -0
  78. package/dist/react/useAuctionBidGate.d.ts +48 -0
  79. package/dist/react/useAuctionBidGate.d.ts.map +1 -0
  80. package/dist/react/useAuctionBidGate.js +144 -0
  81. package/dist/react/useAuctionBidGate.js.map +1 -0
  82. package/dist/react/useAuctionWins.d.ts +51 -0
  83. package/dist/react/useAuctionWins.d.ts.map +1 -0
  84. package/dist/react/useAuctionWins.js +268 -0
  85. package/dist/react/useAuctionWins.js.map +1 -0
  86. package/dist/react/useClips.d.ts +9 -0
  87. package/dist/react/useClips.d.ts.map +1 -0
  88. package/dist/react/useClips.js +37 -0
  89. package/dist/react/useClips.js.map +1 -0
  90. package/dist/react/useLiveSession.d.ts +34 -0
  91. package/dist/react/useLiveSession.d.ts.map +1 -0
  92. package/dist/react/useLiveSession.js +876 -0
  93. package/dist/react/useLiveSession.js.map +1 -0
  94. package/dist/react/useLiveStreams.d.ts +9 -0
  95. package/dist/react/useLiveStreams.d.ts.map +1 -0
  96. package/dist/react/useLiveStreams.js +35 -0
  97. package/dist/react/useLiveStreams.js.map +1 -0
  98. package/dist/react/useOngoingLive.d.ts +13 -0
  99. package/dist/react/useOngoingLive.d.ts.map +1 -0
  100. package/dist/react/useOngoingLive.js +45 -0
  101. package/dist/react/useOngoingLive.js.map +1 -0
  102. package/dist/react/useReplays.d.ts +15 -0
  103. package/dist/react/useReplays.d.ts.map +1 -0
  104. package/dist/react/useReplays.js +66 -0
  105. package/dist/react/useReplays.js.map +1 -0
  106. package/dist/react/useStreamInfo.d.ts +21 -0
  107. package/dist/react/useStreamInfo.d.ts.map +1 -0
  108. package/dist/react/useStreamInfo.js +125 -0
  109. package/dist/react/useStreamInfo.js.map +1 -0
  110. package/dist/replays.d.ts +3 -0
  111. package/dist/replays.d.ts.map +1 -0
  112. package/dist/replays.js +114 -0
  113. package/dist/replays.js.map +1 -0
  114. package/dist/session.d.ts +23 -0
  115. package/dist/session.d.ts.map +1 -0
  116. package/dist/session.js +464 -0
  117. package/dist/session.js.map +1 -0
  118. package/dist/sessionTypes.d.ts +462 -0
  119. package/dist/sessionTypes.d.ts.map +1 -0
  120. package/dist/sessionTypes.js +55 -0
  121. package/dist/sessionTypes.js.map +1 -0
  122. package/dist/streams.d.ts +67 -0
  123. package/dist/streams.d.ts.map +1 -0
  124. package/dist/streams.js +170 -0
  125. package/dist/streams.js.map +1 -0
  126. package/dist/types.d.ts +591 -0
  127. package/dist/types.d.ts.map +1 -0
  128. package/dist/types.js +17 -0
  129. package/dist/types.js.map +1 -0
  130. package/dist/viewer.d.ts +5 -0
  131. package/dist/viewer.d.ts.map +1 -0
  132. package/dist/viewer.js +66 -0
  133. package/dist/viewer.js.map +1 -0
  134. package/dist/wire.d.ts +155 -0
  135. package/dist/wire.d.ts.map +1 -0
  136. package/dist/wire.js +73 -0
  137. package/dist/wire.js.map +1 -0
  138. package/dist/zego.d.ts +83 -0
  139. package/dist/zego.d.ts.map +1 -0
  140. package/dist/zego.js +13 -0
  141. package/dist/zego.js.map +1 -0
  142. package/expo-module.config.json +9 -0
  143. package/ios/LivePip.podspec +20 -0
  144. package/ios/LivePipController.swift +327 -0
  145. package/ios/LivePipModule.swift +35 -0
  146. package/package.json +91 -0
  147. package/plugin/build/index.d.ts +29 -0
  148. package/plugin/build/index.js +101 -0
  149. package/react/package.json +6 -0
package/README.md ADDED
@@ -0,0 +1,322 @@
1
+ # @tiledev/sdk-apptile-live-selling
2
+
3
+ Live selling, auctions and giveaways for React Native — the Apptile live-selling gateway as a typed
4
+ singleton, a Zego room engine, an assembled session state machine, and system picture-in-picture with
5
+ its own config plugin.
6
+
7
+ **Headless.** It ships one component (`LiveVideoTarget`, because a render target is unavoidably
8
+ native). Your header, comments list, bid bar and sheets stay yours.
9
+
10
+ **Storefront-agnostic.** Products, cart writes and the auction checkout come from an adapter you
11
+ supply, so there is no Shopify dependency.
12
+
13
+ ```bash
14
+ npm i @tiledev/sdk-apptile-live-selling
15
+ ```
16
+
17
+ Peers, all optional: `expo`, `react`, `react-native`, `zego-express-engine-reactnative`. Off-device the
18
+ engine and PIP resolve to no-ops, so a web or Node bundle needs none of them.
19
+
20
+ Three entry points: `.` (client, session machine, types), `./react` (providers, hooks, the one
21
+ component), `./types` (types alone, for a package that only needs the shapes).
22
+
23
+ ## 1. Add the config plugin
24
+
25
+ Every native entry live selling needs lives in a file `expo prebuild` regenerates, so it has to be a
26
+ plugin — an edit by hand works on the machine that made it and silently stops working in CI.
27
+
28
+ ```json
29
+ {
30
+ "expo": {
31
+ "plugins": [
32
+ ["@tiledev/sdk-apptile-live-selling", {
33
+ "cameraPermission": "Used to appear on camera when you co-host a live show."
34
+ }]
35
+ ]
36
+ }
37
+ }
38
+ ```
39
+
40
+ | Prop | Default | What it writes |
41
+ |---|---|---|
42
+ | `pip` | `true` | `android:supportsPictureInPicture`, the `configChanges` a PIP resize needs, `UIBackgroundModes: ["audio"]` |
43
+ | `cameraPermission` / `microphonePermission` | generic copy | `NSCameraUsageDescription` / `NSMicrophoneUsageDescription` — never overwrites strings your app already sets |
44
+ | `capturePermissions` | `true` | Android `CAMERA` + `RECORD_AUDIO` |
45
+
46
+ Zego's own Maven repo is added unconditionally: `im.zego:express-video` lives only there, and a clean
47
+ Android checkout fails at configuration time without it.
48
+
49
+ Then `npx expo prebuild` and run the app. The `LivePip` Expo module autolinks itself.
50
+
51
+ > If your app already vendors a `live-pip` module, **delete it** — identical module and pod names
52
+ > collide at link time.
53
+
54
+ ## 2. Configure
55
+
56
+ Every viewer endpoint is **anonymous**: the brand `companyId` is the only credential, sent as
57
+ `x-company-id`. It is not the Shopify store id and not the Tile app id.
58
+
59
+ ```ts
60
+ import { liveSelling } from '@tiledev/sdk-apptile-live-selling';
61
+
62
+ liveSelling.configure({
63
+ apiUrl: 'https://live-selling.apptile.io',
64
+ companyId: '<brand-id>',
65
+ zegoAppId: 361108744, // platform-level: the gateway signs room tokens with one Zego app
66
+ commerce, // see §4
67
+ identity,
68
+ });
69
+ ```
70
+
71
+ | Field | Required | Purpose |
72
+ |---|---|---|
73
+ | `apiUrl` | yes | Gateway base URL, no trailing slash |
74
+ | `companyId` | yes | Apptile "brand id" — the only credential |
75
+ | `zegoAppId` | for video | Matches the gateway, not the brand. Native engine only |
76
+ | `commerce` | for shopping | Resolves lots to products and sells them |
77
+ | `identity` | for bidding | The customer a win is attributed to |
78
+ | `storage` | no | AsyncStorage-shaped. Defaults to `localStorage`, else in-memory |
79
+ | `videoCollectionId` | no | The clip reel. **Not** scoped by the company header, so a stale id serves another tenant's clips — unset ⇒ `clips.list()` is `[]` |
80
+ | `requestSource` | no | `X-Request-Source`. Defaults to `"APPTILE"` |
81
+ | `logger` | no | `{ error }`. Defaults to silent |
82
+ | `customerAccountRequest` | no | The customer's bearer transport. Only the unpaid-wins read uses it |
83
+
84
+ Pass real `storage` in anything you ship. Three things are lost on relaunch without it: the name a
85
+ signed-out shopper gave, the accepted auction terms, and which polls this install voted in. The last one
86
+ does damage — **the gateway does not dedupe votes**, so a forgotten vote lets the same device vote again.
87
+
88
+ ## 3. Mount and render
89
+
90
+ `LiveSellingProvider` configures the client. `LiveSessionProvider` folds the engine's events and the
91
+ polled record into one renderable state — mount it **above your navigator**, because the Zego engine
92
+ holds exactly one room and two places usually render the same stream (a home card and the full-screen
93
+ player). Own it per screen and whichever unmounts first kills the other's video.
94
+
95
+ ```tsx
96
+ import {
97
+ LiveSellingProvider, LiveSessionProvider, useLiveSession, LiveVideoTarget,
98
+ } from '@tiledev/sdk-apptile-live-selling/react';
99
+
100
+ <LiveSellingProvider config={config}>
101
+ <LiveSessionProvider onPipRestore={() => navigate('Live')}>
102
+ <Navigator />
103
+ </LiveSessionProvider>
104
+ </LiveSellingProvider>
105
+ ```
106
+
107
+ Surfaces don't own the video, they *bind* to it:
108
+
109
+ ```tsx
110
+ function LiveScreen() {
111
+ const session = useLiveSession();
112
+ const isFocused = useIsFocused();
113
+
114
+ return (
115
+ <LiveVideoTarget active={isFocused && session.pipMode === 'none'}>
116
+ {session.status !== 'playing' && <YourPoster status={session.status} />}
117
+ </LiveVideoTarget>
118
+ );
119
+ }
120
+ ```
121
+
122
+ `active` is a prop rather than read from navigation focus, so the package needs no navigator
123
+ dependency — and a PIP surface drawn outside the navigator has no route to read focus from anyway.
124
+ Exactly one mounted target should have it set.
125
+
126
+ ### What `useLiveSession()` gives you
127
+
128
+ | | |
129
+ |---|---|
130
+ | `status` | `loading` · `connecting` · `waitingForHost` · `playing` · `ended` · `error` |
131
+ | `stream` | the record: title, host, products, giveaways, `bidCap`, `reactionStyle` |
132
+ | `activeProduct` `products` | resolved through your commerce adapter |
133
+ | `comments` `reactions` `sold` `poll` | the live feed; reactions and sold cards expire on their own |
134
+ | `auction` | `biddingOpen`, `highestBid`, `nextBid`, `timeLeftMs`, `isLeading`, `leaderName`, `extended`, `lastWin` |
135
+ | `giveaway` | `active`, `entryCount`, `registered`, `result` |
136
+ | `guest` | co-host: `mode`, `status`, `slot`, `mutedByHost`, `mixerLayout` |
137
+ | `viewerCount` `elapsedMs` `muted` `pipMode` `viewerName` | header material |
138
+ | `actions` | `sendComment`, `sendReaction`, `addToCart`, `voteInPoll`, `placeBid`, `enterGiveaway`, `enterPip`, `joinAsGuest`, … |
139
+
140
+ `actions` is identity-stable — safe in a dependency array, and deliberately so: one of the effects that
141
+ depends on it rebinds the video, which is a real `startPlayingStream`.
142
+
143
+ `viewerName` is `null` when nobody has said who they are. Ask before the first comment goes out rather
144
+ than publishing "Anonymous" to a room; `actions.chooseViewerName(raw)` remembers the answer.
145
+
146
+ `capabilitiesFor(stream)` returns the flag set a stream calls for (`canBid`, `canEnterGiveaway`,
147
+ `showLiveBadge`, …), so one screen serves an ordinary live show and an auction. An auction is a live show
148
+ plus bidding — the flags are derived, not spelled out twice.
149
+
150
+ ## 4. Adapters
151
+
152
+ The gateway only ever speaks in bare numeric product ids, because that is what the host dashboard
153
+ records. Turning one into something with a price, an image and variants is your job — and so is selling.
154
+
155
+ ```ts
156
+ const commerce = {
157
+ productsByIds: async (ids) => {
158
+ const products = await shopify.products.byIds(ids.map((id) => `gid://shopify/Product/${id}`));
159
+ return products.map((p) => ({
160
+ id: p.id,
161
+ storeProductId: p.id.split('/').pop(),
162
+ title: p.title,
163
+ price: p.priceRange.min.amount,
164
+ currencyCode: p.priceRange.min.currencyCode,
165
+ imageUrl: p.images?.[0]?.url ?? null,
166
+ variants: p.variants.map((v) => ({
167
+ id: v.id, title: v.title, price: v.price.amount, available: v.availableForSale,
168
+ })),
169
+ raw: p, // carried through untouched, for your own call sites
170
+ }));
171
+ },
172
+ // FALSE for a *refused* line rather than throwing. A refused add must not be announced to the
173
+ // room — that card is what everyone else reads as "it is going".
174
+ addLine: (variantId, quantity) => shopify.cart.addLine({ merchandiseId: variantId, quantity }),
175
+ };
176
+
177
+ const identity = {
178
+ bidderId: () => customerId, // bare id; what the host echoes back on a bid
179
+ customerRef: () => `gid://shopify/Customer/${customerId}`,
180
+ customerName: () => customer.firstName,
181
+ };
182
+ ```
183
+
184
+ Only `productsByIds` is required. An unimplemented method disables the feature that needs it rather than
185
+ throwing: no `addLine` ⇒ no add-to-cart, no `createAuctionCheckout` ⇒ wins are reported but not payable.
186
+
187
+ `raw` keeps its type. Name it once and your own product comes back off the session with no cast:
188
+
189
+ ```ts
190
+ const session = useLiveSession<ShopifyProduct>();
191
+ session.activeProduct?.raw?.onlineStoreUrl; // ShopifyProduct | undefined, not unknown
192
+ ```
193
+
194
+ `bidderId` must be the **same id the host's broadcast echoes back**, or `isLeading` compares two
195
+ different things and nobody is ever leading. Keep it distinct from the per-install viewer id, which is
196
+ what the host mutes by.
197
+
198
+ <details>
199
+ <summary><b>The auction checkout contract</b> — read before implementing <code>createAuctionCheckout</code></summary>
200
+
201
+ Build a **disposable** cart, not the shopping cart: an auction win is already a commitment at a settled
202
+ price, and mixing those lines into a browsing cart lets a quantity stepper change it.
203
+
204
+ On Shopify each line carries a hidden `_winningBid` (or `_giveawayItem`) attribute that a **cart
205
+ transform** function on the store reads to reprice the line server-side. Each line also needs something
206
+ unique in its attributes — Shopify merges lines sharing the same merchandise *and* attributes, so two
207
+ identical wins collapse into one quantity-2 line priced as a single lot, and the second win is free.
208
+ </details>
209
+
210
+ ## 5. Auctions — the two rules that matter
211
+
212
+ **Nothing is optimistic.** A viewer *offers* a bid to the round's moderator and waits; only the host's
213
+ broadcast sets the high bid, the leader and the deadline. That is what makes two phones agree.
214
+
215
+ **Deadlines are on the host's clock**, carried as `serverNowMs` on every event that moves them. A phone
216
+ minutes out of sync would otherwise close a round that is still open. `auction.timeLeftMs` is already
217
+ corrected for the offset.
218
+
219
+ `useAuctionBidGate` owns the four ordered conditions in front of a bid and reports which one is current;
220
+ you render the panels. The order is the whole point:
221
+
222
+ ```ts
223
+ const gate = useAuctionBidGate({
224
+ streamingId: session.stream?.streamingId ?? null,
225
+ isLoggedIn, biddingOpen: auction.biddingOpen,
226
+ variantCount: biddingProduct?.variants.length ?? 0,
227
+ placeBid: actions.placeBid,
228
+ });
229
+
230
+ // gate.blocker: 'signIn' | 'unpaidWins' | 'terms' | 'variant' | null
231
+ gate.requestBid(); // the round's minimum — the slide gesture
232
+ gate.requestBid(250); // a custom bid
233
+ ```
234
+
235
+ Signed in → nothing outstanding on another stream → rules accepted once per install → a variant chosen
236
+ (once per round, not per bid). The requested bid is held while you clear the blockers and then placed, so
237
+ finishing the last panel completes the bid they originally asked for. Call `gate.refreshUnpaidWins()`
238
+ from your own focus effect, so a customer who leaves to settle a tab is unblocked on return.
239
+
240
+ If you render the panels as React Native `Modal`s, honour `SHEET_HANDOFF_MS` between them — presenting
241
+ one while another dismisses is a native iOS exception, not a catchable error.
242
+
243
+ ## 6. Platform boundaries
244
+
245
+ Two pairs of files, and Metro picks by platform. TypeScript resolves the non-native one, which is why
246
+ `tsc` needs neither Zego nor Expo installed.
247
+
248
+ | Native (iOS/Android) | Everywhere else |
249
+ |---|---|
250
+ | `engine.native.ts` — the real Zego engine; owns the room, plays into a rebindable view, publishes a guest's camera, translates every Zego channel into a `LiveEvent` | `engine.ts` — no-op, `isNoop: true` |
251
+ | `pip.native.ts` — the `LivePip` Expo module | `pip.ts` — no-op, `isLivePipSupported: false` |
252
+
253
+ So the session never arms a handover that cannot happen, and a web bundle never evaluates the Zego
254
+ entry module.
255
+
256
+ **Picture-in-picture** is two stages. `actions.enterPip()` shrinks to a surface *you* draw; the session
257
+ arms the OS handover in advance, so minimising the app opens a real system window with no JavaScript
258
+ involved at that moment. Coming back raises `onPipRestore` — navigate to your live screen, which the SDK
259
+ can't do itself from above the navigator.
260
+
261
+ ## 7. Imperative client
262
+
263
+ For anything the session doesn't cover. `liveSelling.configure()` first; `isReady()` and `companyId()`
264
+ report state.
265
+
266
+ | Namespace | Methods |
267
+ |---|---|
268
+ | `streams` | `list` `ongoing` `byId` `zegoToken` |
269
+ | `auctions` | `recordBid` `enterGiveaway` `hasAcceptedTerms` `acceptTerms` `unpaidStreamIds` `wins` `isBiddingBlocked` |
270
+ | `polls` | `vote` `loadVotedOption` `rememberVote` `forgetVote` |
271
+ | `replays` | `list` `segments` `comments` `incrementViews` |
272
+ | `clips` | `list` |
273
+ | `guest` | `acceptInvite` `leaveSlot` `userId` |
274
+ | `flags` | `fetch` — per-company feature switches |
275
+ | `viewer` | `id` `loadName` `cachedName` `saveName` `normaliseName` |
276
+ | `engine` | the Zego engine (above) |
277
+
278
+ Read hooks for screens that only need data: `useOngoingLive`, `useLiveStreams`, `useStreamInfo`,
279
+ `useReplays`, `useClips`, `useAuction`.
280
+
281
+ `LiveEvent` is the union the engine emits — `comment`, `reaction`, `sold`, `viewers`, `productChange`,
282
+ `poll*`, `biddingStarted`/`bidInfo`/`biddingClosed`/`auctionWon`, `giveaway*`, the `guest*` co-host
283
+ signals, and an `unknown` fallthrough so a new host-side message is visible rather than dropped.
284
+ Subscribe via `LiveSellingProvider`'s `onEvent` or `liveSelling.engine.subscribe`.
285
+
286
+ ## Scripts
287
+
288
+ | Command | What it does |
289
+ |---|---|
290
+ | `npm run build` | Cleans, compiles `src` → `dist` and `plugin/src` → `plugin/build`. Both are needed: `app.plugin.js` loads the compiled plugin so consumers never need TypeScript at prebuild |
291
+ | `npm run lint` | Typechecks both projects, emitting nothing |
292
+ | `npm run clean` | Removes `dist` and `plugin/build` |
293
+ | `npm pack` | The publish tarball — use it to verify `dist`, `ios/`, `android/`, `expo-module.config.json`, `app.plugin.js` and `plugin/build` all ship |
294
+
295
+ The reducer is exported on its own (`sessionReducer`, `deriveStatus`, `timeLeftMs`) and is pure, so it
296
+ can be driven with no room, socket or React.
297
+
298
+ ## Gotchas
299
+
300
+ - **A camera left off is invisible to Zego.** No media event and no camera-state event fires; only
301
+ zero-FPS quality samples reveal it, which is why `status` becomes `waitingForHost` about 5s in rather
302
+ than instantly.
303
+ - **A dropped socket is not the end of a stream.** Backgrounding disconnects the room and Zego recovers
304
+ by itself, so only the host's explicit signal or the record ends it.
305
+ - **Broadcasts are never replayed.** A shadow-mute is seeded from the stream record for exactly this
306
+ reason — otherwise a muted viewer gets a free message per relaunch, and an *unmute* is invisible until
307
+ the app restarts.
308
+ - **A guest and a viewer cannot both hold the room.** One Zego session per engine, and publishing rights
309
+ live in the token, so co-hosting logs the viewer out and `leaveGuest` takes the room back.
310
+ - **Consuming via `file:` or a workspace link?** Hoist `@types/react`. The `.d.ts` otherwise resolves
311
+ against the SDK's own nested copy, and React 18's `ReactNode` isn't assignable to React 19's.
312
+
313
+ ## Not included
314
+
315
+ UI · a storefront · navigation · a static clip fallback (`clips.list()` reads only the configured
316
+ collection).
317
+
318
+ ## Verified against
319
+
320
+ React 19.1.0 · React Native 0.81.5 · Expo 54 — typechecked from the packed tarball, plus a Node load of
321
+ the pure data layer. The native path (autolinking, the podspec, the plugin's prebuild output) has not
322
+ been exercised on a device yet.
@@ -0,0 +1,33 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'expo-module-gradle-plugin'
3
+
4
+ group = 'expo.modules.livepip'
5
+ version = '1.0.0'
6
+
7
+ android {
8
+ namespace "expo.modules.livepip"
9
+ defaultConfig {
10
+ versionCode 1
11
+ versionName "1.0.0"
12
+ }
13
+ }
14
+
15
+ dependencies {
16
+ // `ComponentActivity.addOnPictureInPictureModeChangedListener`, which is the only way to observe
17
+ // the PIP transitions without editing MainActivity — and MainActivity is prebuild output, so an
18
+ // edit there would be wiped by the next `expo prebuild`. Declared rather than leant on
19
+ // transitively through react-android: this module does not compile without it.
20
+ //
21
+ // The version matches the `strictly 1.9.3` constraint the app's own graph already carries; a
22
+ // different one here would be a resolution conflict rather than an upgrade.
23
+ //
24
+ // The listener's two argument types (`Consumer`, `PictureInPictureModeChangedInfo`) come from
25
+ // androidx.core, which expo-modules-core re-exports as an `api` dependency — hence not repeated.
26
+ implementation 'androidx.activity:activity:1.9.3'
27
+
28
+ // `ReactContext`, to contradict the host pause that would otherwise freeze the PIP window — see
29
+ // `keepRenderingWhileInPip`. expo-modules-core depends on this too, but as `implementation`, so
30
+ // none of it reaches here. Unversioned on purpose: the React Native Gradle plugin resolves it to
31
+ // the one React the app is built against.
32
+ implementation 'com.facebook.react:react-android'
33
+ }
@@ -0,0 +1,7 @@
1
+ <manifest>
2
+ <!--
3
+ Deliberately empty. `android:supportsPictureInPicture` belongs on MainActivity, which is
4
+ generated by prebuild — see `plugins/withLivePipAndroid.js`, which sets it there. A library
5
+ manifest cannot add an attribute to an activity the app declares.
6
+ -->
7
+ </manifest>
@@ -0,0 +1,310 @@
1
+ package expo.modules.livepip
2
+
3
+ import android.app.PictureInPictureParams
4
+ import android.content.Intent
5
+ import android.content.pm.PackageManager
6
+ import android.os.Build
7
+ import android.os.Bundle
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import android.util.Rational
11
+ import androidx.activity.ComponentActivity
12
+ import androidx.annotation.RequiresApi
13
+ import androidx.core.app.PictureInPictureModeChangedInfo
14
+ import androidx.core.os.bundleOf
15
+ import androidx.core.util.Consumer
16
+ import androidx.lifecycle.Lifecycle
17
+ import com.facebook.react.bridge.ReactContext
18
+ import expo.modules.kotlin.modules.Module
19
+ import expo.modules.kotlin.modules.ModuleDefinition
20
+
21
+ /**
22
+ * System picture-in-picture for the live stream, Android side.
23
+ *
24
+ * The API is the one iOS already speaks — `configure` / `setStream` / `setArmed` / `start` / `stop`
25
+ * and four events — so `modules/live-pip/index.ts` and every caller stay platform-agnostic. What
26
+ * happens underneath is not the same shape at all, and it is worth saying why.
27
+ *
28
+ * On iOS a PIP window is a piece of *content*: AVKit wants a sample-buffer layer, the engine's own
29
+ * texture view is not one, and so `LivePipController` intercepts Zego's frames and re-enqueues them.
30
+ * On Android a PIP window is the *activity itself*, scaled down. There is nothing to intercept — the
31
+ * `ZegoTextureView` already on screen keeps drawing, because an activity in PIP is paused but still
32
+ * visible and never reaches `onStop`. So this file is only ever asking the OS to shrink the window,
33
+ * and the "show video, hide everything else" half of the job is React's: see `LivePipBubble`, which
34
+ * fills the window with the stream while `pipMode` is `system`.
35
+ *
36
+ * That is also why `configure` and `setStream` are no-ops here rather than missing. They exist on
37
+ * iOS to build and steer a frame pipeline this platform does not have, and the shared JS calls them
38
+ * unconditionally.
39
+ */
40
+ class LivePipModule : Module() {
41
+ /** Whether backgrounding the app should hand over to a PIP window. Mirrors iOS's meaning exactly. */
42
+ private var armed = false
43
+
44
+ /**
45
+ * Our view of `Activity.isInPictureInPictureMode`, so a listener firing twice for one transition
46
+ * cannot emit `onPipStart` twice.
47
+ */
48
+ private var inPip = false
49
+
50
+ /**
51
+ * The activity the listener below is attached to, so it can be detached from the same one. An
52
+ * activity is recreated on a process restart or a config change we do not handle, and leaving the
53
+ * listener on a dead one means emitting events for a window nobody can see.
54
+ */
55
+ private var listeningTo: ComponentActivity? = null
56
+
57
+ private val pipListener = Consumer<PictureInPictureModeChangedInfo> { info ->
58
+ handleModeChange(info.isInPictureInPictureMode)
59
+ }
60
+
61
+ private val activity: ComponentActivity?
62
+ get() = appContext.currentActivity as? ComponentActivity
63
+
64
+ override fun definition() = ModuleDefinition {
65
+ Name("LivePip")
66
+
67
+ Events("onPipStart", "onPipStop", "onPipRestore", "onPipError")
68
+
69
+ /** No-op: nothing to configure without a frame pipeline. See the note at the top of the file. */
70
+ AsyncFunction("configure") {}
71
+
72
+ /** No-op: the window renders whatever the activity renders, so it needs no stream id. */
73
+ AsyncFunction("setStream") { _: String? -> }
74
+
75
+ AsyncFunction("setArmed") { next: Boolean -> setArmed(next) }
76
+
77
+ AsyncFunction("start") { start() }
78
+
79
+ AsyncFunction("stop") { stop() }
80
+
81
+ /**
82
+ * The pre-Android-12 half of auto-enter.
83
+ *
84
+ * From API 31 the OS does this itself off `setAutoEnterEnabled`, which is the only version that
85
+ * gets a genuinely seamless handoff — the transition happens with no app code in the loop, the
86
+ * same property iOS's `canStartPictureInPictureAutomaticallyFromInline` has. Below that, the last
87
+ * callback before the app leaves is this one, so it is where the request has to go. Not
88
+ * `onPause`: that also fires when the screen turns off, and a PIP window opened because someone
89
+ * pocketed their phone is not what "keep watching" means.
90
+ */
91
+ OnUserLeavesActivity {
92
+ if (armed && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) start()
93
+ }
94
+
95
+ /**
96
+ * Fires when React Native pauses the host — which, entering PIP, happens either just before or
97
+ * just after the mode change, depending on the OS version and on whether auto-enter or an
98
+ * explicit call opened the window. Whichever order it lands in, the pause must not be the last
99
+ * word while a window is up, so the un-pause is re-applied from both sides.
100
+ */
101
+ OnActivityEntersBackground {
102
+ if (inPip) keepRenderingWhileInPip()
103
+ }
104
+
105
+ OnDestroy { detach() }
106
+ }
107
+
108
+ // MARK: - Entry points
109
+ //
110
+ // Each one re-tests the version rather than trusting the guard in the last one. The whole file
111
+ // below API 26 is unreachable — `enterPictureInPictureMode(params)` does not exist there — and a
112
+ // helper that establishes the version is invisible to lint's `NewApi` check, so the test has to
113
+ // sit where the call is or the release build fails on it.
114
+
115
+ private fun setArmed(next: Boolean) {
116
+ armed = next
117
+ if (!available()) {
118
+ // Only worth saying when something was actually asked for. A device without the feature is a
119
+ // live stream without picture-in-picture, which is still a working live stream — the in-app
120
+ // bubble carries on regardless.
121
+ if (next) fail("Picture-in-picture is not available on this device")
122
+ return
123
+ }
124
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
125
+ applyArmed(next)
126
+ }
127
+
128
+ private fun start() {
129
+ if (!available() || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
130
+ enterPip()
131
+ }
132
+
133
+ private fun stop() {
134
+ val activity = this.activity ?: return
135
+ activity.runOnUiThread {
136
+ if (!activity.isInPictureInPictureMode) return@runOnUiThread
137
+ leavePip(activity)
138
+ }
139
+ }
140
+
141
+ // MARK: - The window
142
+
143
+ @RequiresApi(Build.VERSION_CODES.O)
144
+ private fun applyArmed(next: Boolean) {
145
+ val activity = this.activity ?: return
146
+ activity.runOnUiThread {
147
+ attach(activity)
148
+ // Below API 31 there are no params to keep current — `enterPictureInPictureMode` carries them
149
+ // at the moment it is called, from `OnUserLeavesActivity` above.
150
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
151
+ runCatching { activity.setPictureInPictureParams(params(autoEnter = next)) }
152
+ .onFailure { fail(it.message ?: "Could not arm picture-in-picture") }
153
+ }
154
+ // Deliberately *not* leaving an open window when disarmed. Disarming happens when the session
155
+ // ends — the host hangs up, or the viewer closes the window — and the two cases that reach
156
+ // here mid-window both end with the OS taking the window down anyway. The one that does not is
157
+ // a stream ending while the user watches in PIP, and yanking the whole app to the foreground
158
+ // for that is worse than leaving a window they can dismiss themselves: Android has no
159
+ // counterpart to `stopPictureInPicture()`, so foregrounding is the only "close" available.
160
+ }
161
+ }
162
+
163
+ @RequiresApi(Build.VERSION_CODES.O)
164
+ private fun enterPip() {
165
+ val activity = this.activity ?: return
166
+ activity.runOnUiThread {
167
+ if (activity.isInPictureInPictureMode) return@runOnUiThread
168
+ attach(activity)
169
+ // Not merely a failed transition: the OS also throws here when the user has revoked the app's
170
+ // picture-in-picture access in settings, which is a state nothing else reports.
171
+ runCatching { activity.enterPictureInPictureMode(params(autoEnter = armed)) }
172
+ .onFailure { fail(it.message ?: "Could not start picture-in-picture") }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Comes back out of the window and into the app.
178
+ *
179
+ * Android has no counterpart to `stopPictureInPicture()` — the only way out is to bring the task
180
+ * forward, which is exactly what the window's own expand button does. So this takes the expand
181
+ * path, and the listener below reports it as a restore rather than as a dismissal. iOS's `stop()`
182
+ * means the other thing (dismiss, and stop playing); nothing in the app calls either, and the JS
183
+ * wrapper documents the split.
184
+ */
185
+ private fun leavePip(activity: ComponentActivity) {
186
+ val intent = Intent(activity, activity.javaClass)
187
+ .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP)
188
+ runCatching { activity.startActivity(intent) }
189
+ }
190
+
191
+ /**
192
+ * Portrait 9:16 — the shape the hosts publish in, and the same one the in-app bubble uses. Without
193
+ * it the window inherits the activity's aspect ratio, which is the phone's, and the stream sits in
194
+ * a tall window with black down both sides.
195
+ */
196
+ @RequiresApi(Build.VERSION_CODES.O)
197
+ private fun params(autoEnter: Boolean): PictureInPictureParams {
198
+ val builder = PictureInPictureParams.Builder().setAspectRatio(Rational(9, 16))
199
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
200
+ builder.setAutoEnterEnabled(autoEnter)
201
+ // Seamless resize is for content that can be re-laid-out mid-transition; a video surface
202
+ // cross-fades instead, which is what turning it off asks for — and the cross-fade is also what
203
+ // covers React swapping the app's UI for the full-bleed stream on the way in.
204
+ builder.setSeamlessResizeEnabled(false)
205
+ }
206
+ return builder.build()
207
+ }
208
+
209
+ /**
210
+ * API 26, not the manifest's own 24: picture-in-picture existed at 24 for Android TV only, and the
211
+ * params overload does not exist before O. The feature check covers the rest — a handset can ship
212
+ * without it, and low-memory devices routinely do.
213
+ */
214
+ private fun available(): Boolean {
215
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
216
+ val activity = this.activity ?: return false
217
+ return activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
218
+ }
219
+
220
+ // MARK: - Transitions
221
+
222
+ private fun attach(activity: ComponentActivity) {
223
+ if (listeningTo === activity) return
224
+ detach()
225
+ activity.addOnPictureInPictureModeChangedListener(pipListener)
226
+ listeningTo = activity
227
+ inPip = activity.isInPictureInPictureMode
228
+ }
229
+
230
+ private fun detach() {
231
+ listeningTo?.removeOnPictureInPictureModeChangedListener(pipListener)
232
+ listeningTo = null
233
+ }
234
+
235
+ private fun handleModeChange(isInPip: Boolean) {
236
+ if (isInPip == inPip) return
237
+ inPip = isInPip
238
+
239
+ if (isInPip) {
240
+ keepRenderingWhileInPip()
241
+ emit("onPipStart", Bundle())
242
+ return
243
+ }
244
+
245
+ /*
246
+ * Expanded, or closed? Both leave PIP and neither carries a flag saying which.
247
+ *
248
+ * The lifecycle answers it. Tapping the window's close button stops the activity *before* this
249
+ * callback runs, so the state has already fallen below STARTED; tapping expand brings it
250
+ * forward, so it is at least STARTED. This is the check Google's own PIP sample uses, and this
251
+ * callback is the last moment the two paths are still distinguishable.
252
+ */
253
+ val restored = listeningTo?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) ?: false
254
+ // Hand the lifecycle back. Restoring brings a real `onResume` along behind this, so only the
255
+ // close needs saying: there the activity stops without ever pausing again — it already paused on
256
+ // the way *into* the window — so nothing else would undo the un-pause below, and the app would
257
+ // go on rendering a screen no one is looking at.
258
+ if (!restored) resumeReactHostRendering(resumed = false)
259
+ // Same order as iOS: the restore first, then the stop it caused. The JS ignores a stop that
260
+ // carries `restored`, so the pair reads as one event on either platform.
261
+ if (restored) emit("onPipRestore", Bundle())
262
+ emit("onPipStop", bundleOf("restored" to restored))
263
+ }
264
+
265
+ /**
266
+ * Keeps React Native drawing while the app is in a picture-in-picture window.
267
+ *
268
+ * Without this the window is a still photograph. An activity in PIP is *paused* — visible, but
269
+ * paused — and React Native reads that pause as "nobody is looking": `FabricUIManager.onHostPause`
270
+ * stops `DispatchUIFrameCallback`, so mount items queue up and are never applied. React goes on
271
+ * computing perfectly correct layouts that never reach a native view.
272
+ *
273
+ * Measured, not inferred, and worth repeating if this ever regresses: an opaque full-window view
274
+ * rendered from JS reported the right layout through `onLayout` — the PIP window's own size, to the
275
+ * decimal — and did not draw a single pixel of itself. Layout was fine; mounting was frozen. (Do
276
+ * not reach for `dumpsys gfxinfo` to check this. Its frame counter sits still for a paused activity
277
+ * whether or not the window is updating, so it reads the same before and after this fix. Take a
278
+ * screenshot instead.)
279
+ *
280
+ * So the pause is contradicted for exactly as long as a window is up. That is not a trick — the
281
+ * app genuinely is on screen, and every native app in PIP keeps rendering. The cost is that JS
282
+ * sees `AppState: 'active'` while in PIP, which is also the truthful answer.
283
+ *
284
+ * Posted rather than called inline: this runs from inside React Native's own lifecycle dispatch,
285
+ * which is iterating its listener list at the time, and re-entering it there is how you get a
286
+ * concurrent modification out of a list nobody expected to change mid-loop.
287
+ */
288
+ private fun keepRenderingWhileInPip() = resumeReactHostRendering(resumed = true)
289
+
290
+ private fun resumeReactHostRendering(resumed: Boolean) {
291
+ val context = appContext.reactContext as? ReactContext ?: return
292
+ val activity = this.activity
293
+ Handler(Looper.getMainLooper()).post {
294
+ runCatching {
295
+ if (resumed) context.onHostResume(activity) else context.onHostPause()
296
+ }.onFailure { fail(it.message ?: "Could not keep rendering in picture-in-picture") }
297
+ }
298
+ }
299
+
300
+ private fun fail(message: String) = emit("onPipError", bundleOf("message" to message))
301
+
302
+ /**
303
+ * Best-effort by design. Closing the window finishes the activity and the React host goes with it,
304
+ * so the last event of a session is routinely sent into a runtime that is already tearing down.
305
+ * That throws, and it is not a failure worth reporting to anyone.
306
+ */
307
+ private fun emit(name: String, body: Bundle) {
308
+ runCatching { sendEvent(name, body) }
309
+ }
310
+ }
package/app.plugin.js ADDED
@@ -0,0 +1,3 @@
1
+ // Expo resolves a package's config plugin through this file. Points at the compiled plugin so a
2
+ // consumer never needs TypeScript at prebuild time.
3
+ module.exports = require('./plugin/build');