@tiledev/sdk-apptile-live-selling 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +322 -0
- package/android/build.gradle +33 -0
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/expo/modules/livepip/LivePipModule.kt +310 -0
- package/app.plugin.js +3 -0
- package/dist/auctions.d.ts +3 -0
- package/dist/auctions.d.ts.map +1 -0
- package/dist/auctions.js +152 -0
- package/dist/auctions.js.map +1 -0
- package/dist/client.d.ts +63 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +154 -0
- package/dist/client.js.map +1 -0
- package/dist/clips.d.ts +3 -0
- package/dist/clips.d.ts.map +1 -0
- package/dist/clips.js +53 -0
- package/dist/clips.js.map +1 -0
- package/dist/commentLinks.d.ts +46 -0
- package/dist/commentLinks.d.ts.map +1 -0
- package/dist/commentLinks.js +120 -0
- package/dist/commentLinks.js.map +1 -0
- package/dist/engine.d.ts +13 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +40 -0
- package/dist/engine.js.map +1 -0
- package/dist/engine.native.d.ts +3 -0
- package/dist/engine.native.d.ts.map +1 -0
- package/dist/engine.native.js +815 -0
- package/dist/engine.native.js.map +1 -0
- package/dist/flags.d.ts +5 -0
- package/dist/flags.d.ts.map +1 -0
- package/dist/flags.js +61 -0
- package/dist/flags.js.map +1 -0
- package/dist/guest.d.ts +5 -0
- package/dist/guest.d.ts.map +1 -0
- package/dist/guest.js +89 -0
- package/dist/guest.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +80 -0
- package/dist/index.js.map +1 -0
- package/dist/liveSelling.d.ts +3 -0
- package/dist/liveSelling.d.ts.map +1 -0
- package/dist/liveSelling.js +44 -0
- package/dist/liveSelling.js.map +1 -0
- package/dist/nativeViewTag.d.ts +15 -0
- package/dist/nativeViewTag.d.ts.map +1 -0
- package/dist/nativeViewTag.js +23 -0
- package/dist/nativeViewTag.js.map +1 -0
- package/dist/pip.d.ts +14 -0
- package/dist/pip.d.ts.map +1 -0
- package/dist/pip.js +15 -0
- package/dist/pip.js.map +1 -0
- package/dist/pip.native.d.ts +11 -0
- package/dist/pip.native.d.ts.map +1 -0
- package/dist/pip.native.js +47 -0
- package/dist/pip.native.js.map +1 -0
- package/dist/polls.d.ts +11 -0
- package/dist/polls.d.ts.map +1 -0
- package/dist/polls.js +135 -0
- package/dist/polls.js.map +1 -0
- package/dist/react/LiveSellingProvider.d.ts +16 -0
- package/dist/react/LiveSellingProvider.d.ts.map +1 -0
- package/dist/react/LiveSellingProvider.js +49 -0
- package/dist/react/LiveSellingProvider.js.map +1 -0
- package/dist/react/LiveVideoTarget.d.ts +22 -0
- package/dist/react/LiveVideoTarget.d.ts.map +1 -0
- package/dist/react/LiveVideoTarget.js +73 -0
- package/dist/react/LiveVideoTarget.js.map +1 -0
- package/dist/react/index.d.ts +19 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +43 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/useAuction.d.ts +28 -0
- package/dist/react/useAuction.d.ts.map +1 -0
- package/dist/react/useAuction.js +66 -0
- package/dist/react/useAuction.js.map +1 -0
- package/dist/react/useAuctionBidGate.d.ts +48 -0
- package/dist/react/useAuctionBidGate.d.ts.map +1 -0
- package/dist/react/useAuctionBidGate.js +144 -0
- package/dist/react/useAuctionBidGate.js.map +1 -0
- package/dist/react/useAuctionWins.d.ts +51 -0
- package/dist/react/useAuctionWins.d.ts.map +1 -0
- package/dist/react/useAuctionWins.js +268 -0
- package/dist/react/useAuctionWins.js.map +1 -0
- package/dist/react/useClips.d.ts +9 -0
- package/dist/react/useClips.d.ts.map +1 -0
- package/dist/react/useClips.js +37 -0
- package/dist/react/useClips.js.map +1 -0
- package/dist/react/useLiveSession.d.ts +34 -0
- package/dist/react/useLiveSession.d.ts.map +1 -0
- package/dist/react/useLiveSession.js +876 -0
- package/dist/react/useLiveSession.js.map +1 -0
- package/dist/react/useLiveStreams.d.ts +9 -0
- package/dist/react/useLiveStreams.d.ts.map +1 -0
- package/dist/react/useLiveStreams.js +35 -0
- package/dist/react/useLiveStreams.js.map +1 -0
- package/dist/react/useOngoingLive.d.ts +13 -0
- package/dist/react/useOngoingLive.d.ts.map +1 -0
- package/dist/react/useOngoingLive.js +45 -0
- package/dist/react/useOngoingLive.js.map +1 -0
- package/dist/react/useReplays.d.ts +15 -0
- package/dist/react/useReplays.d.ts.map +1 -0
- package/dist/react/useReplays.js +66 -0
- package/dist/react/useReplays.js.map +1 -0
- package/dist/react/useStreamInfo.d.ts +21 -0
- package/dist/react/useStreamInfo.d.ts.map +1 -0
- package/dist/react/useStreamInfo.js +125 -0
- package/dist/react/useStreamInfo.js.map +1 -0
- package/dist/replays.d.ts +3 -0
- package/dist/replays.d.ts.map +1 -0
- package/dist/replays.js +114 -0
- package/dist/replays.js.map +1 -0
- package/dist/session.d.ts +23 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +464 -0
- package/dist/session.js.map +1 -0
- package/dist/sessionTypes.d.ts +462 -0
- package/dist/sessionTypes.d.ts.map +1 -0
- package/dist/sessionTypes.js +55 -0
- package/dist/sessionTypes.js.map +1 -0
- package/dist/streams.d.ts +67 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +170 -0
- package/dist/streams.js.map +1 -0
- package/dist/types.d.ts +591 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -0
- package/dist/viewer.d.ts +5 -0
- package/dist/viewer.d.ts.map +1 -0
- package/dist/viewer.js +66 -0
- package/dist/viewer.js.map +1 -0
- package/dist/wire.d.ts +155 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +73 -0
- package/dist/wire.js.map +1 -0
- package/dist/zego.d.ts +83 -0
- package/dist/zego.d.ts.map +1 -0
- package/dist/zego.js +13 -0
- package/dist/zego.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/LivePip.podspec +20 -0
- package/ios/LivePipController.swift +327 -0
- package/ios/LivePipModule.swift +35 -0
- package/package.json +91 -0
- package/plugin/build/index.d.ts +29 -0
- package/plugin/build/index.js +101 -0
- package/react/package.json +6 -0
|
@@ -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
|