@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
@@ -0,0 +1,876 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useLiveSession = useLiveSession;
4
+ exports.LiveSessionProvider = LiveSessionProvider;
5
+ /**
6
+ * `LiveSessionProvider` / `useLiveSession` — the assembled session.
7
+ *
8
+ * This is the layer that turns the engine's event stream plus the polled record into one renderable
9
+ * state. It owns the two clocks (the elapsed/prune tick and the faster auction tick), the
10
+ * picture-in-picture mode machine, product resolution through the commerce adapter, and every action
11
+ * a live screen fires.
12
+ *
13
+ * **Mount it above your navigator.** The Zego engine holds exactly one room, and two places usually
14
+ * render the same stream — a home-screen card and the full-screen player. Owning the session per
15
+ * screen means whichever unmounts first tears the other's video down; surfaces bind themselves as
16
+ * render targets instead (see `LiveVideoTarget`).
17
+ *
18
+ * `createElement` rather than JSX, like `LiveSellingProvider`, so this stays a plain `.ts` and needs
19
+ * no JSX runtime configured in a consumer's build.
20
+ */
21
+ const react_1 = require("react");
22
+ const react_native_1 = require("react-native");
23
+ const client_1 = require("../client");
24
+ const liveSelling_1 = require("../liveSelling");
25
+ const pip_1 = require("../pip");
26
+ const session_1 = require("../session");
27
+ const useStreamInfo_1 = require("./useStreamInfo");
28
+ /** The elapsed timer and the transient-card pruning share one tick. */
29
+ const TICK_MS = 1000;
30
+ /**
31
+ * The auction clock runs faster than the session tick. Seconds are the unit a bidder acts on, and a
32
+ * 1s tick aliases against the deadline badly enough to show the same second twice and skip another.
33
+ */
34
+ const AUCTION_TICK_MS = 200;
35
+ /**
36
+ * How long past the deadline to wait before closing the round ourselves.
37
+ *
38
+ * The host normally closes it. This only covers the case where nothing arrives, and the delay is what
39
+ * stops a slow last-instant bid — which also extends the clock — from landing after the bar has
40
+ * already given up.
41
+ */
42
+ const AUCTION_GRACE_MS = 1000;
43
+ const LiveSessionContext = (0, react_1.createContext)(null);
44
+ /**
45
+ * Reads the shared live session.
46
+ *
47
+ * `TRaw` is your catalogue's own product type — whatever the commerce adapter put on `LiveProduct.raw`.
48
+ * Pass it and `session.products[n].raw` comes back typed, with no cast at the call site:
49
+ * `useLiveSession<ShopifyProduct>()`.
50
+ */
51
+ function useLiveSession() {
52
+ const session = (0, react_1.useContext)(LiveSessionContext);
53
+ if (!session)
54
+ throw new Error('useLiveSession must be used within a LiveSessionProvider');
55
+ return session;
56
+ }
57
+ function useSessionValue(props) {
58
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
59
+ const { streamingId, viewerName: viewerNameInput, onPipRestore } = props;
60
+ const [state, dispatch] = (0, react_1.useReducer)(session_1.sessionReducer, session_1.initialSessionState);
61
+ const [joining, setJoining] = (0, react_1.useState)(false);
62
+ const [error, setError] = (0, react_1.useState)(null);
63
+ const [now, setNow] = (0, react_1.useState)(() => Date.now());
64
+ const [muted, setMuted] = (0, react_1.useState)(false);
65
+ const [pipMode, setPipMode] = (0, react_1.useState)('none');
66
+ /**
67
+ * Whether the system PIP window is *this session's*.
68
+ *
69
+ * The native module reports the activity's own transitions, and on Android the window IS the
70
+ * activity — so anything else handing itself over fires the same events, with nothing in the
71
+ * payload to say whose window it was. Read through a ref because the listeners below subscribe once
72
+ * per stream and would otherwise close over a stale `pipMode`.
73
+ */
74
+ const pipModeRef = (0, react_1.useRef)('none');
75
+ pipModeRef.current = pipMode;
76
+ const viewerIdRef = (0, react_1.useRef)(null);
77
+ const logger = (0, client_1.getLogger)();
78
+ const identity = (0, client_1.getIdentity)();
79
+ const commerce = (0, client_1.getCommerce)();
80
+ /**
81
+ * What this device comments under.
82
+ *
83
+ * The prop wins, then a name typed on this device, then the signed-in customer's own. The typed
84
+ * name comes before the customer because typing it here is the more explicit act; the customer's
85
+ * name is consulted at all so a signed-in bidder is never asked to invent one.
86
+ */
87
+ const [chosenName, setChosenName] = (0, react_1.useState)(() => liveSelling_1.liveSelling.viewer.cachedName());
88
+ const accountName = (viewerNameInput === null || viewerNameInput === void 0 ? void 0 : viewerNameInput.trim()) || null;
89
+ (0, react_1.useEffect)(() => {
90
+ if (accountName || chosenName)
91
+ return;
92
+ let alive = true;
93
+ void liveSelling_1.liveSelling.viewer.loadName().then((stored) => {
94
+ if (alive && stored)
95
+ setChosenName(stored);
96
+ });
97
+ return () => {
98
+ alive = false;
99
+ };
100
+ }, [accountName, chosenName]);
101
+ const customerName = (_b = (_a = identity === null || identity === void 0 ? void 0 : identity.customerName) === null || _a === void 0 ? void 0 : _a.call(identity)) !== null && _b !== void 0 ? _b : null;
102
+ const namedBy = (_c = accountName !== null && accountName !== void 0 ? accountName : chosenName) !== null && _c !== void 0 ? _c : ((customerName === null || customerName === void 0 ? void 0 : customerName.trim()) || null);
103
+ const viewerName = namedBy !== null && namedBy !== void 0 ? namedBy : 'Anonymous';
104
+ /**
105
+ * Pushed to the engine rather than passed at join: the room is entered once, and a name given
106
+ * mid-show must not cost a rejoin — that interrupts the video for everyone watching on this device.
107
+ * Comments and reactions carry the author in their own payload.
108
+ */
109
+ (0, react_1.useEffect)(() => {
110
+ liveSelling_1.liveSelling.engine.setViewerName(namedBy);
111
+ }, [namedBy]);
112
+ /**
113
+ * The auction clock, separate from `now` so a 200ms tick does not redraw the comment pane five
114
+ * times a second on an ordinary live stream. This one only runs while a round is open.
115
+ */
116
+ const [auctionNow, setAuctionNow] = (0, react_1.useState)(() => Date.now());
117
+ // Poll so a stream ending (or the host swapping the product set) is noticed without a manual
118
+ // refresh. The engine's channels cover in-stream changes; this covers the record itself. With no
119
+ // `streamingId` it follows whichever stream is on air.
120
+ const { stream, refresh: refreshStreamInfo } = (0, useStreamInfo_1.useStreamInfo)(streamingId);
121
+ const watching = streamingId !== null && streamingId !== void 0 ? streamingId : stream === null || stream === void 0 ? void 0 : stream.streamingId;
122
+ (0, react_1.useEffect)(() => {
123
+ dispatch({ kind: 'reset' });
124
+ setError(null);
125
+ }, [watching]);
126
+ (0, react_1.useEffect)(() => liveSelling_1.liveSelling.engine.subscribe((event) => {
127
+ dispatch({ kind: 'event', event, at: Date.now() });
128
+ }), []);
129
+ (0, react_1.useEffect)(() => {
130
+ if (!stream || stream.status !== 'live')
131
+ return;
132
+ // A guest holds the room login while co-hosting — one session per engine, and the publish
133
+ // privilege is in their token. Joining as a viewer would evict them. When guest mode ends this
134
+ // effect re-runs and the viewer session takes the room back.
135
+ if (state.guestMode)
136
+ return;
137
+ /**
138
+ * Off-device the engine is the no-op build, so a join would *succeed* — every stubbed call
139
+ * resolves — and then no room state would ever arrive, leaving the status on `connecting` for
140
+ * ever. An honest dead end beats a convincing one.
141
+ */
142
+ if (react_native_1.Platform.OS === 'web') {
143
+ setError('Live shopping is only available in the app.');
144
+ return;
145
+ }
146
+ let cancelled = false;
147
+ void (async () => {
148
+ try {
149
+ setJoining(true);
150
+ const viewerId = await liveSelling_1.liveSelling.viewer.id();
151
+ viewerIdRef.current = viewerId;
152
+ const token = await liveSelling_1.liveSelling.streams.zegoToken(viewerId, stream.streamingId);
153
+ if (!token)
154
+ throw new Error('Could not get a stream token');
155
+ if (cancelled)
156
+ return;
157
+ await liveSelling_1.liveSelling.engine.join({
158
+ streamingId: stream.streamingId,
159
+ roomId: stream.roomId,
160
+ mixedStreamId: stream.mixedStreamId,
161
+ viewerId,
162
+ viewerName,
163
+ token,
164
+ mutedDeviceIds: stream.mutedDeviceIds,
165
+ });
166
+ }
167
+ catch (caught) {
168
+ logger.error(caught, { at: 'liveSession.join', streamingId: watching });
169
+ if (!cancelled)
170
+ setError('Could not join the live stream');
171
+ }
172
+ finally {
173
+ if (!cancelled)
174
+ setJoining(false);
175
+ }
176
+ })();
177
+ return () => {
178
+ cancelled = true;
179
+ // Releases the room but leaves the engine alive, so playback can continue behind other screens.
180
+ liveSelling_1.liveSelling.engine
181
+ .leave()
182
+ .catch((caught) => logger.error(caught, { at: 'liveSession.leave' }));
183
+ };
184
+ // viewerName is intentionally excluded: it changes when the customer loads, and rejoining the
185
+ // room mid-stream to relabel comments is not worth a video interruption.
186
+ // eslint-disable-next-line react-hooks/exhaustive-deps
187
+ }, [stream === null || stream === void 0 ? void 0 : stream.streamingId, stream === null || stream === void 0 ? void 0 : stream.status, state.guestMode]);
188
+ /**
189
+ * Keep the muted list in step with the polled record.
190
+ *
191
+ * The join seeds it once; this covers changes made while nothing was broadcasting to us — most
192
+ * importantly an *unmute*, which is otherwise invisible until the app restarts. Keyed on the joined
193
+ * ids because the array is a fresh object on every poll.
194
+ */
195
+ const mutedKey = ((_d = stream === null || stream === void 0 ? void 0 : stream.mutedDeviceIds) !== null && _d !== void 0 ? _d : []).join(',');
196
+ (0, react_1.useEffect)(() => {
197
+ if (!stream)
198
+ return;
199
+ liveSelling_1.liveSelling.engine.setMutedDeviceIds(mutedKey ? mutedKey.split(',') : []);
200
+ // eslint-disable-next-line react-hooks/exhaustive-deps
201
+ }, [mutedKey]);
202
+ const status = (0, session_1.deriveStatus)(state, !!stream, joining, error, (stream === null || stream === void 0 ? void 0 : stream.status) === 'live');
203
+ // A PIP surface outlives the screen that opened it, so nothing else would take it down when the
204
+ // host ends the stream — it would sit there over the app showing a frozen last frame.
205
+ (0, react_1.useEffect)(() => {
206
+ if (pipMode === 'none')
207
+ return;
208
+ if (status === 'ended' || status === 'error')
209
+ setPipMode('none');
210
+ }, [status, pipMode]);
211
+ // The host's end broadcast reaches us long before the polled record catches up. Stop the picture,
212
+ // then force a refetch so every poll-driven surface drops the stream instead of waiting out the
213
+ // interval.
214
+ (0, react_1.useEffect)(() => {
215
+ if (status !== 'ended')
216
+ return;
217
+ liveSelling_1.liveSelling.engine
218
+ .stopPlayback()
219
+ .catch((caught) => logger.error(caught, { at: 'liveSession.stopOnEnd' }));
220
+ (0, useStreamInfo_1.refreshStreamRecords)();
221
+ // eslint-disable-next-line react-hooks/exhaustive-deps
222
+ }, [status]);
223
+ // Arming is what makes minimising the app hand over to a system PIP window: the OS starts it
224
+ // itself, with no JavaScript involved at that moment. Arming while an in-app surface is up — rather
225
+ // than waiting for a background event, which arrives too late — is what makes it seamless.
226
+ (0, react_1.useEffect)(() => {
227
+ if (!pip_1.isLivePipSupported)
228
+ return;
229
+ const armed = pipMode !== 'none';
230
+ void pip_1.livePip.setArmed(armed);
231
+ liveSelling_1.liveSelling.engine
232
+ .setBackgroundVideoEnabled(armed)
233
+ .catch((caught) => logger.error(caught, { at: 'liveSession.backgroundVideo' }));
234
+ // eslint-disable-next-line react-hooks/exhaustive-deps
235
+ }, [pipMode]);
236
+ (0, react_1.useEffect)(() => {
237
+ if (!pip_1.isLivePipSupported)
238
+ return;
239
+ /**
240
+ * Only ours if this session had its own surface up first. Anything else minimising into a system
241
+ * window raises the same event, and taking it means the live session believes it is in a PIP
242
+ * window it never opened.
243
+ */
244
+ const offStart = pip_1.livePip.addListener('onPipStart', () => {
245
+ if (pipModeRef.current !== 'inapp')
246
+ return;
247
+ setPipMode('system');
248
+ });
249
+ // Expanding and closing both end in `onPipStop`; `restored` is the only thing that tells them
250
+ // apart, and the expand path is handled by `onPipRestore` instead.
251
+ const offStop = pip_1.livePip.addListener('onPipStop', ({ restored }) => {
252
+ if (restored)
253
+ return;
254
+ if (pipModeRef.current !== 'system')
255
+ return;
256
+ setPipMode('none');
257
+ liveSelling_1.liveSelling.engine
258
+ .stopPlayback()
259
+ .catch((caught) => logger.error(caught, { at: 'liveSession.pipDismissed' }));
260
+ });
261
+ const offRestore = pip_1.livePip.addListener('onPipRestore', () => {
262
+ if (pipModeRef.current !== 'system')
263
+ return;
264
+ setPipMode('none');
265
+ onPipRestore === null || onPipRestore === void 0 ? void 0 : onPipRestore(watching);
266
+ });
267
+ const offError = pip_1.livePip.addListener('onPipError', ({ message }) => logger.error(new Error(message), { at: 'liveSession.pipError' }));
268
+ return () => {
269
+ offStart === null || offStart === void 0 ? void 0 : offStart();
270
+ offStop === null || offStop === void 0 ? void 0 : offStop();
271
+ offRestore === null || offRestore === void 0 ? void 0 : offRestore();
272
+ offError === null || offError === void 0 ? void 0 : offError();
273
+ };
274
+ // eslint-disable-next-line react-hooks/exhaustive-deps
275
+ }, [watching, onPipRestore]);
276
+ // Anything that takes the app out of the foreground tears down the video surface without ending
277
+ // the session. The engine reconnects the room on its own, but the render target has to be re-issued.
278
+ (0, react_1.useEffect)(() => {
279
+ const subscription = react_native_1.AppState.addEventListener('change', (next) => {
280
+ if (next !== 'active')
281
+ return;
282
+ liveSelling_1.liveSelling.engine
283
+ .refreshPlayback()
284
+ .catch((caught) => logger.error(caught, { at: 'liveSession.refreshPlayback' }));
285
+ });
286
+ return () => subscription.remove();
287
+ // eslint-disable-next-line react-hooks/exhaustive-deps
288
+ }, []);
289
+ (0, react_1.useEffect)(() => {
290
+ if (status !== 'playing' && state.sold.length === 0 && state.reactions.length === 0)
291
+ return;
292
+ const timer = setInterval(() => {
293
+ const at = Date.now();
294
+ setNow(at);
295
+ dispatch({ kind: 'prune', now: at });
296
+ }, TICK_MS);
297
+ return () => clearInterval(timer);
298
+ }, [status, state.sold.length, state.reactions.length]);
299
+ /**
300
+ * The auction clock. Only runs while a round is open, so it costs nothing on a normal live stream.
301
+ * It also owns the fallback close: past the deadline plus the grace period with no word from the
302
+ * host, the round is over as far as this device is concerned.
303
+ */
304
+ const { biddingOpen, endsAtMs, serverOffsetMs } = state;
305
+ (0, react_1.useEffect)(() => {
306
+ if (!biddingOpen)
307
+ return;
308
+ const timer = setInterval(() => {
309
+ const at = Date.now();
310
+ setAuctionNow(at);
311
+ // Destructured rather than read off `state`, so the closure cannot go stale against a deadline
312
+ // a late bid has already moved — the deps re-arm it when one does.
313
+ if (endsAtMs - (at + serverOffsetMs) < -AUCTION_GRACE_MS) {
314
+ dispatch({ kind: 'auctionExpired' });
315
+ }
316
+ }, AUCTION_TICK_MS);
317
+ return () => clearInterval(timer);
318
+ }, [biddingOpen, endsAtMs, serverOffsetMs]);
319
+ /**
320
+ * Giveaway state off the polled record.
321
+ *
322
+ * The realtime start only reaches viewers already in the room, so without this a viewer who joined
323
+ * mid-giveaway would see no nudge at all — and one returning to the screen would be invited to
324
+ * enter a giveaway they are already in.
325
+ */
326
+ const activeGiveaway = (0, react_1.useMemo)(() => { var _a; return (_a = stream === null || stream === void 0 ? void 0 : stream.giveaways.find((giveaway) => giveaway.status === 'active')) !== null && _a !== void 0 ? _a : null; }, [stream]);
327
+ const bidderId = (_f = (_e = identity === null || identity === void 0 ? void 0 : identity.bidderId) === null || _e === void 0 ? void 0 : _e.call(identity)) !== null && _f !== void 0 ? _f : null;
328
+ const customerRef = (_h = (_g = identity === null || identity === void 0 ? void 0 : identity.customerRef) === null || _g === void 0 ? void 0 : _g.call(identity)) !== null && _h !== void 0 ? _h : null;
329
+ /**
330
+ * Closes the gap between the realtime giveaway start and the polled record it depends on.
331
+ *
332
+ * The reducer knows the lot the instant the event lands, but `giveaway.active` below still needs a
333
+ * matching entry in the record. One fetch, not a poll: the record and the event are written by the
334
+ * same host action, so a single re-read right after the event is the fix.
335
+ */
336
+ const giveawayKnown = (0, react_1.useMemo)(() => !state.giveawayStoreProductId ||
337
+ !!(stream === null || stream === void 0 ? void 0 : stream.giveaways.some((giveaway) => giveaway.storeProductId === state.giveawayStoreProductId)), [state.giveawayStoreProductId, stream]);
338
+ (0, react_1.useEffect)(() => {
339
+ if (giveawayKnown)
340
+ return;
341
+ void refreshStreamInfo();
342
+ }, [giveawayKnown, refreshStreamInfo]);
343
+ (0, react_1.useEffect)(() => {
344
+ if (!activeGiveaway)
345
+ return;
346
+ const entries = activeGiveaway.entries.filter((entry) => entry.active);
347
+ dispatch({
348
+ kind: 'giveawaySynced',
349
+ storeProductId: activeGiveaway.storeProductId,
350
+ entryCount: entries.length,
351
+ registered: !!bidderId && entries.some((entry) => entry.userId === bidderId),
352
+ });
353
+ }, [activeGiveaway, bidderId]);
354
+ // Host order first, then anything added mid-stream, minus anything withdrawn.
355
+ const storeProductIds = (0, react_1.useMemo)(() => {
356
+ if (!stream)
357
+ return [];
358
+ const merged = stream.products.map((product) => product.storeProductId);
359
+ for (const id of state.extraStoreProductIds) {
360
+ if (!merged.includes(id))
361
+ merged.push(id);
362
+ }
363
+ return merged.filter((id) => !state.removedStoreProductIds.includes(id));
364
+ }, [stream, state.extraStoreProductIds, state.removedStoreProductIds]);
365
+ /**
366
+ * Resolved through the commerce adapter, keyed on the joined ids so a mid-stream product add
367
+ * refetches and an unchanged set does not.
368
+ */
369
+ const [products, setProducts] = (0, react_1.useState)([]);
370
+ const productKey = storeProductIds.join(',');
371
+ (0, react_1.useEffect)(() => {
372
+ if (!productKey || !commerce) {
373
+ setProducts([]);
374
+ return;
375
+ }
376
+ let alive = true;
377
+ void commerce
378
+ .productsByIds(productKey.split(','))
379
+ .then((next) => {
380
+ if (alive)
381
+ setProducts(next);
382
+ })
383
+ .catch((caught) => logger.error(caught, { at: 'liveSession.products' }));
384
+ return () => {
385
+ alive = false;
386
+ };
387
+ // eslint-disable-next-line react-hooks/exhaustive-deps
388
+ }, [productKey]);
389
+ const activeProduct = (0, react_1.useMemo)(() => {
390
+ var _a, _b, _c, _d, _e, _f, _g;
391
+ if (!products.length)
392
+ return null;
393
+ const preferred = (_c = (_a = state.activeStoreProductId) !== null && _a !== void 0 ? _a : (_b = stream === null || stream === void 0 ? void 0 : stream.products.find((product) => product.isCurrent)) === null || _b === void 0 ? void 0 : _b.storeProductId) !== null && _c !== void 0 ? _c : (_d = stream === null || stream === void 0 ? void 0 : stream.products[0]) === null || _d === void 0 ? void 0 : _d.storeProductId;
394
+ if (!preferred)
395
+ return (_e = products[0]) !== null && _e !== void 0 ? _e : null;
396
+ return (_g = (_f = products.find((product) => product.storeProductId === preferred)) !== null && _f !== void 0 ? _f : products[0]) !== null && _g !== void 0 ? _g : null;
397
+ }, [products, state.activeStoreProductId, stream]);
398
+ const multiplier = (_k = (_j = state.multiplierOverride) !== null && _j !== void 0 ? _j : stream === null || stream === void 0 ? void 0 : stream.viewerMultiplier) !== null && _k !== void 0 ? _k : 1;
399
+ const elapsedMs = (stream === null || stream === void 0 ? void 0 : stream.startedAtMs) ? Math.max(0, now - stream.startedAtMs) : 0;
400
+ /**
401
+ * Percentages come off the raw counts, so the multiplier cannot skew them — it inflates only the
402
+ * total on display, exactly as the host's own dashboard does.
403
+ */
404
+ const poll = (0, react_1.useMemo)(() => {
405
+ var _a, _b;
406
+ if (!state.poll)
407
+ return null;
408
+ const rawTotal = (_b = (_a = state.pollResults) === null || _a === void 0 ? void 0 : _a.totalVotes) !== null && _b !== void 0 ? _b : 0;
409
+ return {
410
+ pollUuid: state.poll.pollUuid,
411
+ question: state.poll.question,
412
+ choices: state.poll.options.map((option) => {
413
+ var _a, _b;
414
+ const votes = (_b = (_a = state.pollResults) === null || _a === void 0 ? void 0 : _a.votes[option.optionId]) !== null && _b !== void 0 ? _b : 0;
415
+ return {
416
+ ...option,
417
+ votes,
418
+ percent: rawTotal > 0 ? Math.round((votes / rawTotal) * 100) : 0,
419
+ };
420
+ }),
421
+ totalVotes: Math.round(rawTotal * multiplier),
422
+ votedOptionId: state.votedOptionId,
423
+ };
424
+ }, [state.poll, state.pollResults, state.votedOptionId, multiplier]);
425
+ /**
426
+ * What `voteInPoll` reads at the moment it fires.
427
+ *
428
+ * Through a ref rather than a dependency so the action stays identity-stable: `actions` goes into
429
+ * effect dependency arrays, and rebuilding it when a poll opens would re-run them — including the
430
+ * one that binds the video, which is a real `startPlayingStream`.
431
+ */
432
+ const voteContextRef = (0, react_1.useRef)({
433
+ poll: state.poll,
434
+ votedOptionId: state.votedOptionId,
435
+ streamingId: stream === null || stream === void 0 ? void 0 : stream.streamingId,
436
+ });
437
+ voteContextRef.current = {
438
+ poll: state.poll,
439
+ votedOptionId: state.votedOptionId,
440
+ streamingId: stream === null || stream === void 0 ? void 0 : stream.streamingId,
441
+ };
442
+ /**
443
+ * The poll this install has already submitted for, set synchronously.
444
+ *
445
+ * `voteContextRef` is only as fresh as the last committed render, so two taps in one frame both
446
+ * read "not voted yet". The reducer ignores the second, but the request had already gone out — two
447
+ * votes on the server from one device, which the multiplier then doubles again.
448
+ */
449
+ const submittedPollRef = (0, react_1.useRef)(null);
450
+ /**
451
+ * Reads back a vote cast in an earlier session.
452
+ *
453
+ * The room is re-joined on far more than a fresh install — a relaunch, a long spell in the
454
+ * background, a Fast Refresh — and each starts this reducer from nothing while the poll on the wire
455
+ * carries on. Without this the viewer is re-offered a poll they answered, and the service, which
456
+ * does not dedupe, would count them twice.
457
+ */
458
+ (0, react_1.useEffect)(() => {
459
+ var _a;
460
+ const pollUuid = (_a = state.poll) === null || _a === void 0 ? void 0 : _a.pollUuid;
461
+ if (!pollUuid || state.votedOptionId)
462
+ return;
463
+ let alive = true;
464
+ void liveSelling_1.liveSelling.polls
465
+ .loadVotedOption(pollUuid)
466
+ .then((optionId) => {
467
+ if (!alive || !optionId)
468
+ return;
469
+ submittedPollRef.current = pollUuid;
470
+ dispatch({ kind: 'restoreVote', pollUuid, optionId });
471
+ })
472
+ .catch((caught) => logger.error(caught, { at: 'liveSession.votedOption' }));
473
+ return () => {
474
+ alive = false;
475
+ };
476
+ // eslint-disable-next-line react-hooks/exhaustive-deps
477
+ }, [(_l = state.poll) === null || _l === void 0 ? void 0 : _l.pollUuid]);
478
+ const bindVideoView = (0, react_1.useCallback)((reactTag, force = false) => {
479
+ liveSelling_1.liveSelling.engine
480
+ .bindView(reactTag, force)
481
+ .catch((caught) => (0, client_1.getLogger)().error(caught, { at: 'liveSession.bindView' }));
482
+ }, []);
483
+ const sendComment = (0, react_1.useCallback)(async (text) => {
484
+ var _a;
485
+ if (!text.trim())
486
+ return;
487
+ try {
488
+ // The host mutes by device; a per-install viewer id is the same granularity.
489
+ const deviceId = (_a = viewerIdRef.current) !== null && _a !== void 0 ? _a : (await liveSelling_1.liveSelling.viewer.id());
490
+ const own = await liveSelling_1.liveSelling.engine.sendComment(text, deviceId);
491
+ if (own)
492
+ dispatch({ kind: 'ownComment', comment: own });
493
+ }
494
+ catch (caught) {
495
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.sendComment' });
496
+ }
497
+ }, []);
498
+ const sendReaction = (0, react_1.useCallback)(async (reactionUrl) => {
499
+ try {
500
+ await liveSelling_1.liveSelling.engine.sendReaction(reactionUrl);
501
+ }
502
+ catch (caught) {
503
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.sendReaction' });
504
+ }
505
+ }, []);
506
+ const announceAddToCart = (0, react_1.useCallback)(async (productTitle) => {
507
+ try {
508
+ await liveSelling_1.liveSelling.engine.sendCartAnnouncement(productTitle);
509
+ }
510
+ catch (caught) {
511
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.announceAddToCart' });
512
+ }
513
+ }, []);
514
+ /**
515
+ * Add to cart, then tell the room — and only in that order.
516
+ *
517
+ * A refused line (out of stock, or a hold someone else owns) must not be announced: that card is
518
+ * what everyone else in the room reads as "it is going".
519
+ */
520
+ const addToCart = (0, react_1.useCallback)(async (variantId, productTitle) => {
521
+ const adapter = (0, client_1.getCommerce)();
522
+ if (!(adapter === null || adapter === void 0 ? void 0 : adapter.addLine))
523
+ return false;
524
+ try {
525
+ const landed = await adapter.addLine(variantId, 1);
526
+ if (!landed)
527
+ return false;
528
+ await liveSelling_1.liveSelling.engine.sendCartAnnouncement(productTitle);
529
+ return true;
530
+ }
531
+ catch (caught) {
532
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.addToCart', variantId });
533
+ return false;
534
+ }
535
+ }, []);
536
+ const voteInPoll = (0, react_1.useCallback)(async (optionId) => {
537
+ var _a, _b, _c;
538
+ const { poll, votedOptionId, streamingId: current } = voteContextRef.current;
539
+ // One vote per install per poll. The tally is only revealed by voting, so a second tap is a
540
+ // double tap rather than a change of mind — and the endpoint cannot retract one.
541
+ if (!poll || votedOptionId || submittedPollRef.current === poll.pollUuid)
542
+ return;
543
+ const forStream = (_a = poll.streamingId) !== null && _a !== void 0 ? _a : current;
544
+ if (!forStream)
545
+ return;
546
+ // Claimed before anything awaits, so a second tap in the same frame cannot get past the guard.
547
+ submittedPollRef.current = poll.pollUuid;
548
+ dispatch({ kind: 'vote', optionId });
549
+ try {
550
+ const viewerId = (_b = viewerIdRef.current) !== null && _b !== void 0 ? _b : (await liveSelling_1.liveSelling.viewer.id());
551
+ await liveSelling_1.liveSelling.polls.vote({
552
+ streamingId: forStream,
553
+ pollUuid: poll.pollUuid,
554
+ viewerId,
555
+ optionId: (_c = poll.wireOptionIds[optionId]) !== null && _c !== void 0 ? _c : optionId,
556
+ });
557
+ // Written only once the service has counted it, so a failed request leaves no trace.
558
+ await liveSelling_1.liveSelling.polls.rememberVote(poll.pollUuid, optionId);
559
+ }
560
+ catch (caught) {
561
+ // Put it back the way it was: a vote that never landed must not look counted, or the viewer is
562
+ // locked out of the poll by a failure they cannot see.
563
+ submittedPollRef.current = null;
564
+ dispatch({ kind: 'voteFailed', optionId });
565
+ void liveSelling_1.liveSelling.polls.forgetVote(poll.pollUuid).catch(() => undefined);
566
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.voteInPoll' });
567
+ }
568
+ }, []);
569
+ /** Kept for the whole stint because the invite token is the only credential the leave endpoint has. */
570
+ const inviteTokenRef = (0, react_1.useRef)(null);
571
+ const joinAsGuest = (0, react_1.useCallback)(async (params) => {
572
+ const credentials = await liveSelling_1.liveSelling.guest.acceptInvite(params);
573
+ if (!credentials)
574
+ throw new Error('That invite could not be redeemed');
575
+ inviteTokenRef.current = params.inviteToken;
576
+ await liveSelling_1.liveSelling.engine.joinAsGuest(credentials, params.previewTag);
577
+ return credentials;
578
+ }, []);
579
+ const leaveGuest = (0, react_1.useCallback)(async () => {
580
+ const inviteToken = inviteTokenRef.current;
581
+ inviteTokenRef.current = null;
582
+ // Engine first, REST second, and the REST failure is swallowed: a stale slot server-side is
583
+ // recoverable (the host can remove it), whereas leaving the room joined is not — it would keep
584
+ // publishing this camera after the screen is gone.
585
+ await liveSelling_1.liveSelling.engine.leaveGuest();
586
+ if (!inviteToken)
587
+ return;
588
+ try {
589
+ await liveSelling_1.liveSelling.guest.leaveSlot(inviteToken);
590
+ }
591
+ catch (caught) {
592
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.leaveGuestSlot' });
593
+ }
594
+ }, []);
595
+ const setGuestMicMuted = (0, react_1.useCallback)((next) => liveSelling_1.liveSelling.engine.setGuestMicMuted(next), []);
596
+ const setGuestCameraOff = (0, react_1.useCallback)((off) => liveSelling_1.liveSelling.engine.setGuestCameraOff(off), []);
597
+ const flipGuestCamera = (0, react_1.useCallback)((front) => liveSelling_1.liveSelling.engine.flipGuestCamera(front), []);
598
+ const bindGuestPreview = (0, react_1.useCallback)((reactTag) => {
599
+ liveSelling_1.liveSelling.engine
600
+ .bindGuestPreview(reactTag)
601
+ .catch((caught) => (0, client_1.getLogger)().error(caught, { at: 'liveSession.bindGuestPreview' }));
602
+ }, []);
603
+ const bindGuestHostView = (0, react_1.useCallback)((reactTag) => {
604
+ liveSelling_1.liveSelling.engine
605
+ .bindGuestHostView(reactTag)
606
+ .catch((caught) => (0, client_1.getLogger)().error(caught, { at: 'liveSession.bindGuestHostView' }));
607
+ }, []);
608
+ const chooseViewerName = (0, react_1.useCallback)(async (raw) => {
609
+ const saved = await liveSelling_1.liveSelling.viewer.saveName(raw);
610
+ if (saved)
611
+ setChosenName(saved);
612
+ return saved;
613
+ }, []);
614
+ const applyMuted = (0, react_1.useCallback)((next) => {
615
+ setMuted(next);
616
+ liveSelling_1.liveSelling.engine.setMuted(next);
617
+ }, []);
618
+ const enterPip = (0, react_1.useCallback)(() => setPipMode('inapp'), []);
619
+ const exitPip = (0, react_1.useCallback)((options) => {
620
+ setPipMode('none');
621
+ if (!(options === null || options === void 0 ? void 0 : options.stopVideo))
622
+ return;
623
+ liveSelling_1.liveSelling.engine
624
+ .stopPlayback()
625
+ .catch((caught) => (0, client_1.getLogger)().error(caught, { at: 'liveSession.exitPip' }));
626
+ }, []);
627
+ /**
628
+ * The lot under the hammer.
629
+ *
630
+ * The host names it when opening the round, and that wins over the featured product: the host opens
631
+ * a round on the lot it means to sell, while a viewer's product-change may not have landed yet.
632
+ * Bidding on the wrong lot because the card had not caught up is the failure this avoids.
633
+ */
634
+ const biddingLot = (0, react_1.useMemo)(() => {
635
+ var _a, _b;
636
+ const id = state.biddingStoreProductId;
637
+ if (!stream)
638
+ return null;
639
+ if (id)
640
+ return (_a = stream.products.find((product) => product.storeProductId === id)) !== null && _a !== void 0 ? _a : null;
641
+ return (_b = stream.products.find((product) => product.isCurrent)) !== null && _b !== void 0 ? _b : null;
642
+ }, [stream, state.biddingStoreProductId]);
643
+ /**
644
+ * The bidder, as the host will see them.
645
+ *
646
+ * `bidderId` and not the viewer id, because the host attributes the win to a customer and turns it
647
+ * into a cart. It is also what comes back on the host's broadcast, so `isLeading` compares like
648
+ * with like. Null when signed out, which is exactly when bidding is not offered.
649
+ */
650
+ const bidderEmail = (_o = (_m = identity === null || identity === void 0 ? void 0 : identity.customerEmail) === null || _m === void 0 ? void 0 : _m.call(identity)) !== null && _o !== void 0 ? _o : null;
651
+ const bidder = (0, react_1.useMemo)(() => (bidderId ? { userId: bidderId, userName: viewerName, email: bidderEmail } : null), [bidderId, viewerName, bidderEmail]);
652
+ const auction = (0, react_1.useMemo)(() => {
653
+ var _a, _b, _c, _d, _e, _f, _g;
654
+ // Falls back to the lot's opening price: until the first bid lands the host sends no next bid,
655
+ // and a slide-to-bid showing zero is worse than useless.
656
+ const nextBid = state.nextBid || (biddingLot === null || biddingLot === void 0 ? void 0 : biddingLot.baseBidPrice) || 0;
657
+ return {
658
+ biddingOpen: state.biddingOpen,
659
+ highestBid: state.highestBid,
660
+ nextBid,
661
+ timeLeftMs: (0, session_1.timeLeftMs)(state, auctionNow),
662
+ isLeading: !!bidder && ((_a = state.bidLeader) === null || _a === void 0 ? void 0 : _a.userId) === bidder.userId,
663
+ leaderName: (_c = (_b = state.bidLeader) === null || _b === void 0 ? void 0 : _b.userName) !== null && _c !== void 0 ? _c : null,
664
+ extended: state.auctionExtended,
665
+ storeProductId: (_e = (_d = state.biddingStoreProductId) !== null && _d !== void 0 ? _d : biddingLot === null || biddingLot === void 0 ? void 0 : biddingLot.storeProductId) !== null && _e !== void 0 ? _e : null,
666
+ lastWin: state.lastWin,
667
+ bidCap: (_f = stream === null || stream === void 0 ? void 0 : stream.bidCap) !== null && _f !== void 0 ? _f : null,
668
+ extendedBiddingMs: (_g = stream === null || stream === void 0 ? void 0 : stream.extendedBiddingMs) !== null && _g !== void 0 ? _g : 0,
669
+ };
670
+ }, [state, auctionNow, biddingLot, bidder, stream]);
671
+ const giveaway = (0, react_1.useMemo)(() => {
672
+ var _a, _b;
673
+ const result = state.giveawayResult;
674
+ // Entrant names come off the record's roster rather than the event, which carries only the
675
+ // winner. A draw the record has not caught up with still celebrates, with fewer names in it.
676
+ const settled = result
677
+ ? stream === null || stream === void 0 ? void 0 : stream.giveaways.find((item) => item.storeProductId === result.storeProductId && item.status === 'ended')
678
+ : undefined;
679
+ return {
680
+ active: state.giveawayStoreProductId
681
+ ? ((_a = stream === null || stream === void 0 ? void 0 : stream.giveaways.find((item) => item.storeProductId === state.giveawayStoreProductId)) !== null && _a !== void 0 ? _a : null)
682
+ : null,
683
+ entryCount: state.giveawayEntryCount,
684
+ registered: state.giveawayRegistered,
685
+ result: result
686
+ ? { winnerName: result.winnerName, entrantNames: (_b = settled === null || settled === void 0 ? void 0 : settled.entrantNames) !== null && _b !== void 0 ? _b : [] }
687
+ : null,
688
+ };
689
+ }, [
690
+ state.giveawayStoreProductId,
691
+ state.giveawayEntryCount,
692
+ state.giveawayRegistered,
693
+ state.giveawayResult,
694
+ stream,
695
+ ]);
696
+ /**
697
+ * The round, the stream and the bidder, for the actions to read when they fire.
698
+ *
699
+ * Through refs rather than dependencies, and that is not an optimisation: `actions` has to keep a
700
+ * stable identity, because callers put it in effect dependency arrays and one of those effects
701
+ * rebinds the video. Depending on the round directly would mint a new `placeBid`, and so a new
702
+ * `actions`, on every comment and every viewer-count update.
703
+ */
704
+ const roundRef = (0, react_1.useRef)({ auction, moderatorId: state.moderatorId, endsAtMs, serverOffsetMs });
705
+ roundRef.current = { auction, moderatorId: state.moderatorId, endsAtMs, serverOffsetMs };
706
+ const streamRef = (0, react_1.useRef)(stream);
707
+ streamRef.current = stream;
708
+ const bidderRef = (0, react_1.useRef)(bidder);
709
+ bidderRef.current = bidder;
710
+ const giveawayLotRef = (0, react_1.useRef)(state.giveawayStoreProductId);
711
+ giveawayLotRef.current = state.giveawayStoreProductId;
712
+ const customerRefRef = (0, react_1.useRef)(customerRef);
713
+ customerRefRef.current = customerRef;
714
+ const viewerNameRef = (0, react_1.useRef)(viewerName);
715
+ viewerNameRef.current = viewerName;
716
+ /**
717
+ * Offers a bid to the moderator and records it.
718
+ *
719
+ * Both paths run, and the order matters: the engine first because that is what moves the round,
720
+ * REST second because that is what survives. A REST failure is logged rather than surfaced — the
721
+ * bid is already in flight, and telling the bidder it failed when the host has accepted it would be
722
+ * a lie.
723
+ */
724
+ const placeBid = (0, react_1.useCallback)(async (options) => {
725
+ var _a, _b;
726
+ const round = roundRef.current;
727
+ const current = streamRef.current;
728
+ const who = bidderRef.current;
729
+ if (!current || !who)
730
+ return;
731
+ const amount = (_a = options === null || options === void 0 ? void 0 : options.amount) !== null && _a !== void 0 ? _a : round.auction.nextBid;
732
+ if (!amount || amount <= 0)
733
+ return;
734
+ try {
735
+ await liveSelling_1.liveSelling.engine.sendBid({
736
+ bidAmount: amount,
737
+ bidder: who,
738
+ moderatorId: round.moderatorId,
739
+ // Where in the round the bid landed, on the host's clock — the host uses it to order bids
740
+ // that arrive within the same instant.
741
+ placedAtMs: round.endsAtMs - Math.max(0, round.endsAtMs - (Date.now() + round.serverOffsetMs)),
742
+ variantId: (_b = options === null || options === void 0 ? void 0 : options.variantId) !== null && _b !== void 0 ? _b : null,
743
+ });
744
+ }
745
+ catch (caught) {
746
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.placeBid', amount });
747
+ }
748
+ try {
749
+ await liveSelling_1.liveSelling.auctions.recordBid({
750
+ streamingId: current.streamingId,
751
+ amount,
752
+ bidderId: who.userId,
753
+ bidderName: who.userName,
754
+ storeProductId: round.auction.storeProductId,
755
+ });
756
+ }
757
+ catch (caught) {
758
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.recordBid', amount });
759
+ }
760
+ }, []);
761
+ const enterGiveaway = (0, react_1.useCallback)(async () => {
762
+ var _a;
763
+ const lot = giveawayLotRef.current;
764
+ const current = streamRef.current;
765
+ const customer = customerRefRef.current;
766
+ if (!current || !lot || !customer)
767
+ return;
768
+ try {
769
+ const viewerId = (_a = viewerIdRef.current) !== null && _a !== void 0 ? _a : (await liveSelling_1.liveSelling.viewer.id());
770
+ const entered = await liveSelling_1.liveSelling.auctions.enterGiveaway({
771
+ streamingId: current.streamingId,
772
+ storeProductId: lot,
773
+ customerGid: customer,
774
+ displayName: viewerNameRef.current,
775
+ viewerId,
776
+ });
777
+ // Only on the host's confirmation. The entry count follows from its own push, so nothing here
778
+ // guesses at it.
779
+ if (entered)
780
+ dispatch({ kind: 'giveawayRegistered' });
781
+ }
782
+ catch (caught) {
783
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.enterGiveaway' });
784
+ }
785
+ }, []);
786
+ const dismissAuctionWin = (0, react_1.useCallback)(() => dispatch({ kind: 'dismissWin' }), []);
787
+ const dismissGiveawayResult = (0, react_1.useCallback)(() => dispatch({ kind: 'dismissGiveawayResult' }), []);
788
+ // Every callback above is identity-stable, so this object is minted once. That is deliberate:
789
+ // callers put `actions` in effect dependency arrays, and a fresh object each render makes those
790
+ // effects re-run every render — which meant re-binding the video on every state tick.
791
+ const actions = (0, react_1.useMemo)(() => ({
792
+ bindVideoView,
793
+ sendComment,
794
+ sendReaction,
795
+ announceAddToCart,
796
+ addToCart,
797
+ voteInPoll,
798
+ chooseViewerName,
799
+ joinAsGuest,
800
+ leaveGuest,
801
+ setGuestMicMuted,
802
+ setGuestCameraOff,
803
+ flipGuestCamera,
804
+ bindGuestPreview,
805
+ bindGuestHostView,
806
+ setMuted: applyMuted,
807
+ enterPip,
808
+ exitPip,
809
+ placeBid,
810
+ enterGiveaway,
811
+ dismissAuctionWin,
812
+ dismissGiveawayResult,
813
+ }), [
814
+ bindVideoView,
815
+ sendComment,
816
+ sendReaction,
817
+ announceAddToCart,
818
+ addToCart,
819
+ voteInPoll,
820
+ chooseViewerName,
821
+ joinAsGuest,
822
+ leaveGuest,
823
+ setGuestMicMuted,
824
+ setGuestCameraOff,
825
+ flipGuestCamera,
826
+ bindGuestPreview,
827
+ bindGuestHostView,
828
+ applyMuted,
829
+ enterPip,
830
+ exitPip,
831
+ placeBid,
832
+ enterGiveaway,
833
+ dismissAuctionWin,
834
+ dismissGiveawayResult,
835
+ ]);
836
+ return {
837
+ status,
838
+ stream: stream !== null && stream !== void 0 ? stream : null,
839
+ viewerCount: Math.round(state.rawViewerCount * multiplier),
840
+ rawViewerCount: state.rawViewerCount,
841
+ comments: state.comments,
842
+ activeProduct,
843
+ products,
844
+ sold: state.sold,
845
+ reactions: state.reactions,
846
+ poll,
847
+ viewerName: namedBy,
848
+ guest: {
849
+ mode: state.guestMode,
850
+ status: state.guestStatus,
851
+ slot: state.guestSlot,
852
+ mutedByHost: state.guestMutedByHost,
853
+ cameraOffByHost: state.guestCameraOffByHost,
854
+ mixerLayout: state.mixerLayout,
855
+ },
856
+ muted,
857
+ videoTag: state.videoTag,
858
+ elapsedMs,
859
+ pipMode,
860
+ auction,
861
+ giveaway,
862
+ error,
863
+ actions,
864
+ };
865
+ }
866
+ /**
867
+ * Holds one live session for the whole app. Nothing is joined while no stream is live, so the engine
868
+ * stays idle on an ordinary session.
869
+ *
870
+ * Goes *inside* `LiveSellingProvider`, which is what configures the client.
871
+ */
872
+ function LiveSessionProvider({ children, ...props }) {
873
+ const session = useSessionValue(props);
874
+ return (0, react_1.createElement)(LiveSessionContext.Provider, { value: session }, children);
875
+ }
876
+ //# sourceMappingURL=useLiveSession.js.map