@tiledev/sdk-apptile-live-selling 0.8.1 → 0.9.1

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 (196) hide show
  1. package/README.md +452 -34
  2. package/dist/auctionComments.d.ts +25 -0
  3. package/dist/auctionComments.d.ts.map +1 -0
  4. package/dist/auctionComments.js +28 -0
  5. package/dist/auctionComments.js.map +1 -0
  6. package/dist/auctionRules.d.ts +76 -0
  7. package/dist/auctionRules.d.ts.map +1 -0
  8. package/dist/auctionRules.js +106 -0
  9. package/dist/auctionRules.js.map +1 -0
  10. package/dist/auctions.d.ts.map +1 -1
  11. package/dist/auctions.js +19 -10
  12. package/dist/auctions.js.map +1 -1
  13. package/dist/client.d.ts +51 -2
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +123 -23
  16. package/dist/client.js.map +1 -1
  17. package/dist/commentHistory.d.ts +66 -0
  18. package/dist/commentHistory.d.ts.map +1 -0
  19. package/dist/commentHistory.js +125 -0
  20. package/dist/commentHistory.js.map +1 -0
  21. package/dist/engine.native.d.ts.map +1 -1
  22. package/dist/engine.native.js +17 -845
  23. package/dist/engine.native.js.map +1 -1
  24. package/dist/giveawayPrize.d.ts +13 -0
  25. package/dist/giveawayPrize.d.ts.map +1 -0
  26. package/dist/giveawayPrize.js +18 -0
  27. package/dist/giveawayPrize.js.map +1 -0
  28. package/dist/giveawayPrizeLines.d.ts +67 -0
  29. package/dist/giveawayPrizeLines.d.ts.map +1 -0
  30. package/dist/giveawayPrizeLines.js +48 -0
  31. package/dist/giveawayPrizeLines.js.map +1 -0
  32. package/dist/giveawayRoster.d.ts +53 -0
  33. package/dist/giveawayRoster.d.ts.map +1 -0
  34. package/dist/giveawayRoster.js +93 -0
  35. package/dist/giveawayRoster.js.map +1 -0
  36. package/dist/heldComments.d.ts +29 -0
  37. package/dist/heldComments.d.ts.map +1 -0
  38. package/dist/heldComments.js +61 -0
  39. package/dist/heldComments.js.map +1 -0
  40. package/dist/hiddenShows.d.ts +34 -0
  41. package/dist/hiddenShows.d.ts.map +1 -0
  42. package/dist/hiddenShows.js +42 -0
  43. package/dist/hiddenShows.js.map +1 -0
  44. package/dist/index.d.ts +19 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +93 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/liveHaptics.d.ts +7 -0
  49. package/dist/liveHaptics.d.ts.map +1 -0
  50. package/dist/liveHaptics.js +11 -0
  51. package/dist/liveHaptics.js.map +1 -0
  52. package/dist/liveHaptics.native.d.ts +7 -0
  53. package/dist/liveHaptics.native.d.ts.map +1 -0
  54. package/dist/liveHaptics.native.js +12 -0
  55. package/dist/liveHaptics.native.js.map +1 -0
  56. package/dist/liveShow.d.ts +58 -0
  57. package/dist/liveShow.d.ts.map +1 -0
  58. package/dist/liveShow.js +55 -0
  59. package/dist/liveShow.js.map +1 -0
  60. package/dist/phoneTaps.d.ts +25 -0
  61. package/dist/phoneTaps.d.ts.map +1 -0
  62. package/dist/phoneTaps.js +47 -0
  63. package/dist/phoneTaps.js.map +1 -0
  64. package/dist/pipWindow.d.ts +85 -0
  65. package/dist/pipWindow.d.ts.map +1 -0
  66. package/dist/pipWindow.js +88 -0
  67. package/dist/pipWindow.js.map +1 -0
  68. package/dist/polls.d.ts.map +1 -1
  69. package/dist/polls.js +3 -0
  70. package/dist/polls.js.map +1 -1
  71. package/dist/react/LiveSellingProvider.d.ts +10 -1
  72. package/dist/react/LiveSellingProvider.d.ts.map +1 -1
  73. package/dist/react/LiveSellingProvider.js +6 -2
  74. package/dist/react/LiveSellingProvider.js.map +1 -1
  75. package/dist/react/LiveVideoTarget.d.ts.map +1 -1
  76. package/dist/react/LiveVideoTarget.js +41 -3
  77. package/dist/react/LiveVideoTarget.js.map +1 -1
  78. package/dist/react/clocks.d.ts +29 -0
  79. package/dist/react/clocks.d.ts.map +1 -0
  80. package/dist/react/clocks.js +124 -0
  81. package/dist/react/clocks.js.map +1 -0
  82. package/dist/react/index.d.ts +7 -2
  83. package/dist/react/index.d.ts.map +1 -1
  84. package/dist/react/index.js +15 -1
  85. package/dist/react/index.js.map +1 -1
  86. package/dist/react/index.web.d.ts +9 -2
  87. package/dist/react/index.web.d.ts.map +1 -1
  88. package/dist/react/index.web.js +27 -4
  89. package/dist/react/index.web.js.map +1 -1
  90. package/dist/react/startSignIn.d.ts +3 -0
  91. package/dist/react/startSignIn.d.ts.map +1 -0
  92. package/dist/react/startSignIn.js +19 -0
  93. package/dist/react/startSignIn.js.map +1 -0
  94. package/dist/react/useAuctionBidGate.d.ts +54 -4
  95. package/dist/react/useAuctionBidGate.d.ts.map +1 -1
  96. package/dist/react/useAuctionBidGate.js +169 -25
  97. package/dist/react/useAuctionBidGate.js.map +1 -1
  98. package/dist/react/useAuctionWins.d.ts +9 -1
  99. package/dist/react/useAuctionWins.d.ts.map +1 -1
  100. package/dist/react/useAuctionWins.js +9 -3
  101. package/dist/react/useAuctionWins.js.map +1 -1
  102. package/dist/react/useGiveawayEntry.d.ts +59 -0
  103. package/dist/react/useGiveawayEntry.d.ts.map +1 -0
  104. package/dist/react/useGiveawayEntry.js +170 -0
  105. package/dist/react/useGiveawayEntry.js.map +1 -0
  106. package/dist/react/useGiveawayPrize.d.ts +12 -0
  107. package/dist/react/useGiveawayPrize.d.ts.map +1 -0
  108. package/dist/react/useGiveawayPrize.js +53 -0
  109. package/dist/react/useGiveawayPrize.js.map +1 -0
  110. package/dist/react/useHiddenShows.d.ts +10 -0
  111. package/dist/react/useHiddenShows.d.ts.map +1 -0
  112. package/dist/react/useHiddenShows.js +30 -0
  113. package/dist/react/useHiddenShows.js.map +1 -0
  114. package/dist/react/useLiveSession.d.ts +7 -3
  115. package/dist/react/useLiveSession.d.ts.map +1 -1
  116. package/dist/react/useLiveSession.js +522 -157
  117. package/dist/react/useLiveSession.js.map +1 -1
  118. package/dist/react/useLiveShow.d.ts +3 -0
  119. package/dist/react/useLiveShow.d.ts.map +1 -0
  120. package/dist/react/useLiveShow.js +21 -0
  121. package/dist/react/useLiveShow.js.map +1 -0
  122. package/dist/react/useLiveStreams.d.ts.map +1 -1
  123. package/dist/react/useLiveStreams.js +6 -2
  124. package/dist/react/useLiveStreams.js.map +1 -1
  125. package/dist/react/useOngoingLive.d.ts +1 -0
  126. package/dist/react/useOngoingLive.d.ts.map +1 -1
  127. package/dist/react/useOngoingLive.js +7 -1
  128. package/dist/react/useOngoingLive.js.map +1 -1
  129. package/dist/react/useReplays.d.ts +12 -0
  130. package/dist/react/useReplays.d.ts.map +1 -1
  131. package/dist/react/useReplays.js +56 -24
  132. package/dist/react/useReplays.js.map +1 -1
  133. package/dist/react/useStreamInfo.d.ts +13 -2
  134. package/dist/react/useStreamInfo.d.ts.map +1 -1
  135. package/dist/react/useStreamInfo.js +31 -5
  136. package/dist/react/useStreamInfo.js.map +1 -1
  137. package/dist/replayPages.d.ts +31 -0
  138. package/dist/replayPages.d.ts.map +1 -0
  139. package/dist/replayPages.js +34 -0
  140. package/dist/replayPages.js.map +1 -0
  141. package/dist/replays.d.ts +7 -0
  142. package/dist/replays.d.ts.map +1 -1
  143. package/dist/replays.js +52 -22
  144. package/dist/replays.js.map +1 -1
  145. package/dist/session.d.ts +39 -6
  146. package/dist/session.d.ts.map +1 -1
  147. package/dist/session.js +209 -22
  148. package/dist/session.js.map +1 -1
  149. package/dist/sessionTypes.d.ts +190 -14
  150. package/dist/sessionTypes.d.ts.map +1 -1
  151. package/dist/sessionTypes.js +2 -1
  152. package/dist/sessionTypes.js.map +1 -1
  153. package/dist/shopifyCommerce.d.ts +149 -0
  154. package/dist/shopifyCommerce.d.ts.map +1 -0
  155. package/dist/shopifyCommerce.js +123 -0
  156. package/dist/shopifyCommerce.js.map +1 -0
  157. package/dist/shopifyIdentity.d.ts +51 -0
  158. package/dist/shopifyIdentity.d.ts.map +1 -0
  159. package/dist/shopifyIdentity.js +52 -0
  160. package/dist/shopifyIdentity.js.map +1 -0
  161. package/dist/shopifyIds.d.ts +9 -0
  162. package/dist/shopifyIds.d.ts.map +1 -0
  163. package/dist/shopifyIds.js +18 -0
  164. package/dist/shopifyIds.js.map +1 -0
  165. package/dist/showLinks.d.ts +24 -0
  166. package/dist/showLinks.d.ts.map +1 -0
  167. package/dist/showLinks.js +23 -0
  168. package/dist/showLinks.js.map +1 -0
  169. package/dist/showLots.d.ts +28 -0
  170. package/dist/showLots.d.ts.map +1 -0
  171. package/dist/showLots.js +51 -0
  172. package/dist/showLots.js.map +1 -0
  173. package/dist/showNames.d.ts +45 -0
  174. package/dist/showNames.d.ts.map +1 -0
  175. package/dist/showNames.js +74 -0
  176. package/dist/showNames.js.map +1 -0
  177. package/dist/streams.d.ts +27 -1
  178. package/dist/streams.d.ts.map +1 -1
  179. package/dist/streams.js +84 -21
  180. package/dist/streams.js.map +1 -1
  181. package/dist/types.d.ts +214 -8
  182. package/dist/types.d.ts.map +1 -1
  183. package/dist/types.js +2 -1
  184. package/dist/types.js.map +1 -1
  185. package/dist/wire.d.ts +31 -0
  186. package/dist/wire.d.ts.map +1 -1
  187. package/dist/wire.js +25 -0
  188. package/dist/wire.js.map +1 -1
  189. package/dist/zego.d.ts +3 -1
  190. package/dist/zego.d.ts.map +1 -1
  191. package/dist/zego.js +1 -1
  192. package/dist/zegoEngine.d.ts +168 -0
  193. package/dist/zegoEngine.d.ts.map +1 -0
  194. package/dist/zegoEngine.js +1052 -0
  195. package/dist/zegoEngine.js.map +1 -0
  196. package/package.json +3 -2
@@ -6,9 +6,17 @@ exports.LiveSessionProvider = LiveSessionProvider;
6
6
  * `LiveSessionProvider` / `useLiveSession` — the assembled session.
7
7
  *
8
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.
9
+ * state. It owns the picture-in-picture mode machine, product resolution through the commerce adapter,
10
+ * and every action a live screen fires.
11
+ *
12
+ * **It never ticks.** The session changes only when something happens — a room event, a record read,
13
+ * an action's answer — and is the same object until one of its fields changes. It holds moments, not
14
+ * clocks: when the round closes (`auction.closesAt`) and when the show started (`stream.startedAtMs`).
15
+ * A component that shows a clock counts to one of them itself (`useAuctionCountdown`, `useShowClock`,
16
+ * in `clocks.ts`), so only that component is drawn as the clock moves. Before 0.9.0 the session held
17
+ * both clocks, and every reader — every screen in the app, since the session is app-wide — was drawn
18
+ * five times a second during an auction round. Its own timers are timeouts to the moment they are for:
19
+ * the round's fallback close, and the next card or reaction to run out.
12
20
  *
13
21
  * **Mount it above your navigator.** The Zego engine holds exactly one room, and two places usually
14
22
  * render the same stream — a home-screen card and the full-screen player. Owning the session per
@@ -22,18 +30,17 @@ const react_1 = require("react");
22
30
  const react_native_1 = require("react-native");
23
31
  const cartAttributes_1 = require("../cartAttributes");
24
32
  const client_1 = require("../client");
33
+ const auctionRules_1 = require("../auctionRules");
34
+ const giveawayRoster_1 = require("../giveawayRoster");
35
+ const showNames_1 = require("../showNames");
36
+ const showLots_1 = require("../showLots");
37
+ const heldComments_1 = require("../heldComments");
25
38
  const liveSelling_1 = require("../liveSelling");
26
39
  const sdk_apptile_live_selling_native_1 = require("@tiledev/sdk-apptile-live-selling-native");
40
+ const pipWindow_1 = require("../pipWindow");
27
41
  const session_1 = require("../session");
28
42
  const useStreamInfo_1 = require("./useStreamInfo");
29
43
  const videoTargets_1 = require("./videoTargets");
30
- /** The elapsed timer and the transient-card pruning share one tick. */
31
- const TICK_MS = 1000;
32
- /**
33
- * The auction clock runs faster than the session tick. Seconds are the unit a bidder acts on, and a
34
- * 1s tick aliases against the deadline badly enough to show the same second twice and skip another.
35
- */
36
- const AUCTION_TICK_MS = 200;
37
44
  /**
38
45
  * How long past the deadline to wait before closing the round ourselves.
39
46
  *
@@ -42,6 +49,8 @@ const AUCTION_TICK_MS = 200;
42
49
  * already given up.
43
50
  */
44
51
  const AUCTION_GRACE_MS = 1000;
52
+ /** The longest wait a JavaScript timer can hold (2^31 − 1 ms, about 24 days); a longer one fires at once. */
53
+ const LONGEST_TIMER_MS = 2147483647;
45
54
  /**
46
55
  * How long the OS is given to actually put a picture-in-picture window up after the app goes away.
47
56
  *
@@ -51,6 +60,11 @@ const AUCTION_GRACE_MS = 1000;
51
60
  * means the host is talking to nobody from inside a backgrounded app.
52
61
  */
53
62
  const PIP_GRANT_MS = 1200;
63
+ /**
64
+ * The device said it has no picture-in-picture (L21). Module state, because it is a fact about the
65
+ * device rather than about one session: once heard, arming stops and the error is not reported again.
66
+ */
67
+ let pipUnavailableOnDevice = false;
54
68
  const LiveSessionContext = (0, react_1.createContext)(null);
55
69
  /**
56
70
  * Reads the shared live session.
@@ -68,12 +82,11 @@ function useLiveSession() {
68
82
  /* Everything but the stream hold, which belongs to the provider component: it owns which stream this
69
83
  hook is watching, so it is the only thing that can pin it. */
70
84
  function useSessionValue(props) {
71
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
85
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
72
86
  const { streamingId, viewerName: viewerNameInput, onPipRestore } = props;
73
87
  const [state, dispatch] = (0, react_1.useReducer)(session_1.sessionReducer, session_1.initialSessionState);
74
88
  const [joining, setJoining] = (0, react_1.useState)(false);
75
89
  const [error, setError] = (0, react_1.useState)(null);
76
- const [now, setNow] = (0, react_1.useState)(() => Date.now());
77
90
  const [muted, setMuted] = (0, react_1.useState)(false);
78
91
  const [pipMode, setPipMode] = (0, react_1.useState)('none');
79
92
  /**
@@ -88,6 +101,24 @@ function useSessionValue(props) {
88
101
  pipModeRef.current = pipMode;
89
102
  /** Read inside the PIP listeners, which subscribe once and would otherwise close over a stale value. */
90
103
  const handoverRef = (0, react_1.useRef)(false);
104
+ /**
105
+ * Where the picture was when the system window took it: the in-app bubble (`'inapp'`) or a
106
+ * full-screen player (`'none'`). Coming back puts it there again (`whereTheWindowCameFrom`).
107
+ */
108
+ const windowCameFromRef = (0, react_1.useRef)('none');
109
+ /**
110
+ * The session is taking the window down itself (iOS: the app came back with it still up), so the
111
+ * `onPipStop` that follows is not the shopper closing it.
112
+ */
113
+ const takingWindowDownRef = (0, react_1.useRef)(false);
114
+ /**
115
+ * The show was stopped because the app is away: no window came (`PIP_GRANT_MS`), or the shopper
116
+ * closed the window. Until the app is back in the foreground, a surface taking the picture does not
117
+ * start it again (`bindVideoView`). Without this, closing a window opened from the full-screen
118
+ * player handed the picture straight back to that player, still focused behind the window, and the
119
+ * show played on with sound behind an app nobody was looking at.
120
+ */
121
+ const stoppedWhileAwayRef = (0, react_1.useRef)(false);
91
122
  const viewerIdRef = (0, react_1.useRef)(null);
92
123
  const logger = (0, client_1.getLogger)();
93
124
  const identity = (0, client_1.getIdentity)();
@@ -100,7 +131,8 @@ function useSessionValue(props) {
100
131
  * name is consulted at all so a signed-in bidder is never asked to invent one.
101
132
  */
102
133
  const [chosenName, setChosenName] = (0, react_1.useState)(() => liveSelling_1.liveSelling.viewer.cachedName());
103
- const accountName = (viewerNameInput === null || viewerNameInput === void 0 ? void 0 : viewerNameInput.trim()) || null;
134
+ // Every name here is published to the whole room, so none may be an email (`publicName`, 0.9.0).
135
+ const accountName = (0, showNames_1.publicName)(viewerNameInput);
104
136
  (0, react_1.useEffect)(() => {
105
137
  if (accountName || chosenName)
106
138
  return;
@@ -113,31 +145,70 @@ function useSessionValue(props) {
113
145
  alive = false;
114
146
  };
115
147
  }, [accountName, chosenName]);
116
- 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;
117
- 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);
148
+ const customerName = (0, showNames_1.publicName)((_a = identity === null || identity === void 0 ? void 0 : identity.customerName) === null || _a === void 0 ? void 0 : _a.call(identity));
149
+ const namedBy = (_b = accountName !== null && accountName !== void 0 ? accountName : (0, showNames_1.publicName)(chosenName)) !== null && _b !== void 0 ? _b : customerName;
118
150
  const viewerName = namedBy !== null && namedBy !== void 0 ? namedBy : 'Anonymous';
151
+ const bidderId = (_d = (_c = identity === null || identity === void 0 ? void 0 : identity.bidderId) === null || _c === void 0 ? void 0 : _c.call(identity)) !== null && _d !== void 0 ? _d : null;
152
+ /**
153
+ * A signed-in shopper whose name has not loaded yet. They are never asked for one — the session
154
+ * uses the customer's own — so a comment sent now is held until it lands (L6).
155
+ */
156
+ const nameResolving = !namedBy && !!bidderId;
157
+ const accountNameRef = (0, react_1.useRef)(accountName);
158
+ accountNameRef.current = accountName;
159
+ const namedByRef = (0, react_1.useRef)(namedBy);
160
+ namedByRef.current = namedBy;
161
+ const nameResolvingRef = (0, react_1.useRef)(nameResolving);
162
+ nameResolvingRef.current = nameResolving;
119
163
  /**
120
164
  * Pushed to the engine rather than passed at join: the room is entered once, and a name given
121
165
  * mid-show must not cost a rejoin — that interrupts the video for everyone watching on this device.
122
166
  * Comments and reactions carry the author in their own payload.
167
+ *
168
+ * This effect alone is too late for the FIRST comment after a name is chosen, which is why
169
+ * `chooseViewerName` also sets it directly — see there.
123
170
  */
124
171
  (0, react_1.useEffect)(() => {
125
172
  liveSelling_1.liveSelling.engine.setViewerName(namedBy);
126
173
  }, [namedBy]);
127
- /**
128
- * The auction clock, separate from `now` so a 200ms tick does not redraw the comment pane five
129
- * times a second on an ordinary live stream. This one only runs while a round is open.
130
- */
131
- const [auctionNow, setAuctionNow] = (0, react_1.useState)(() => Date.now());
132
174
  // Poll so a stream ending (or the host swapping the product set) is noticed without a manual
133
175
  // refresh. The engine's channels cover in-stream changes; this covers the record itself. With no
134
176
  // `streamingId` it follows whichever stream is on air.
135
- const { stream, refresh: refreshStreamInfo } = (0, useStreamInfo_1.useStreamInfo)(streamingId);
177
+ const { stream, refresh: refreshStreamInfo, hasRead, forcedReadAt } = (0, useStreamInfo_1.useStreamInfo)(streamingId);
136
178
  const watching = streamingId !== null && streamingId !== void 0 ? streamingId : stream === null || stream === void 0 ? void 0 : stream.streamingId;
179
+ /** The comment pipeline; `heldComments` uses it, so it is defined before the show reset below. */
180
+ const deliverComment = (0, react_1.useCallback)(async (text, options) => {
181
+ var _a;
182
+ try {
183
+ // The host mutes by device; a per-install viewer id is the same granularity.
184
+ const deviceId = (_a = viewerIdRef.current) !== null && _a !== void 0 ? _a : (await liveSelling_1.liveSelling.viewer.id());
185
+ const own = await liveSelling_1.liveSelling.engine.sendComment(text, deviceId, options);
186
+ if (own)
187
+ dispatch({ kind: 'ownComment', comment: own });
188
+ }
189
+ catch (caught) {
190
+ (0, client_1.getLogger)().error(caught, { at: 'liveSession.sendComment' });
191
+ }
192
+ }, []);
193
+ const heldCommentsRef = (0, react_1.useRef)(null);
194
+ if (!heldCommentsRef.current) {
195
+ heldCommentsRef.current = new heldComments_1.HeldComments(({ text, options }) => deliverComment(text, options));
196
+ }
137
197
  (0, react_1.useEffect)(() => {
198
+ var _a;
138
199
  dispatch({ kind: 'reset' });
139
200
  setError(null);
201
+ // A comment held for the last show's room must not be said in this one.
202
+ (_a = heldCommentsRef.current) === null || _a === void 0 ? void 0 : _a.drop();
140
203
  }, [watching]);
204
+ /** The name arrived: say it to the engine first, then send what was waiting for it, in order. */
205
+ (0, react_1.useEffect)(() => {
206
+ var _a;
207
+ if (!namedBy || !((_a = heldCommentsRef.current) === null || _a === void 0 ? void 0 : _a.size))
208
+ return;
209
+ liveSelling_1.liveSelling.engine.setViewerName(namedBy);
210
+ void heldCommentsRef.current.release();
211
+ }, [namedBy]);
141
212
  (0, react_1.useEffect)(() => liveSelling_1.liveSelling.engine.subscribe((event) => {
142
213
  dispatch({ kind: 'event', event, at: Date.now() });
143
214
  }), []);
@@ -208,14 +279,42 @@ function useSessionValue(props) {
208
279
  * importantly an *unmute*, which is otherwise invisible until the app restarts. Keyed on the joined
209
280
  * ids because the array is a fresh object on every poll.
210
281
  */
211
- const mutedKey = ((_d = stream === null || stream === void 0 ? void 0 : stream.mutedDeviceIds) !== null && _d !== void 0 ? _d : []).join(',');
282
+ const mutedKey = ((_e = stream === null || stream === void 0 ? void 0 : stream.mutedDeviceIds) !== null && _e !== void 0 ? _e : []).join(',');
212
283
  (0, react_1.useEffect)(() => {
213
284
  if (!stream)
214
285
  return;
215
286
  liveSelling_1.liveSelling.engine.setMutedDeviceIds(mutedKey ? mutedKey.split(',') : []);
216
287
  // eslint-disable-next-line react-hooks/exhaustive-deps
217
288
  }, [mutedKey]);
218
- const status = (0, session_1.deriveStatus)(state, !!stream, joining, error, (stream === null || stream === void 0 ? void 0 : stream.status) === 'live');
289
+ /** The room's and the record's view, before the off-air wait below has its say. */
290
+ const derivedStatus = (0, session_1.deriveStatus)(state, !!stream, joining, error, (stream === null || stream === void 0 ? void 0 : stream.status) === 'live');
291
+ /**
292
+ * Nothing on air, settled — Freckled Poppy's `NOTHING_ON_AIR_MS`, moved here.
293
+ *
294
+ * With no record at all `deriveStatus` says `loading`, and keeps saying it: a deep link to a show
295
+ * that has gone, or a session with nothing live, spun for ever. After `offAirGraceMs` (4 s) with a
296
+ * read finished, nothing live in it, and the room not reconnecting, the status becomes `ended`.
297
+ *
298
+ * Every forced read (`refreshStreamRecords()`, which a screen calls on focus) starts the wait again.
299
+ * That is the guard against a stale record: a push tap lands on a screen whose last read predates
300
+ * the show, and without the restart the show would be declared over before the fresh read came
301
+ * back.
302
+ */
303
+ const nothingOnAir = (0, session_1.isNothingOnAir)({
304
+ hasRead,
305
+ streamStatus: (_f = stream === null || stream === void 0 ? void 0 : stream.status) !== null && _f !== void 0 ? _f : null,
306
+ status: derivedStatus,
307
+ });
308
+ const graceMs = (0, client_1.offAirGraceMs)();
309
+ const [settledOffAir, setSettledOffAir] = (0, react_1.useState)(false);
310
+ (0, react_1.useEffect)(() => {
311
+ setSettledOffAir(false);
312
+ if (!nothingOnAir)
313
+ return;
314
+ const timer = setTimeout(() => setSettledOffAir(true), graceMs);
315
+ return () => clearTimeout(timer);
316
+ }, [nothingOnAir, forcedReadAt, graceMs]);
317
+ const status = (0, session_1.withOffAirSettled)(derivedStatus, settledOffAir);
219
318
  // A PIP surface outlives the screen that opened it, so nothing else would take it down when the
220
319
  // host ends the stream — it would sit there over the app showing a frozen last frame.
221
320
  (0, react_1.useEffect)(() => {
@@ -227,15 +326,19 @@ function useSessionValue(props) {
227
326
  // The host's end broadcast reaches us long before the polled record catches up. Stop the picture,
228
327
  // then force a refetch so every poll-driven surface drops the stream instead of waiting out the
229
328
  // interval.
329
+ //
330
+ // Keyed on the status BEFORE the off-air wait: an end the wait declared has no record to catch up
331
+ // with, and its forced read would restart the wait — which would end the show again four seconds
332
+ // later, for ever.
230
333
  (0, react_1.useEffect)(() => {
231
- if (status !== 'ended')
334
+ if (derivedStatus !== 'ended')
232
335
  return;
233
336
  liveSelling_1.liveSelling.engine
234
337
  .stopPlayback()
235
338
  .catch((caught) => logger.error(caught, { at: 'liveSession.stopOnEnd' }));
236
339
  (0, useStreamInfo_1.refreshStreamRecords)();
237
340
  // eslint-disable-next-line react-hooks/exhaustive-deps
238
- }, [status]);
341
+ }, [derivedStatus]);
239
342
  /**
240
343
  * Whether a mounted surface has asked to hand over to a system window.
241
344
  *
@@ -253,16 +356,29 @@ function useSessionValue(props) {
253
356
  // `pipMode !== 'none'` covers the in-app bubble; `handoverWanted` is what lets the FULL-SCREEN
254
357
  // player hand over too. Without it, minimising from the player armed nothing, no window opened,
255
358
  // and the audio played on behind the app because the plugin declares the `audio` background mode.
359
+ //
360
+ // What was last armed, and when it was last disarmed, are what decide on Android whether a window
361
+ // that opens is ours — see `ownsSystemPipWindow`.
362
+ //
363
+ // Only while the show is `playing` (`shouldArmSystemWindow`): joining, a host off camera and an ended
364
+ // show have no picture to put in a window. A window already up stays armed whatever the status does.
365
+ const armedRef = (0, react_1.useRef)(false);
366
+ const disarmedAtRef = (0, react_1.useRef)(0);
256
367
  (0, react_1.useEffect)(() => {
257
368
  if (!sdk_apptile_live_selling_native_1.isLivePipSupported)
258
369
  return;
259
- const armed = pipMode !== 'none' || handoverWanted;
260
- void sdk_apptile_live_selling_native_1.livePip.setArmed(armed);
370
+ const armed = (0, pipWindow_1.shouldArmSystemWindow)({ pipMode, handoverWanted, status });
371
+ if (armedRef.current && !armed)
372
+ disarmedAtRef.current = Date.now();
373
+ armedRef.current = armed;
374
+ // A device without picture-in-picture answers every arming with an error (L21); stop asking.
375
+ if (!(armed && pipUnavailableOnDevice))
376
+ void sdk_apptile_live_selling_native_1.livePip.setArmed(armed);
261
377
  liveSelling_1.liveSelling.engine
262
378
  .setBackgroundVideoEnabled(armed)
263
379
  .catch((caught) => logger.error(caught, { at: 'liveSession.backgroundVideo' }));
264
380
  // eslint-disable-next-line react-hooks/exhaustive-deps
265
- }, [pipMode, handoverWanted]);
381
+ }, [pipMode, handoverWanted, status]);
266
382
  /**
267
383
  * The sound must not outlive the app when no window follows.
268
384
  *
@@ -271,11 +387,16 @@ function useSessionValue(props) {
271
387
  * declared. So a backgrounded session gives the OS `PIP_GRANT_MS` to produce a window and stops
272
388
  * playback if none arrived. `pipModeRef` rather than `pipMode`, because this subscribes once and
273
389
  * the answer is only knowable when the timer fires.
390
+ *
391
+ * A show stopped here stays stopped until the app is back (`stoppedWhileAwayRef`): a surface that
392
+ * re-claims meanwhile — its status changed, a window was closed — would otherwise start it again
393
+ * behind the app. Back in the foreground, `LiveVideoTarget` takes the picture again by itself.
274
394
  */
275
395
  (0, react_1.useEffect)(() => {
276
396
  let grant = null;
277
397
  const subscription = react_native_1.AppState.addEventListener('change', (next) => {
278
398
  if (next === 'active') {
399
+ stoppedWhileAwayRef.current = false;
279
400
  if (grant)
280
401
  clearTimeout(grant);
281
402
  grant = null;
@@ -289,6 +410,7 @@ function useSessionValue(props) {
289
410
  grant = null;
290
411
  if (pipModeRef.current === 'system')
291
412
  return;
413
+ stoppedWhileAwayRef.current = true;
292
414
  liveSelling_1.liveSelling.engine
293
415
  .stopPlayback()
294
416
  .catch((caught) => logger.error(caught, { at: 'liveSession.noPipWindow' }));
@@ -306,22 +428,52 @@ function useSessionValue(props) {
306
428
  return;
307
429
  /**
308
430
  * Only ours if this session had a surface up first — either the in-app bubble, or the full-screen
309
- * player having asked for the handover. Anything else minimising into a system window raises the
310
- * same event, and taking it means the live session believes it is in a PIP window it never
311
- * opened.
431
+ * player having asked for the handover — or, on Android, if this session armed the window the OS
432
+ * just opened. Anything else minimising into a system window raises the same event, and taking it
433
+ * means the live session believes it is in a PIP window it never opened. See `ownsSystemPipWindow`
434
+ * for why Android cannot go by the handover alone.
312
435
  */
313
436
  const offStart = sdk_apptile_live_selling_native_1.livePip.addListener('onPipStart', () => {
314
- if (pipModeRef.current !== 'inapp' && !handoverRef.current)
437
+ const ours = (0, pipWindow_1.ownsSystemPipWindow)({
438
+ platform: react_native_1.Platform.OS,
439
+ pipMode: pipModeRef.current,
440
+ handoverWanted: handoverRef.current,
441
+ armed: armedRef.current,
442
+ disarmedAt: disarmedAtRef.current,
443
+ now: Date.now(),
444
+ });
445
+ if (!ours)
315
446
  return;
447
+ windowCameFromRef.current = (0, pipWindow_1.whereTheWindowCameFrom)(pipModeRef.current, windowCameFromRef.current);
448
+ takingWindowDownRef.current = false;
316
449
  setPipMode('system');
317
450
  });
451
+ /**
452
+ * Coming back: the picture goes back to where the window took it from. The in-app bubble is the
453
+ * app's own surface and is still mounted, so `'inapp'` is all it takes; a full-screen player is a
454
+ * route only the app can navigate to, so it hears `onPipRestore`. Set in the same event as the
455
+ * window's end, so no render ever sees `'none'` on the way back to the bubble: a card that claims
456
+ * on `'none'` would take the picture for one frame and leave a black one behind.
457
+ */
458
+ const giveThePictureBack = () => {
459
+ const place = windowCameFromRef.current;
460
+ setPipMode(place);
461
+ if (place === 'none')
462
+ onPipRestore === null || onPipRestore === void 0 ? void 0 : onPipRestore(watching);
463
+ };
318
464
  // Expanding and closing both end in `onPipStop`; `restored` is the only thing that tells them
319
- // apart, and the expand path is handled by `onPipRestore` instead.
465
+ // apart, and the expand path is handled by `onPipRestore` instead. Closing stops the show, and it
466
+ // stays stopped until the app is back in the foreground (`stoppedWhileAwayRef`).
320
467
  const offStop = sdk_apptile_live_selling_native_1.livePip.addListener('onPipStop', ({ restored }) => {
468
+ if (takingWindowDownRef.current) {
469
+ takingWindowDownRef.current = false;
470
+ return;
471
+ }
321
472
  if (restored)
322
473
  return;
323
474
  if (pipModeRef.current !== 'system')
324
475
  return;
476
+ stoppedWhileAwayRef.current = true;
325
477
  setPipMode('none');
326
478
  liveSelling_1.liveSelling.engine
327
479
  .stopPlayback()
@@ -330,14 +482,35 @@ function useSessionValue(props) {
330
482
  const offRestore = sdk_apptile_live_selling_native_1.livePip.addListener('onPipRestore', () => {
331
483
  if (pipModeRef.current !== 'system')
332
484
  return;
333
- setPipMode('none');
334
- onPipRestore === null || onPipRestore === void 0 ? void 0 : onPipRestore(watching);
485
+ giveThePictureBack();
486
+ });
487
+ /* iOS: back in the app with the window still up (see `isWindowStillUpOnReturn`). The picture goes
488
+ back where it was, then the window is taken down; its `onPipStop` is ours, not a close. */
489
+ const offReturn = react_native_1.AppState.addEventListener('change', (next) => {
490
+ if (next !== 'active')
491
+ return;
492
+ if (!(0, pipWindow_1.isWindowStillUpOnReturn)(react_native_1.Platform.OS, pipModeRef.current))
493
+ return;
494
+ takingWindowDownRef.current = true;
495
+ giveThePictureBack();
496
+ void sdk_apptile_live_selling_native_1.livePip.stop();
497
+ });
498
+ /* "Not available on this device" is a fact about the device, reported once as a warning — not an
499
+ error filed every time the bubble opens (L21). */
500
+ const offError = sdk_apptile_live_selling_native_1.livePip.addListener('onPipError', ({ message }) => {
501
+ if ((0, pipWindow_1.isPipUnavailableMessage)(message)) {
502
+ if (!pipUnavailableOnDevice)
503
+ (0, client_1.logWarning)(new Error(message), { at: 'liveSession.pipUnavailable' });
504
+ pipUnavailableOnDevice = true;
505
+ return;
506
+ }
507
+ logger.error(new Error(message), { at: 'liveSession.pipError' });
335
508
  });
336
- const offError = sdk_apptile_live_selling_native_1.livePip.addListener('onPipError', ({ message }) => logger.error(new Error(message), { at: 'liveSession.pipError' }));
337
509
  return () => {
338
510
  offStart === null || offStart === void 0 ? void 0 : offStart();
339
511
  offStop === null || offStop === void 0 ? void 0 : offStop();
340
512
  offRestore === null || offRestore === void 0 ? void 0 : offRestore();
513
+ offReturn.remove();
341
514
  offError === null || offError === void 0 ? void 0 : offError();
342
515
  };
343
516
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -355,39 +528,45 @@ function useSessionValue(props) {
355
528
  return () => subscription.remove();
356
529
  // eslint-disable-next-line react-hooks/exhaustive-deps
357
530
  }, []);
531
+ /**
532
+ * Sold cards, cart cards and reactions leave when their time is up (`SOLD_CARD_MS`, `CART_CARD_MS`,
533
+ * `REACTION_MS`): one timer, to the moment the next one runs out (`nextExpiryAt`), set again whenever
534
+ * that moment changes. Nothing runs with none on screen.
535
+ *
536
+ * The prune is told it is that moment even if the timer fires a millisecond early, so the card it
537
+ * was set for is always the one that goes — otherwise nothing would change, the moment would stay
538
+ * the same, and no timer would be set again.
539
+ */
540
+ const nextExpiry = (0, session_1.nextExpiryAt)(state);
358
541
  (0, react_1.useEffect)(() => {
359
- if (status !== 'playing' &&
360
- state.sold.length === 0 &&
361
- state.cartAnnouncements.length === 0 &&
362
- state.reactions.length === 0) {
542
+ if (nextExpiry === null)
363
543
  return;
364
- }
365
- const timer = setInterval(() => {
366
- const at = Date.now();
367
- setNow(at);
368
- dispatch({ kind: 'prune', now: at });
369
- }, TICK_MS);
370
- return () => clearInterval(timer);
371
- }, [status, state.sold.length, state.cartAnnouncements.length, state.reactions.length]);
544
+ const timer = setTimeout(() => {
545
+ dispatch({ kind: 'prune', now: Math.max(Date.now(), nextExpiry) });
546
+ }, Math.max(0, nextExpiry - Date.now()));
547
+ return () => clearTimeout(timer);
548
+ }, [nextExpiry]);
372
549
  /**
373
- * The auction clock. Only runs while a round is open, so it costs nothing on a normal live stream.
374
- * It also owns the fallback close: past the deadline plus the grace period with no word from the
375
- * host, the round is over as far as this device is concerned.
550
+ * The round's fallback close. The host normally closes the round; when nothing arrives by the
551
+ * deadline plus the grace period, the round is over as far as this device is concerned. One timer to
552
+ * that moment, set again when a late bid moves the deadline, and gone when the round closes.
553
+ *
554
+ * Destructured rather than read off `state`, so the timer is keyed on exactly the three things that
555
+ * move the moment. An open round with no deadline yet (a bid heard before the opening, carrying
556
+ * none) closes at once, as it did on the old 200 ms clock: there is nothing to wait for.
376
557
  */
377
558
  const { biddingOpen, endsAtMs, serverOffsetMs } = state;
378
559
  (0, react_1.useEffect)(() => {
379
560
  if (!biddingOpen)
380
561
  return;
381
- const timer = setInterval(() => {
382
- const at = Date.now();
383
- setAuctionNow(at);
384
- // Destructured rather than read off `state`, so the closure cannot go stale against a deadline
385
- // a late bid has already moved — the deps re-arm it when one does.
386
- if (endsAtMs - (at + serverOffsetMs) < -AUCTION_GRACE_MS) {
387
- dispatch({ kind: 'auctionExpired' });
388
- }
389
- }, AUCTION_TICK_MS);
390
- return () => clearInterval(timer);
562
+ const closeAt = endsAtMs - serverOffsetMs + AUCTION_GRACE_MS;
563
+ const wait = Math.max(0, closeAt - Date.now());
564
+ // A timer holds at most 2^31 − 1 ms (about 24 days) and fires at once past that. A deadline that
565
+ // far away is bad data, and the old clock never closed it early either.
566
+ if (wait > LONGEST_TIMER_MS)
567
+ return;
568
+ const timer = setTimeout(() => dispatch({ kind: 'auctionExpired' }), wait);
569
+ return () => clearTimeout(timer);
391
570
  }, [biddingOpen, endsAtMs, serverOffsetMs]);
392
571
  /**
393
572
  * Giveaway state off the polled record.
@@ -397,7 +576,6 @@ function useSessionValue(props) {
397
576
  * enter a giveaway they are already in.
398
577
  */
399
578
  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]);
400
- 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;
401
579
  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;
402
580
  /**
403
581
  * Closes the gap between the realtime giveaway start and the polled record it depends on.
@@ -414,6 +592,7 @@ function useSessionValue(props) {
414
592
  void refreshStreamInfo();
415
593
  }, [giveawayKnown, refreshStreamInfo]);
416
594
  (0, react_1.useEffect)(() => {
595
+ var _a;
417
596
  if (!activeGiveaway)
418
597
  return;
419
598
  const entries = activeGiveaway.entries.filter((entry) => entry.active);
@@ -423,8 +602,37 @@ function useSessionValue(props) {
423
602
  entryCount: entries.length,
424
603
  registered: !!bidderId && entries.some((entry) => entry.userId === bidderId),
425
604
  bidderId,
605
+ entrantNames: activeGiveaway.entrantNames,
606
+ startedAtMs: (_a = activeGiveaway.startedAtMs) !== null && _a !== void 0 ? _a : null,
426
607
  });
427
608
  }, [activeGiveaway, bidderId]);
609
+ /**
610
+ * The draw's roster — ENG-705, moved here from Freckled Poppy's Live screen.
611
+ *
612
+ * `giveaway_end` names the winner and nobody else, and reaches the room before the gateway has
613
+ * written the entrants onto the record. When the roster latched while the lot was open is empty
614
+ * too, the record is asked again every 700 ms for up to 5 s; the first read with names is taken and
615
+ * kept (`giveawayRoster`), and running out of time lets the reveal go ahead without one.
616
+ */
617
+ const rosterWait = state.giveawayResult && !state.giveawayResult.entrantNamesReady
618
+ ? `${state.giveawayResult.storeProductId}\u0000${state.giveawayResult.winnerName}`
619
+ : null;
620
+ (0, react_1.useEffect)(() => {
621
+ if (!rosterWait)
622
+ return;
623
+ return (0, giveawayRoster_1.waitForGiveawayRoster)({
624
+ readRecord: () => void refreshStreamInfo(),
625
+ onGiveUp: () => dispatch({ kind: 'giveawayRosterTimedOut' }),
626
+ });
627
+ }, [rosterWait, refreshStreamInfo]);
628
+ (0, react_1.useEffect)(() => {
629
+ const result = state.giveawayResult;
630
+ if (!result || result.entrantNamesReady || !stream)
631
+ return;
632
+ const names = (0, giveawayRoster_1.rosterFromRecord)(stream.giveaways, result.storeProductId);
633
+ if (names.length)
634
+ dispatch({ kind: 'giveawayRoster', storeProductId: result.storeProductId, names });
635
+ }, [state.giveawayResult, stream]);
428
636
  /**
429
637
  * The poll off the RECORD, for one this device never heard open — DEL-2769.
430
638
  *
@@ -483,29 +691,93 @@ function useSessionValue(props) {
483
691
  }));
484
692
  }, [stream, state.removedStoreProductIds]);
485
693
  /**
486
- * A lot the host featured that this record has never heard of.
694
+ * The host featured a lot (`PRODUCT_CHANGE`): read the record again, once per change.
695
+ *
696
+ * Two things live only on the record and change at exactly this moment:
487
697
  *
488
- * `productChange` adds the id to the extras, so a CATALOGUE lot survives: the id goes to
489
- * `productsByIds` and the storefront answers for it immediately. A LINK lot cannot — everything
490
- * it has to show (name, thumbnail, URL) lives only on the stream record, which is polled on a
491
- * five-minute timer. So a link lot added and featured mid-stream resolved to nothing, and
492
- * `activeProduct` fell back to `products[0]` — showing the room a different product entirely.
698
+ * - **The lot number.** On a company with show-gated products the gateway issues `sell_id` when the
699
+ * host SHOWS the lot (DEL-2885), and nothing re-reads the record mid-watch — so `sellId` stayed
700
+ * null for the whole session. Freckled Poppy forced this read from its Live screen; it lives here
701
+ * now, for every app.
702
+ * - **A link lot added mid-stream.** Its name, thumbnail and URL exist nowhere else, so without the
703
+ * read it resolved to nothing and `activeProduct` fell back to a different product.
493
704
  *
494
- * Re-reading the record is what makes it appear. Once per id: an id that is genuinely absent
495
- * must not turn into a poll every render.
705
+ * Skipped when the record already lists the lot with its number: the read would bring nothing new.
496
706
  */
497
- const chasedRecordFor = (0, react_1.useRef)(null);
498
707
  (0, react_1.useEffect)(() => {
708
+ var _a;
499
709
  const active = state.activeStoreProductId;
500
- if (!active || !stream)
710
+ if (!active)
711
+ return;
712
+ const row = (_a = streamRef.current) === null || _a === void 0 ? void 0 : _a.products.find((product) => product.storeProductId === active);
713
+ if (row === null || row === void 0 ? void 0 : row.sellId)
501
714
  return;
502
- if (stream.products.some((product) => product.storeProductId === active))
715
+ void refreshStreamInfo();
716
+ }, [state.activeStoreProductId, refreshStreamInfo]);
717
+ /**
718
+ * What the room said before this device arrived, put in front of the comments — once per show.
719
+ *
720
+ * Read when the show is known to be live, and dropped if the show changed while it was in flight.
721
+ * `mergeCommentHistory` (in the reducer) leaves out anything the room has already delivered and
722
+ * renames the host's stored "You" to the host.
723
+ */
724
+ const liveStreamingId = (stream === null || stream === void 0 ? void 0 : stream.status) === 'live' ? stream.streamingId : null;
725
+ (0, react_1.useEffect)(() => {
726
+ if (!liveStreamingId || react_native_1.Platform.OS === 'web')
727
+ return;
728
+ let cancelled = false;
729
+ liveSelling_1.liveSelling.streams
730
+ .commentHistory(liveStreamingId)
731
+ .then((history) => {
732
+ var _a, _b;
733
+ if (cancelled || !history.length)
734
+ return;
735
+ dispatch({
736
+ kind: 'commentHistory',
737
+ history,
738
+ hostName: (_b = (_a = streamRef.current) === null || _a === void 0 ? void 0 : _a.hostName) !== null && _b !== void 0 ? _b : null,
739
+ ownName: namedByRef.current,
740
+ });
741
+ })
742
+ .catch((caught) => logger.error(caught, { at: 'liveSession.commentHistory', streamingId: liveStreamingId }));
743
+ return () => {
744
+ cancelled = true;
745
+ };
746
+ // eslint-disable-next-line react-hooks/exhaustive-deps
747
+ }, [liveStreamingId]);
748
+ /**
749
+ * The pinned comment, off the RECORD — for arrival, and for every reconnect.
750
+ *
751
+ * The room's `PINNED_COMMENT_CHANGED` reaches the reducer directly and wins until the record's own
752
+ * pin changes. Dispatched only when the record's pin CHANGES (keyed with the show), so a read
753
+ * taken before an unpin cannot bring the old pin back — the same precedence the record's poll has.
754
+ *
755
+ * A broadcast sent while the room was down never arrives, so a reconnect re-reads the record and
756
+ * the first record after it is taken whatever it says. Joining counts as a reconnect, so arriving
757
+ * costs one extra read.
758
+ */
759
+ const recordPin = (_j = stream === null || stream === void 0 ? void 0 : stream.pinnedComment) !== null && _j !== void 0 ? _j : null;
760
+ const recordPinKey = `${watching !== null && watching !== void 0 ? watching : ''}\u0000${(_k = recordPin === null || recordPin === void 0 ? void 0 : recordPin.id) !== null && _k !== void 0 ? _k : ''}\u0000${(_l = recordPin === null || recordPin === void 0 ? void 0 : recordPin.text) !== null && _l !== void 0 ? _l : ''}`;
761
+ const lastRecordPinKey = (0, react_1.useRef)(null);
762
+ const takeNextRecordPin = (0, react_1.useRef)(false);
763
+ (0, react_1.useEffect)(() => {
764
+ if (!stream)
503
765
  return;
504
- if (chasedRecordFor.current === active)
766
+ if (recordPinKey === lastRecordPinKey.current && !takeNextRecordPin.current)
505
767
  return;
506
- chasedRecordFor.current = active;
507
- refreshStreamInfo();
508
- }, [state.activeStoreProductId, stream, refreshStreamInfo]);
768
+ lastRecordPinKey.current = recordPinKey;
769
+ takeNextRecordPin.current = false;
770
+ dispatch({ kind: 'pinnedCommentSynced', comment: recordPin });
771
+ // eslint-disable-next-line react-hooks/exhaustive-deps
772
+ }, [stream, recordPinKey]);
773
+ const wasConnected = (0, react_1.useRef)(false);
774
+ (0, react_1.useEffect)(() => {
775
+ if (state.connected && !wasConnected.current) {
776
+ takeNextRecordPin.current = true;
777
+ void refreshStreamInfo();
778
+ }
779
+ wasConnected.current = state.connected;
780
+ }, [state.connected, refreshStreamInfo]);
509
781
  /**
510
782
  * Resolved through the commerce adapter, keyed on the joined ids so a mid-stream product add
511
783
  * refetches and an unchanged set does not. Link lots are held out: they have no id the catalogue
@@ -559,27 +831,15 @@ function useSessionValue(props) {
559
831
  return ordered;
560
832
  };
561
833
  /*
562
- * Sell codes live on the stream record, not in the catalogue — the commerce adapter is asked for
563
- * products by id and has no idea a lot carries one. Joined on here so a call site reads
564
- * `product.sellId` instead of holding both lists and matching them itself.
834
+ * Sell codes and opening bids live on the stream record, not in the catalogue — the commerce
835
+ * adapter is asked for products by id and has no idea a lot carries either. Joined on here so a
836
+ * call site reads `product.sellId` and `product.baseBidPrice` instead of holding both lists and
837
+ * matching them itself (`withLotFieldsFromRecord`; the opening bid since 0.9.0).
565
838
  *
566
- * A new array only when at least one code exists, so an app on a gateway that issues none keeps
567
- * the adapter's own objects and the identity the memo already promised.
839
+ * A new array only when the record has either for some lot, so an app on a gateway that issues
840
+ * none keeps the adapter's own objects and the identity the memo already promised.
568
841
  */
569
- const base = merge();
570
- const codes = new Map();
571
- for (const row of (_a = stream === null || stream === void 0 ? void 0 : stream.products) !== null && _a !== void 0 ? _a : []) {
572
- if (row.sellId)
573
- codes.set(row.storeProductId, row.sellId);
574
- }
575
- if (!codes.size)
576
- return base;
577
- return base.map((product) => {
578
- var _a;
579
- return codes.has(product.storeProductId)
580
- ? { ...product, sellId: (_a = codes.get(product.storeProductId)) !== null && _a !== void 0 ? _a : null }
581
- : product;
582
- });
842
+ return (0, showLots_1.withLotFieldsFromRecord)(merge(), (_a = stream === null || stream === void 0 ? void 0 : stream.products) !== null && _a !== void 0 ? _a : []);
583
843
  }, [catalogueProducts, externalProducts, storeProductIds, stream]);
584
844
  const activeProduct = (0, react_1.useMemo)(() => {
585
845
  var _a, _b, _c, _d, _e, _f;
@@ -607,11 +867,10 @@ function useSessionValue(props) {
607
867
  return null;
608
868
  return (_f = products[0]) !== null && _f !== void 0 ? _f : null;
609
869
  }, [products, state.activeStoreProductId, stream]);
610
- 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;
870
+ const multiplier = (_o = (_m = state.multiplierOverride) !== null && _m !== void 0 ? _m : stream === null || stream === void 0 ? void 0 : stream.viewerMultiplier) !== null && _o !== void 0 ? _o : 1;
611
871
  /* Same precedence as the multiplier: the host's live word wins, the record stands until they
612
872
  give one. Both fall back rather than throw, so a stream row missing `meta` still renders. */
613
- const reactionStyle = (_m = (_l = state.reactionModeOverride) !== null && _l !== void 0 ? _l : stream === null || stream === void 0 ? void 0 : stream.reactionStyle) !== null && _m !== void 0 ? _m : 'single';
614
- const elapsedMs = (stream === null || stream === void 0 ? void 0 : stream.startedAtMs) ? Math.max(0, now - stream.startedAtMs) : 0;
873
+ const reactionStyle = (_q = (_p = state.reactionModeOverride) !== null && _p !== void 0 ? _p : stream === null || stream === void 0 ? void 0 : stream.reactionStyle) !== null && _q !== void 0 ? _q : 'single';
615
874
  /**
616
875
  * Percentages come off the raw counts, so the multiplier cannot skew them — it inflates only the
617
876
  * total on display, exactly as the host's own dashboard does.
@@ -689,27 +948,28 @@ function useSessionValue(props) {
689
948
  alive = false;
690
949
  };
691
950
  // eslint-disable-next-line react-hooks/exhaustive-deps
692
- }, [(_o = state.poll) === null || _o === void 0 ? void 0 : _o.pollUuid]);
951
+ }, [(_r = state.poll) === null || _r === void 0 ? void 0 : _r.pollUuid]);
693
952
  const bindVideoView = (0, react_1.useCallback)((reactTag, force = false) => {
953
+ // Stopped because the app is away: a claim now would start it behind the app, with sound. The
954
+ // surface claims again once the app is back (`LiveVideoTarget`'s foreground re-claim).
955
+ if (stoppedWhileAwayRef.current)
956
+ return;
694
957
  liveSelling_1.liveSelling.engine
695
958
  .bindView(reactTag, force)
696
959
  .catch((caught) => (0, client_1.getLogger)().error(caught, { at: 'liveSession.bindView' }));
697
960
  }, []);
961
+ /**
962
+ * Sends now — or, for a signed-in shopper whose name has not loaded, holds it until the name lands
963
+ * (L6) and resolves once it has gone out. See `HeldComments`.
964
+ */
698
965
  const sendComment = (0, react_1.useCallback)(async (text, options) => {
699
- var _a;
700
966
  if (!text.trim())
701
967
  return;
702
- try {
703
- // The host mutes by device; a per-install viewer id is the same granularity.
704
- const deviceId = (_a = viewerIdRef.current) !== null && _a !== void 0 ? _a : (await liveSelling_1.liveSelling.viewer.id());
705
- const own = await liveSelling_1.liveSelling.engine.sendComment(text, deviceId, options);
706
- if (own)
707
- dispatch({ kind: 'ownComment', comment: own });
708
- }
709
- catch (caught) {
710
- (0, client_1.getLogger)().error(caught, { at: 'liveSession.sendComment' });
711
- }
712
- }, []);
968
+ const held = heldCommentsRef.current;
969
+ if (nameResolvingRef.current && held)
970
+ return held.hold({ text, options });
971
+ await deliverComment(text, options);
972
+ }, [deliverComment]);
713
973
  const sendReaction = (0, react_1.useCallback)(async (reactionUrl) => {
714
974
  try {
715
975
  await liveSelling_1.liveSelling.engine.sendReaction(reactionUrl);
@@ -820,10 +1080,22 @@ function useSessionValue(props) {
820
1080
  .bindGuestHostView(reactTag)
821
1081
  .catch((caught) => (0, client_1.getLogger)().error(caught, { at: 'liveSession.bindGuestHostView' }));
822
1082
  }, []);
1083
+ /**
1084
+ * Saves the name and gives it to the engine NOW, not in the effect (L6).
1085
+ *
1086
+ * The caller's next line is the comment that prompted for the name. The effect that pushes the name
1087
+ * belongs to this provider, a parent of that caller, and React runs parents' effects after
1088
+ * children's — so the comment went out under the join-time name, usually "Anonymous", into the
1089
+ * room and the stored history. Freckled Poppy set the engine's name itself to get round it. A name
1090
+ * passed as the `viewerName` prop still wins, as it does everywhere else.
1091
+ */
823
1092
  const chooseViewerName = (0, react_1.useCallback)(async (raw) => {
1093
+ var _a;
824
1094
  const saved = await liveSelling_1.liveSelling.viewer.saveName(raw);
825
- if (saved)
1095
+ if (saved) {
1096
+ liveSelling_1.liveSelling.engine.setViewerName((_a = accountNameRef.current) !== null && _a !== void 0 ? _a : saved);
826
1097
  setChosenName(saved);
1098
+ }
827
1099
  return saved;
828
1100
  }, []);
829
1101
  /** The speaker button, and nothing else. See `setOutputMuted` below for the other half. */
@@ -922,38 +1194,51 @@ function useSessionValue(props) {
922
1194
  * into a cart. It is also what comes back on the host's broadcast, so `isLeading` compares like
923
1195
  * with like. Null when signed out, which is exactly when bidding is not offered.
924
1196
  */
925
- const bidderEmail = (_q = (_p = identity === null || identity === void 0 ? void 0 : identity.customerEmail) === null || _p === void 0 ? void 0 : _p.call(identity)) !== null && _q !== void 0 ? _q : null;
1197
+ const bidderEmail = (_t = (_s = identity === null || identity === void 0 ? void 0 : identity.customerEmail) === null || _s === void 0 ? void 0 : _s.call(identity)) !== null && _t !== void 0 ? _t : null;
926
1198
  const bidder = (0, react_1.useMemo)(() => (bidderId ? { userId: bidderId, userName: viewerName, email: bidderEmail } : null), [bidderId, viewerName, bidderEmail]);
1199
+ /**
1200
+ * The hammer, with whether it is this shopper's (0.9.0): they led the round as it closed and the
1201
+ * winning bid is theirs (`isYourWin`). Its own memo, so it is a new object only when the win, the
1202
+ * round's last leader or the bidder change, never on a comment.
1203
+ */
1204
+ const lastWin = (0, react_1.useMemo)(() => state.lastWin
1205
+ ? { ...state.lastWin, isYou: (0, auctionRules_1.isYourWin)(state.lastWin, state.leaderWhenRoundClosed, bidderId) }
1206
+ : null, [state.lastWin, state.leaderWhenRoundClosed, bidderId]);
927
1207
  const auction = (0, react_1.useMemo)(() => {
928
1208
  var _a, _b, _c, _d, _e, _f, _g;
929
- // Falls back to the lot's opening price: until the first bid lands the host sends no next bid,
930
- // and a slide-to-bid showing zero is worse than useless.
931
- const nextBid = state.nextBid || (biddingLot === null || biddingLot === void 0 ? void 0 : biddingLot.baseBidPrice) || 0;
1209
+ // Falls back to the lot's opening bid, else its bid step, else the show's (`openingBidFor`): until
1210
+ // the first bid lands the host sends no next bid, and a slide-to-bid showing zero is worse than useless.
1211
+ const nextBid = state.nextBid || (0, auctionRules_1.openingBidFor)(biddingLot, stream);
932
1212
  return {
933
1213
  biddingOpen: state.biddingOpen,
934
1214
  highestBid: state.highestBid,
935
1215
  nextBid,
936
- timeLeftMs: (0, session_1.timeLeftMs)(state, auctionNow),
1216
+ // A moment, not a countdown: it changes only when the host moves the deadline, so the session
1217
+ // never ticks. A component counts down to it itself (`useAuctionCountdown`).
1218
+ closesAt: (0, session_1.roundClosesAt)(state),
937
1219
  isLeading: !!bidder && ((_a = state.bidLeader) === null || _a === void 0 ? void 0 : _a.userId) === bidder.userId,
938
1220
  leaderName: (_c = (_b = state.bidLeader) === null || _b === void 0 ? void 0 : _b.userName) !== null && _c !== void 0 ? _c : null,
939
1221
  extended: state.auctionExtended,
940
1222
  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,
941
- lastWin: state.lastWin,
1223
+ lastWin,
942
1224
  bidCap: (_f = stream === null || stream === void 0 ? void 0 : stream.bidCap) !== null && _f !== void 0 ? _f : null,
943
1225
  extendedBiddingMs: (_g = stream === null || stream === void 0 ? void 0 : stream.extendedBiddingMs) !== null && _g !== void 0 ? _g : 0,
1226
+ wonLotIds: state.wonLotIds,
944
1227
  };
945
- }, [state, auctionNow, biddingLot, bidder, stream]);
1228
+ }, [state, biddingLot, bidder, stream, lastWin]);
946
1229
  const giveaway = (0, react_1.useMemo)(() => {
947
- var _a, _b;
948
- const result = state.giveawayResult;
949
- // Entrant names come off the record's roster rather than the event, which carries only the
950
- // winner. A draw the record has not caught up with still celebrates, with fewer names in it.
951
- const settled = result
952
- ? stream === null || stream === void 0 ? void 0 : stream.giveaways.find((item) => item.storeProductId === result.storeProductId && item.status === 'ended')
953
- : undefined;
1230
+ const drawn = state.giveawayResult;
1231
+ /**
1232
+ * The draw, with this shopper's part in it (0.9.0): whether they had entered (kept past the draw,
1233
+ * as `registered` goes when it lands) and whether they won: by the customer id the record names on
1234
+ * this draw's giveaway when a read has it, else by name among those who entered.
1235
+ */
1236
+ const youEntered = !!drawn && !!bidderId && drawn.enteredBy === bidderId;
1237
+ const winnerId = drawn && stream ? (0, giveawayRoster_1.winnerIdFromRecord)(stream.giveaways, drawn.storeProductId, drawn.startedAtMs) : null;
954
1238
  return {
955
- active: state.giveawayStoreProductId
956
- ? ((_a = stream === null || stream === void 0 ? void 0 : stream.giveaways.find((item) => item.storeProductId === state.giveawayStoreProductId)) !== null && _a !== void 0 ? _a : null)
1239
+ // The open giveaway for the lot, not an earlier one of the same product (`giveawayForLot`).
1240
+ active: state.giveawayStoreProductId && stream
1241
+ ? (0, giveawayRoster_1.giveawayForLot)(stream.giveaways, state.giveawayStoreProductId)
957
1242
  : null,
958
1243
  // Inflated by the host's multiplier, exactly as `viewerCount` and poll totals are. State keeps
959
1244
  // the raw count; only the read out of the session multiplies.
@@ -964,8 +1249,21 @@ function useSessionValue(props) {
964
1249
  * still active and a re-render to fire.
965
1250
  */
966
1251
  registered: !!bidderId && state.giveawayRegisteredBy === bidderId,
967
- result: result
968
- ? { winnerName: result.winnerName, entrantNames: (_b = settled === null || settled === void 0 ? void 0 : settled.entrantNames) !== null && _b !== void 0 ? _b : [] }
1252
+ /* The roster is held in the reducer once known, so a later read cannot change it mid-reveal. */
1253
+ result: drawn
1254
+ ? {
1255
+ winnerName: drawn.winnerName,
1256
+ entrantNames: drawn.entrantNames,
1257
+ entrantNamesReady: drawn.entrantNamesReady,
1258
+ youEntered,
1259
+ isYou: (0, giveawayRoster_1.isYourGiveawayWin)({
1260
+ winnerId,
1261
+ winnerName: drawn.winnerName,
1262
+ youEntered,
1263
+ bidderId,
1264
+ viewerName: namedBy,
1265
+ }),
1266
+ }
969
1267
  : null,
970
1268
  };
971
1269
  }, [
@@ -976,6 +1274,7 @@ function useSessionValue(props) {
976
1274
  state.giveawayResult,
977
1275
  stream,
978
1276
  multiplier,
1277
+ namedBy,
979
1278
  ]);
980
1279
  /**
981
1280
  * The round, the stream and the bidder, for the actions to read when they fire.
@@ -1012,11 +1311,13 @@ function useSessionValue(props) {
1012
1311
  const round = roundRef.current;
1013
1312
  const current = streamRef.current;
1014
1313
  const who = bidderRef.current;
1015
- if (!current || !who)
1016
- return;
1314
+ if (!current)
1315
+ return { sent: false, reason: 'noShow' };
1316
+ if (!who)
1317
+ return { sent: false, reason: 'signedOut' };
1017
1318
  const amount = (_a = options === null || options === void 0 ? void 0 : options.amount) !== null && _a !== void 0 ? _a : round.auction.nextBid;
1018
1319
  if (!amount || amount <= 0)
1019
- return;
1320
+ return { sent: false, reason: 'noAmount' };
1020
1321
  /**
1021
1322
  * The lot's only variant, for a bid that names none.
1022
1323
  *
@@ -1033,6 +1334,20 @@ function useSessionValue(props) {
1033
1334
  */
1034
1335
  const lot = productsRef.current.find((product) => product.storeProductId === round.auction.storeProductId);
1035
1336
  const soleVariantId = (lot === null || lot === void 0 ? void 0 : lot.variants.length) === 1 ? ((_c = (_b = lot.variants[0]) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : null) : null;
1337
+ /**
1338
+ * No moderator, no bid (L3). Bids go to the moderator in charge and to nobody else, and a viewer
1339
+ * who missed the round's opening may not know one — see `lastModeratorId`. Said rather than
1340
+ * swallowed, and the ledger is left alone: before 0.9.0 the REST record still went out, for a bid
1341
+ * that never entered the round.
1342
+ */
1343
+ if (!round.moderatorId) {
1344
+ (0, client_1.logWarning)(new Error('No moderator is known for this round, so the bid was not sent'), {
1345
+ at: 'liveSession.placeBid',
1346
+ amount,
1347
+ });
1348
+ return { sent: false, reason: 'noModerator' };
1349
+ }
1350
+ let result = { sent: true };
1036
1351
  try {
1037
1352
  await liveSelling_1.liveSelling.engine.sendBid({
1038
1353
  bidAmount: amount,
@@ -1046,6 +1361,7 @@ function useSessionValue(props) {
1046
1361
  }
1047
1362
  catch (caught) {
1048
1363
  (0, client_1.getLogger)().error(caught, { at: 'liveSession.placeBid', amount });
1364
+ result = { sent: false, reason: 'sendFailed' };
1049
1365
  }
1050
1366
  try {
1051
1367
  await liveSelling_1.liveSelling.auctions.recordBid({
@@ -1059,14 +1375,17 @@ function useSessionValue(props) {
1059
1375
  catch (caught) {
1060
1376
  (0, client_1.getLogger)().error(caught, { at: 'liveSession.recordBid', amount });
1061
1377
  }
1378
+ return result;
1062
1379
  }, []);
1063
1380
  const enterGiveaway = (0, react_1.useCallback)(async (cartId) => {
1064
1381
  var _a, _b, _c;
1065
1382
  const lot = giveawayLotRef.current;
1066
1383
  const current = streamRef.current;
1067
1384
  const customer = customerRefRef.current;
1068
- if (!current || !lot || !customer)
1069
- return;
1385
+ if (!current || !lot)
1386
+ return { entered: false, reason: 'noGiveaway' };
1387
+ if (!customer)
1388
+ return { entered: false, reason: 'signedOut' };
1070
1389
  try {
1071
1390
  const viewerId = (_a = viewerIdRef.current) !== null && _a !== void 0 ? _a : (await liveSelling_1.liveSelling.viewer.id());
1072
1391
  const entered = await liveSelling_1.liveSelling.auctions.enterGiveaway({
@@ -1079,11 +1398,14 @@ function useSessionValue(props) {
1079
1398
  });
1080
1399
  // Only on the host's confirmation. The entry count follows from its own push, so nothing here
1081
1400
  // guesses at it.
1082
- if (entered)
1083
- dispatch({ kind: 'giveawayRegistered', bidderId: (_c = (_b = bidderRef.current) === null || _b === void 0 ? void 0 : _b.userId) !== null && _c !== void 0 ? _c : null });
1401
+ if (!entered)
1402
+ return { entered: false, reason: 'refused' };
1403
+ dispatch({ kind: 'giveawayRegistered', bidderId: (_c = (_b = bidderRef.current) === null || _b === void 0 ? void 0 : _b.userId) !== null && _c !== void 0 ? _c : null });
1404
+ return { entered: true };
1084
1405
  }
1085
1406
  catch (caught) {
1086
1407
  (0, client_1.getLogger)().error(caught, { at: 'liveSession.enterGiveaway' });
1408
+ return { entered: false, reason: 'failed' };
1087
1409
  }
1088
1410
  }, []);
1089
1411
  const dismissAuctionWin = (0, react_1.useCallback)(() => dispatch({ kind: 'dismissWin' }), []);
@@ -1140,10 +1462,35 @@ function useSessionValue(props) {
1140
1462
  dismissAuctionWin,
1141
1463
  dismissGiveawayResult,
1142
1464
  ]);
1143
- return {
1465
+ const guest = (0, react_1.useMemo)(() => ({
1466
+ mode: state.guestMode,
1467
+ status: state.guestStatus,
1468
+ slot: state.guestSlot,
1469
+ mutedByHost: state.guestMutedByHost,
1470
+ cameraOffByHost: state.guestCameraOffByHost,
1471
+ mixerLayout: state.mixerLayout,
1472
+ }), [
1473
+ state.guestMode,
1474
+ state.guestStatus,
1475
+ state.guestSlot,
1476
+ state.guestMutedByHost,
1477
+ state.guestCameraOffByHost,
1478
+ state.mixerLayout,
1479
+ ]);
1480
+ const viewerCount = Math.round(state.rawViewerCount * multiplier);
1481
+ const pinnedComment = status === 'ended' ? null : state.pinnedComment;
1482
+ /**
1483
+ * The same object until one of its fields changes.
1484
+ *
1485
+ * Every component that reads the session is drawn again when this object changes, and the session is
1486
+ * app-wide. Built fresh on every render, it changed whenever the provider was drawn for any reason —
1487
+ * its parent passing the same props again, a record read that changed nothing a reader sees — and
1488
+ * drew every reader with it. Field by field, so a new object means something in it is new.
1489
+ */
1490
+ return (0, react_1.useMemo)(() => ({
1144
1491
  status,
1145
1492
  stream: stream !== null && stream !== void 0 ? stream : null,
1146
- viewerCount: Math.round(state.rawViewerCount * multiplier),
1493
+ viewerCount,
1147
1494
  rawViewerCount: state.rawViewerCount,
1148
1495
  reactionStyle,
1149
1496
  comments: state.comments,
@@ -1154,23 +1501,39 @@ function useSessionValue(props) {
1154
1501
  reactions: state.reactions,
1155
1502
  poll,
1156
1503
  viewerName: namedBy,
1157
- guest: {
1158
- mode: state.guestMode,
1159
- status: state.guestStatus,
1160
- slot: state.guestSlot,
1161
- mutedByHost: state.guestMutedByHost,
1162
- cameraOffByHost: state.guestCameraOffByHost,
1163
- mixerLayout: state.mixerLayout,
1164
- },
1504
+ guest,
1165
1505
  muted,
1166
1506
  videoTag: state.videoTag,
1167
- elapsedMs,
1168
1507
  pipMode,
1169
1508
  auction,
1170
1509
  giveaway,
1510
+ pinnedComment,
1171
1511
  error,
1172
1512
  actions,
1173
- };
1513
+ }), [
1514
+ status,
1515
+ stream,
1516
+ viewerCount,
1517
+ state.rawViewerCount,
1518
+ reactionStyle,
1519
+ state.comments,
1520
+ activeProduct,
1521
+ products,
1522
+ state.sold,
1523
+ state.cartAnnouncements,
1524
+ state.reactions,
1525
+ poll,
1526
+ namedBy,
1527
+ guest,
1528
+ muted,
1529
+ state.videoTag,
1530
+ pipMode,
1531
+ auction,
1532
+ giveaway,
1533
+ pinnedComment,
1534
+ error,
1535
+ actions,
1536
+ ]);
1174
1537
  }
1175
1538
  /**
1176
1539
  * Holds one live session for the whole app. Nothing is joined while no stream is live, so the engine
@@ -1198,18 +1561,20 @@ function LiveSessionProvider({ children, ...props }) {
1198
1561
  the video — see the note on `actions` in `useSessionValue`. */
1199
1562
  const watchedRef = (0, react_1.useRef)(undefined);
1200
1563
  watchedRef.current = (_d = (_c = props.streamingId) !== null && _c !== void 0 ? _c : heldId) !== null && _d !== void 0 ? _d : (_e = session.stream) === null || _e === void 0 ? void 0 : _e.streamingId;
1201
- const holdStream = (0, react_1.useCallback)(() => {
1202
- /* Nothing to pin yet — the caller retries when a stream id arrives, so a player opened before
1203
- the read resolved is not left following whatever turns up next. */
1204
- const id = watchedRef.current;
1564
+ const holdStream = (0, react_1.useCallback)((streamingId) => {
1565
+ /* A named show wins over whatever is on screen — see `holdStream` in `LiveSessionActions`. With
1566
+ none named and nothing to pin yet, the caller retries when a stream id arrives, so a player
1567
+ opened before the read resolved is not left following whatever turns up next. */
1568
+ const id = streamingId || watchedRef.current;
1205
1569
  if (id)
1206
1570
  setHeldId(id);
1207
1571
  }, []);
1208
1572
  const releaseStream = (0, react_1.useCallback)(() => setHeldId(null), []);
1209
- /* Two memos, and the split matters: `session` is a fresh object every render, so folding the
1210
- actions into the same one would rebuild `actions` every render too — the exact thing the note in
1573
+ /* Two memos, and the split matters: `session` changes with every event, so folding the actions into
1574
+ the same one would rebuild `actions` on every event too — the exact thing the note in
1211
1575
  `useSessionValue` warns about, and it live-locks a player that calls `holdStream` from an effect
1212
- keyed on `actions`. `session.actions` is stable, so keyed on that this one is as well. */
1576
+ keyed on `actions`. `session.actions` is stable, so keyed on that this one is as well. `session`
1577
+ is the same object while nothing in it changed, so `value` is too: no reader is drawn for nothing. */
1213
1578
  const actions = (0, react_1.useMemo)(() => ({ ...session.actions, holdStream, releaseStream }), [session.actions, holdStream, releaseStream]);
1214
1579
  const value = (0, react_1.useMemo)(() => ({ ...session, actions }), [session, actions]);
1215
1580
  return (0, react_1.createElement)(LiveSessionContext.Provider, { value }, children);