@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.
- package/README.md +322 -0
- package/android/build.gradle +33 -0
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/expo/modules/livepip/LivePipModule.kt +310 -0
- package/app.plugin.js +3 -0
- package/dist/auctions.d.ts +3 -0
- package/dist/auctions.d.ts.map +1 -0
- package/dist/auctions.js +152 -0
- package/dist/auctions.js.map +1 -0
- package/dist/client.d.ts +63 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +154 -0
- package/dist/client.js.map +1 -0
- package/dist/clips.d.ts +3 -0
- package/dist/clips.d.ts.map +1 -0
- package/dist/clips.js +53 -0
- package/dist/clips.js.map +1 -0
- package/dist/commentLinks.d.ts +46 -0
- package/dist/commentLinks.d.ts.map +1 -0
- package/dist/commentLinks.js +120 -0
- package/dist/commentLinks.js.map +1 -0
- package/dist/engine.d.ts +13 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +40 -0
- package/dist/engine.js.map +1 -0
- package/dist/engine.native.d.ts +3 -0
- package/dist/engine.native.d.ts.map +1 -0
- package/dist/engine.native.js +815 -0
- package/dist/engine.native.js.map +1 -0
- package/dist/flags.d.ts +5 -0
- package/dist/flags.d.ts.map +1 -0
- package/dist/flags.js +61 -0
- package/dist/flags.js.map +1 -0
- package/dist/guest.d.ts +5 -0
- package/dist/guest.d.ts.map +1 -0
- package/dist/guest.js +89 -0
- package/dist/guest.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +80 -0
- package/dist/index.js.map +1 -0
- package/dist/liveSelling.d.ts +3 -0
- package/dist/liveSelling.d.ts.map +1 -0
- package/dist/liveSelling.js +44 -0
- package/dist/liveSelling.js.map +1 -0
- package/dist/nativeViewTag.d.ts +15 -0
- package/dist/nativeViewTag.d.ts.map +1 -0
- package/dist/nativeViewTag.js +23 -0
- package/dist/nativeViewTag.js.map +1 -0
- package/dist/pip.d.ts +14 -0
- package/dist/pip.d.ts.map +1 -0
- package/dist/pip.js +15 -0
- package/dist/pip.js.map +1 -0
- package/dist/pip.native.d.ts +11 -0
- package/dist/pip.native.d.ts.map +1 -0
- package/dist/pip.native.js +47 -0
- package/dist/pip.native.js.map +1 -0
- package/dist/polls.d.ts +11 -0
- package/dist/polls.d.ts.map +1 -0
- package/dist/polls.js +135 -0
- package/dist/polls.js.map +1 -0
- package/dist/react/LiveSellingProvider.d.ts +16 -0
- package/dist/react/LiveSellingProvider.d.ts.map +1 -0
- package/dist/react/LiveSellingProvider.js +49 -0
- package/dist/react/LiveSellingProvider.js.map +1 -0
- package/dist/react/LiveVideoTarget.d.ts +22 -0
- package/dist/react/LiveVideoTarget.d.ts.map +1 -0
- package/dist/react/LiveVideoTarget.js +73 -0
- package/dist/react/LiveVideoTarget.js.map +1 -0
- package/dist/react/index.d.ts +19 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +43 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/useAuction.d.ts +28 -0
- package/dist/react/useAuction.d.ts.map +1 -0
- package/dist/react/useAuction.js +66 -0
- package/dist/react/useAuction.js.map +1 -0
- package/dist/react/useAuctionBidGate.d.ts +48 -0
- package/dist/react/useAuctionBidGate.d.ts.map +1 -0
- package/dist/react/useAuctionBidGate.js +144 -0
- package/dist/react/useAuctionBidGate.js.map +1 -0
- package/dist/react/useAuctionWins.d.ts +51 -0
- package/dist/react/useAuctionWins.d.ts.map +1 -0
- package/dist/react/useAuctionWins.js +268 -0
- package/dist/react/useAuctionWins.js.map +1 -0
- package/dist/react/useClips.d.ts +9 -0
- package/dist/react/useClips.d.ts.map +1 -0
- package/dist/react/useClips.js +37 -0
- package/dist/react/useClips.js.map +1 -0
- package/dist/react/useLiveSession.d.ts +34 -0
- package/dist/react/useLiveSession.d.ts.map +1 -0
- package/dist/react/useLiveSession.js +876 -0
- package/dist/react/useLiveSession.js.map +1 -0
- package/dist/react/useLiveStreams.d.ts +9 -0
- package/dist/react/useLiveStreams.d.ts.map +1 -0
- package/dist/react/useLiveStreams.js +35 -0
- package/dist/react/useLiveStreams.js.map +1 -0
- package/dist/react/useOngoingLive.d.ts +13 -0
- package/dist/react/useOngoingLive.d.ts.map +1 -0
- package/dist/react/useOngoingLive.js +45 -0
- package/dist/react/useOngoingLive.js.map +1 -0
- package/dist/react/useReplays.d.ts +15 -0
- package/dist/react/useReplays.d.ts.map +1 -0
- package/dist/react/useReplays.js +66 -0
- package/dist/react/useReplays.js.map +1 -0
- package/dist/react/useStreamInfo.d.ts +21 -0
- package/dist/react/useStreamInfo.d.ts.map +1 -0
- package/dist/react/useStreamInfo.js +125 -0
- package/dist/react/useStreamInfo.js.map +1 -0
- package/dist/replays.d.ts +3 -0
- package/dist/replays.d.ts.map +1 -0
- package/dist/replays.js +114 -0
- package/dist/replays.js.map +1 -0
- package/dist/session.d.ts +23 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +464 -0
- package/dist/session.js.map +1 -0
- package/dist/sessionTypes.d.ts +462 -0
- package/dist/sessionTypes.d.ts.map +1 -0
- package/dist/sessionTypes.js +55 -0
- package/dist/sessionTypes.js.map +1 -0
- package/dist/streams.d.ts +67 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +170 -0
- package/dist/streams.js.map +1 -0
- package/dist/types.d.ts +591 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -0
- package/dist/viewer.d.ts +5 -0
- package/dist/viewer.d.ts.map +1 -0
- package/dist/viewer.js +66 -0
- package/dist/viewer.js.map +1 -0
- package/dist/wire.d.ts +155 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +73 -0
- package/dist/wire.js.map +1 -0
- package/dist/zego.d.ts +83 -0
- package/dist/zego.d.ts.map +1 -0
- package/dist/zego.js +13 -0
- package/dist/zego.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/LivePip.podspec +20 -0
- package/ios/LivePipController.swift +327 -0
- package/ios/LivePipModule.swift +35 -0
- package/package.json +91 -0
- package/plugin/build/index.d.ts +29 -0
- package/plugin/build/index.js +101 -0
- 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