@tribe-nest/forge 3.26.0 → 3.31.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/package.json +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { Maximize, Pause, Pin, Play, Send } from "lucide-react";
|
|
3
|
+
import { io, type Socket } from "socket.io-client";
|
|
4
|
+
import type { IBroadcastPass, ILiveBroadcast } from "../../../types/models";
|
|
5
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
6
|
+
import { getForgeWebSocketUrl } from "../../../client/createForgeClient";
|
|
7
|
+
import {
|
|
8
|
+
BroadcastChannelProvider,
|
|
9
|
+
useBroadcastAudience,
|
|
10
|
+
useBroadcastComments,
|
|
11
|
+
useBroadcastSessionApi,
|
|
12
|
+
type BroadcastComment,
|
|
13
|
+
} from "../../../data/queries/useBroadcasts";
|
|
14
|
+
import { useForgeT } from "../../../i18n";
|
|
15
|
+
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
16
|
+
import { timeAgo } from "../community/util";
|
|
17
|
+
import { buttonStyle } from "../Button";
|
|
18
|
+
import { Loading } from "../Loading";
|
|
19
|
+
|
|
20
|
+
/** The socket messages a watching browser sends and receives. */
|
|
21
|
+
const SocketEvent = {
|
|
22
|
+
BroadcastChatMessage: "broadcastChatMessage",
|
|
23
|
+
BroadcastJoin: "broadcastJoin",
|
|
24
|
+
BroadcastPinComment: "broadcastPinComment",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The simulcast layers a viewer can pin themselves to. The one-letter values are
|
|
29
|
+
* the encoder's rids, not labels: `f` full, `h` half, `q` quarter.
|
|
30
|
+
*/
|
|
31
|
+
const SIMULCAST_QUALITIES = [
|
|
32
|
+
{ label: "forge.broadcast_player.quality_auto", value: "auto" },
|
|
33
|
+
{ label: "forge.broadcast_player.quality_high", value: "f" },
|
|
34
|
+
{ label: "forge.broadcast_player.quality_medium", value: "h" },
|
|
35
|
+
{ label: "forge.broadcast_player.quality_low", value: "q" },
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
const newId = (): string => {
|
|
39
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
40
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface BroadcastPlayerRenderProps {
|
|
44
|
+
/** The HLS playlist to play. */
|
|
45
|
+
src?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BroadcastPlayerProps {
|
|
49
|
+
broadcast: ILiveBroadcast;
|
|
50
|
+
/** The validated pass. Its name is what this viewer's chat messages are signed with. */
|
|
51
|
+
broadcastPass: IBroadcastPass;
|
|
52
|
+
/**
|
|
53
|
+
* How to play the HLS stream.
|
|
54
|
+
*
|
|
55
|
+
* Forge does not ship an HLS engine: a `<video src="…m3u8">` plays natively on
|
|
56
|
+
* Safari and on nothing else, so a site that wants Chrome and Firefox passes a
|
|
57
|
+
* player here (the starter passes `react-player`, which is exactly what
|
|
58
|
+
* apps/client uses). The bare `<video>` is the fallback rather than the
|
|
59
|
+
* default-and-only, so a missing renderer degrades to "plays on Safari"
|
|
60
|
+
* instead of to a blank rectangle everywhere.
|
|
61
|
+
*/
|
|
62
|
+
renderPlayer?: (props: BroadcastPlayerRenderProps) => ReactNode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The watch surface: the stream, who else is here, and the chat.
|
|
67
|
+
*
|
|
68
|
+
* Mounted ONLY behind a validated pass. Two playback paths, chosen by the
|
|
69
|
+
* broadcast itself: a WebRTC subscription when the broadcast carries a realtime
|
|
70
|
+
* config (sub-second, with a quality picker), and HLS otherwise.
|
|
71
|
+
*/
|
|
72
|
+
export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
|
|
73
|
+
const t = useForgeT();
|
|
74
|
+
const theme = useForgeTheme();
|
|
75
|
+
const { apiUrl, profileId } = useForge();
|
|
76
|
+
const sessionApi = useBroadcastSessionApi();
|
|
77
|
+
|
|
78
|
+
const [comments, setComments] = useState<BroadcastComment[]>([]);
|
|
79
|
+
const [comment, setComment] = useState("");
|
|
80
|
+
const [pinnedComment, setPinnedComment] = useState<BroadcastComment | null>(null);
|
|
81
|
+
const commentsContainerRef = useRef<HTMLDivElement>(null);
|
|
82
|
+
const socketRef = useRef<Socket | null>(null);
|
|
83
|
+
|
|
84
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
85
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
86
|
+
const videoContainerRef = useRef<HTMLDivElement>(null);
|
|
87
|
+
const [isAudioLoaded, setIsAudioLoaded] = useState(false);
|
|
88
|
+
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
89
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
90
|
+
const [showControls, setShowControls] = useState(false);
|
|
91
|
+
const [currentQuality, setCurrentQuality] = useState<string>("auto");
|
|
92
|
+
const [realtimeSessionId, setRealtimeSessionId] = useState<string | null>(null);
|
|
93
|
+
const isSubscribingRef = useRef(false);
|
|
94
|
+
|
|
95
|
+
const isRealtime = useMemo(
|
|
96
|
+
() => !!broadcast.realtimeConfig && Object.keys(broadcast.realtimeConfig).length > 0,
|
|
97
|
+
[broadcast.realtimeConfig],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const { data: audienceData } = useBroadcastAudience(broadcast.id, !broadcast.endedAt);
|
|
101
|
+
const { data: initialComments } = useBroadcastComments(broadcast.id);
|
|
102
|
+
|
|
103
|
+
// The backlog seeds the list once; everything after it arrives over the socket.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (initialComments) setComments(initialComments);
|
|
106
|
+
}, [initialComments]);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (comments.length === 0) return;
|
|
110
|
+
commentsContainerRef.current?.scrollTo({
|
|
111
|
+
top: commentsContainerRef.current.scrollHeight,
|
|
112
|
+
behavior: "smooth",
|
|
113
|
+
});
|
|
114
|
+
}, [comments]);
|
|
115
|
+
|
|
116
|
+
// ── WebRTC subscription ────────────────────────────────────────────────────
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const config = broadcast.realtimeConfig;
|
|
119
|
+
if (!isRealtime || !config) return;
|
|
120
|
+
if (isSubscribingRef.current) return;
|
|
121
|
+
isSubscribingRef.current = true;
|
|
122
|
+
|
|
123
|
+
const subscribe = async () => {
|
|
124
|
+
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.cloudflare.com:3478" }] });
|
|
125
|
+
// The transceiver order is the contract with the server: video first,
|
|
126
|
+
// audio second. Swapping them silently hands the audio track to the video
|
|
127
|
+
// element and the viewer gets a black rectangle with sound.
|
|
128
|
+
pc.addTransceiver("video", { direction: "recvonly" });
|
|
129
|
+
pc.addTransceiver("audio", { direction: "recvonly" });
|
|
130
|
+
|
|
131
|
+
pc.ontrack = (event) => {
|
|
132
|
+
if (event.track.kind === "video" && videoRef.current && event.streams[0]) {
|
|
133
|
+
videoRef.current.srcObject = event.streams[0];
|
|
134
|
+
setIsVideoLoaded(true);
|
|
135
|
+
}
|
|
136
|
+
if (event.track.kind === "audio" && audioRef.current && event.streams[0]) {
|
|
137
|
+
const stream = new MediaStream();
|
|
138
|
+
stream.addTrack(event.track);
|
|
139
|
+
audioRef.current.srcObject = stream;
|
|
140
|
+
setIsAudioLoaded(true);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const offer = await pc.createOffer();
|
|
145
|
+
await pc.setLocalDescription(offer);
|
|
146
|
+
const local = pc.localDescription?.toJSON();
|
|
147
|
+
|
|
148
|
+
const answer = await sessionApi.subscribe({
|
|
149
|
+
sdp: local?.sdp,
|
|
150
|
+
type: local?.type,
|
|
151
|
+
trackIds: config.tracks.map((track) => track.trackName),
|
|
152
|
+
sessionId: config.sessionId,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
setRealtimeSessionId(answer.sessionId);
|
|
156
|
+
await pc.setRemoteDescription(
|
|
157
|
+
new RTCSessionDescription({ sdp: answer.sessionDescription.sdp, type: "answer" }),
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
subscribe().catch(() => {
|
|
162
|
+
// Nothing useful to say to a viewer here: the spinner over the stage is
|
|
163
|
+
// already the honest report that no track arrived.
|
|
164
|
+
isSubscribingRef.current = false;
|
|
165
|
+
});
|
|
166
|
+
// `sessionApi` is rebuilt each render and is deliberately not a dependency:
|
|
167
|
+
// including it would re-negotiate the peer connection on every render.
|
|
168
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
169
|
+
}, [isRealtime, broadcast.realtimeConfig]);
|
|
170
|
+
|
|
171
|
+
// ── Chat socket ────────────────────────────────────────────────────────────
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!profileId || !broadcast.id || socketRef.current) return;
|
|
174
|
+
|
|
175
|
+
const socket = io(getForgeWebSocketUrl(apiUrl), {
|
|
176
|
+
auth: { profileId },
|
|
177
|
+
transports: ["websocket"],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
socket.on("connect", () => {
|
|
181
|
+
socket.emit(SocketEvent.BroadcastJoin, { broadcastId: broadcast.id });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
socket.on("message", (message: { event: string; data: unknown }) => {
|
|
185
|
+
if (message.event === SocketEvent.BroadcastChatMessage) {
|
|
186
|
+
const data = message.data as { broadcastId: string; message: BroadcastComment };
|
|
187
|
+
setComments((prev) => [...prev, data.message]);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (message.event === SocketEvent.BroadcastPinComment) {
|
|
191
|
+
const data = message.data as { broadcastId: string; comment: BroadcastComment | null };
|
|
192
|
+
setPinnedComment(data.comment);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
socketRef.current = socket;
|
|
197
|
+
}, [apiUrl, profileId, broadcast.id]);
|
|
198
|
+
|
|
199
|
+
const sendComment = () => {
|
|
200
|
+
if (!profileId || !socketRef.current || !comment.trim()) return;
|
|
201
|
+
socketRef.current.emit(SocketEvent.BroadcastChatMessage, {
|
|
202
|
+
broadcastId: broadcast.id,
|
|
203
|
+
message: {
|
|
204
|
+
id: newId(),
|
|
205
|
+
name: broadcastPass.name || t("forge.broadcast_player.anonymous"),
|
|
206
|
+
profileId,
|
|
207
|
+
content: comment,
|
|
208
|
+
publishedAt: new Date().toISOString(),
|
|
209
|
+
channelProvider: BroadcastChannelProvider.Website,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
setComment("");
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const togglePlay = () => {
|
|
216
|
+
if (!isRealtime || !isAudioLoaded || !isVideoLoaded) return;
|
|
217
|
+
if (isPlaying) {
|
|
218
|
+
audioRef.current?.pause();
|
|
219
|
+
videoRef.current?.pause();
|
|
220
|
+
setIsPlaying(false);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
void audioRef.current?.play();
|
|
224
|
+
void videoRef.current?.play();
|
|
225
|
+
if (audioRef.current) audioRef.current.volume = 1;
|
|
226
|
+
setIsPlaying(true);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const toggleFullscreen = () => {
|
|
230
|
+
if (!videoContainerRef.current) return;
|
|
231
|
+
if (document.fullscreenElement) void document.exitFullscreen();
|
|
232
|
+
else void videoContainerRef.current.requestFullscreen();
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const changeQuality = async (quality: string) => {
|
|
236
|
+
const config = broadcast.realtimeConfig;
|
|
237
|
+
if (!config) return;
|
|
238
|
+
setCurrentQuality(quality);
|
|
239
|
+
const videoTrack = config.tracks.find((track) => track.mid === "0");
|
|
240
|
+
try {
|
|
241
|
+
await sessionApi.switchQuality({
|
|
242
|
+
track: {
|
|
243
|
+
...videoTrack,
|
|
244
|
+
sessionId: realtimeSessionId,
|
|
245
|
+
...(quality !== "auto"
|
|
246
|
+
? {
|
|
247
|
+
simulcast: {
|
|
248
|
+
preferredRid: quality,
|
|
249
|
+
priorityOrdering: "asciibetical",
|
|
250
|
+
ridNotAvailable: "asciibetical",
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
: {}),
|
|
254
|
+
},
|
|
255
|
+
sessionId: config.sessionId,
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
// Falling back to whatever layer is already playing is better than
|
|
259
|
+
// stopping the stream to report a preference that did not take.
|
|
260
|
+
setCurrentQuality("auto");
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const panel = {
|
|
265
|
+
backgroundColor: theme.colors.background,
|
|
266
|
+
borderRadius: theme.cornerRadius,
|
|
267
|
+
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
|
268
|
+
border: `1px solid ${theme.colors.primary}30`,
|
|
269
|
+
} as const;
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div
|
|
273
|
+
style={{
|
|
274
|
+
display: "flex",
|
|
275
|
+
flexWrap: "wrap",
|
|
276
|
+
gap: 32,
|
|
277
|
+
padding: 16,
|
|
278
|
+
maxWidth: 1700,
|
|
279
|
+
marginInline: "auto",
|
|
280
|
+
flex: 1,
|
|
281
|
+
color: theme.colors.text,
|
|
282
|
+
fontFamily: theme.fontFamily,
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
<div style={{ flex: "1 1 520px", minWidth: 0, ...panel }}>
|
|
286
|
+
{!isRealtime && (
|
|
287
|
+
<div data-testid="broadcast-hls-stage" style={{ aspectRatio: "16/9" }}>
|
|
288
|
+
{renderPlayer ? (
|
|
289
|
+
renderPlayer({ src: broadcast.liveUrl })
|
|
290
|
+
) : (
|
|
291
|
+
<video
|
|
292
|
+
src={broadcast.liveUrl}
|
|
293
|
+
controls
|
|
294
|
+
autoPlay
|
|
295
|
+
playsInline
|
|
296
|
+
style={{ width: "100%", height: "100%", background: "#000" }}
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
{isRealtime && (
|
|
303
|
+
<div
|
|
304
|
+
ref={videoContainerRef}
|
|
305
|
+
data-testid="broadcast-realtime-stage"
|
|
306
|
+
onClick={togglePlay}
|
|
307
|
+
onMouseEnter={() => setShowControls(true)}
|
|
308
|
+
onMouseLeave={() => setShowControls(false)}
|
|
309
|
+
style={{ aspectRatio: "16/9", position: "relative", background: "#000", cursor: "pointer" }}
|
|
310
|
+
>
|
|
311
|
+
<video ref={videoRef} playsInline style={{ width: "100%", height: "100%" }} />
|
|
312
|
+
<audio ref={audioRef} />
|
|
313
|
+
|
|
314
|
+
{(!isAudioLoaded || !isVideoLoaded) && (
|
|
315
|
+
<div
|
|
316
|
+
style={{
|
|
317
|
+
position: "absolute",
|
|
318
|
+
top: "50%",
|
|
319
|
+
left: "50%",
|
|
320
|
+
transform: "translate(-50%, -50%)",
|
|
321
|
+
pointerEvents: "none",
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
<Loading />
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
|
|
328
|
+
{isAudioLoaded && isVideoLoaded && !isPlaying && (
|
|
329
|
+
<div
|
|
330
|
+
style={{
|
|
331
|
+
position: "absolute",
|
|
332
|
+
inset: 0,
|
|
333
|
+
display: "flex",
|
|
334
|
+
alignItems: "center",
|
|
335
|
+
justifyContent: "center",
|
|
336
|
+
background: "rgba(0, 0, 0, 0.2)",
|
|
337
|
+
pointerEvents: "none",
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
<Play size={64} color="#ffffff" fill="#ffffff" />
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
|
|
344
|
+
{isAudioLoaded && isVideoLoaded && isPlaying && showControls && (
|
|
345
|
+
<div
|
|
346
|
+
style={{
|
|
347
|
+
position: "absolute",
|
|
348
|
+
top: "50%",
|
|
349
|
+
left: "50%",
|
|
350
|
+
transform: "translate(-50%, -50%)",
|
|
351
|
+
pointerEvents: "none",
|
|
352
|
+
opacity: 0.8,
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
<Pause size={64} color="#ffffff" fill="#ffffff" />
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
|
|
359
|
+
{isAudioLoaded && isVideoLoaded && (
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
onClick={(e) => {
|
|
363
|
+
e.stopPropagation();
|
|
364
|
+
toggleFullscreen();
|
|
365
|
+
}}
|
|
366
|
+
aria-label={t("forge.broadcast_player.fullscreen")}
|
|
367
|
+
style={{
|
|
368
|
+
position: "absolute",
|
|
369
|
+
bottom: 16,
|
|
370
|
+
right: 16,
|
|
371
|
+
padding: 8,
|
|
372
|
+
borderRadius: 8,
|
|
373
|
+
border: "none",
|
|
374
|
+
cursor: "pointer",
|
|
375
|
+
background: "rgba(0, 0, 0, 0.5)",
|
|
376
|
+
color: "#ffffff",
|
|
377
|
+
zIndex: 1,
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
<Maximize size={20} />
|
|
381
|
+
</button>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
|
|
386
|
+
<div
|
|
387
|
+
style={{
|
|
388
|
+
padding: 16,
|
|
389
|
+
display: "flex",
|
|
390
|
+
justifyContent: "space-between",
|
|
391
|
+
alignItems: "center",
|
|
392
|
+
gap: 16,
|
|
393
|
+
flexWrap: "wrap",
|
|
394
|
+
}}
|
|
395
|
+
>
|
|
396
|
+
<div>
|
|
397
|
+
<p style={{ fontSize: 22, fontWeight: 500 }}>{broadcast.title}</p>
|
|
398
|
+
<div style={{ display: "flex", alignItems: "center", gap: 24, marginTop: 8, fontSize: 14 }}>
|
|
399
|
+
<p>{t("forge.broadcast_player.watching_now", { count: audienceData?.count ?? 0 })}</p>
|
|
400
|
+
{broadcast.startedAt && (
|
|
401
|
+
<p>{t("forge.broadcast_player.started", { duration: timeAgo(broadcast.startedAt) })}</p>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
{isRealtime && (
|
|
407
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
408
|
+
<label htmlFor="broadcast-quality">{t("forge.broadcast_player.quality")}</label>
|
|
409
|
+
<select
|
|
410
|
+
id="broadcast-quality"
|
|
411
|
+
value={currentQuality}
|
|
412
|
+
onChange={(e) => void changeQuality(e.target.value)}
|
|
413
|
+
style={{
|
|
414
|
+
padding: 8,
|
|
415
|
+
borderRadius: theme.cornerRadius,
|
|
416
|
+
border: `1px solid ${theme.colors.primary}40`,
|
|
417
|
+
background: theme.colors.background,
|
|
418
|
+
color: theme.colors.text,
|
|
419
|
+
fontFamily: theme.fontFamily,
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
{SIMULCAST_QUALITIES.map((quality) => (
|
|
423
|
+
<option key={quality.value} value={quality.value}>
|
|
424
|
+
{t(quality.label)}
|
|
425
|
+
</option>
|
|
426
|
+
))}
|
|
427
|
+
</select>
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<div style={{ flex: "1 1 340px", maxWidth: 400, height: 600, position: "relative", overflow: "hidden", ...panel }}>
|
|
434
|
+
<p style={{ fontSize: 18, padding: 16, borderBottom: `1px solid ${theme.colors.primary}30` }}>
|
|
435
|
+
{t("forge.broadcast_player.chat")}
|
|
436
|
+
</p>
|
|
437
|
+
|
|
438
|
+
{pinnedComment && (
|
|
439
|
+
<div
|
|
440
|
+
data-testid="broadcast-pinned-comment"
|
|
441
|
+
style={{
|
|
442
|
+
display: "flex",
|
|
443
|
+
alignItems: "center",
|
|
444
|
+
gap: 8,
|
|
445
|
+
margin: "8px 16px 0",
|
|
446
|
+
padding: 8,
|
|
447
|
+
backgroundColor: `${theme.colors.primary}15`,
|
|
448
|
+
border: `1px solid ${theme.colors.primary}50`,
|
|
449
|
+
borderRadius: theme.cornerRadius,
|
|
450
|
+
}}
|
|
451
|
+
>
|
|
452
|
+
<Pin size={14} style={{ color: theme.colors.primary, flexShrink: 0 }} />
|
|
453
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
454
|
+
<span style={{ fontSize: 12, fontWeight: 500, color: theme.colors.primary }}>
|
|
455
|
+
{t("forge.broadcast_player.pinned")}
|
|
456
|
+
</span>
|
|
457
|
+
<span style={{ fontSize: 12, marginLeft: 8, opacity: 0.7 }}>{pinnedComment.name}</span>
|
|
458
|
+
<p style={{ fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
459
|
+
{pinnedComment.content}
|
|
460
|
+
</p>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
)}
|
|
464
|
+
|
|
465
|
+
<div ref={commentsContainerRef} style={{ overflowY: "auto", height: "100%", padding: "16px 16px 320px" }}>
|
|
466
|
+
{comments.map((item) => (
|
|
467
|
+
<div
|
|
468
|
+
key={item.id}
|
|
469
|
+
style={{
|
|
470
|
+
display: "flex",
|
|
471
|
+
flexDirection: "column",
|
|
472
|
+
gap: 8,
|
|
473
|
+
padding: 8,
|
|
474
|
+
marginBottom: 8,
|
|
475
|
+
border: `1px solid ${theme.colors.primary}30`,
|
|
476
|
+
borderRadius: theme.cornerRadius,
|
|
477
|
+
}}
|
|
478
|
+
>
|
|
479
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
480
|
+
<span style={{ fontSize: 14, opacity: 0.7 }}>{item.name}</span>
|
|
481
|
+
<span style={{ fontSize: 12, opacity: 0.6 }}>{timeAgo(item.publishedAt)}</span>
|
|
482
|
+
</div>
|
|
483
|
+
<div style={{ fontSize: 14 }}>{item.content}</div>
|
|
484
|
+
</div>
|
|
485
|
+
))}
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
<div
|
|
489
|
+
style={{
|
|
490
|
+
position: "absolute",
|
|
491
|
+
bottom: 0,
|
|
492
|
+
left: 0,
|
|
493
|
+
right: 0,
|
|
494
|
+
padding: 16,
|
|
495
|
+
background: theme.colors.background,
|
|
496
|
+
display: "flex",
|
|
497
|
+
alignItems: "center",
|
|
498
|
+
gap: 8,
|
|
499
|
+
}}
|
|
500
|
+
>
|
|
501
|
+
<input
|
|
502
|
+
aria-label={t("forge.broadcast_player.message_label")}
|
|
503
|
+
value={comment}
|
|
504
|
+
onChange={(e) => setComment(e.target.value)}
|
|
505
|
+
onKeyDown={(e) => {
|
|
506
|
+
if (e.key !== "Enter") return;
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
sendComment();
|
|
509
|
+
}}
|
|
510
|
+
style={{
|
|
511
|
+
flex: 1,
|
|
512
|
+
minWidth: 0,
|
|
513
|
+
padding: 10,
|
|
514
|
+
border: `1px solid ${theme.colors.primary}40`,
|
|
515
|
+
borderRadius: theme.cornerRadius,
|
|
516
|
+
background: theme.colors.background,
|
|
517
|
+
color: theme.colors.text,
|
|
518
|
+
fontFamily: theme.fontFamily,
|
|
519
|
+
}}
|
|
520
|
+
/>
|
|
521
|
+
<button
|
|
522
|
+
type="button"
|
|
523
|
+
onClick={sendComment}
|
|
524
|
+
disabled={!comment.trim().length}
|
|
525
|
+
aria-label={t("forge.broadcast_player.send")}
|
|
526
|
+
style={buttonStyle(theme, { disabled: !comment.trim().length })}
|
|
527
|
+
>
|
|
528
|
+
<Send size={18} />
|
|
529
|
+
</button>
|
|
530
|
+
</div>
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export default BroadcastPlayer;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ILiveBroadcast } from "../../../types/models";
|
|
2
|
+
import { useEvent } from "../../../data/queries/useEvents";
|
|
3
|
+
import { useForgeT } from "../../../i18n";
|
|
4
|
+
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
5
|
+
import { useAmountFormatter } from "../../format/useFormatCurrency";
|
|
6
|
+
import { minTicketPrice } from "../../headless/broadcast/broadcastState";
|
|
7
|
+
import { EventTickets } from "../EventTickets";
|
|
8
|
+
|
|
9
|
+
export interface BroadcastTicketPurchaseProps {
|
|
10
|
+
broadcast: ILiveBroadcast;
|
|
11
|
+
/** Format a numeric amount for display. Defaults to the site's currency formatter. */
|
|
12
|
+
formatAmount?: (amount: number) => string;
|
|
13
|
+
/** Stripe return URL for a PAID purchase (forwarded to the ticket flow). */
|
|
14
|
+
finalisePath?: (slug: string, orderId: string) => string;
|
|
15
|
+
/** Host-owned navigation on FREE completion (forwarded to the ticket flow). */
|
|
16
|
+
onComplete?: (info: { slug: string; orderId: string }) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The way IN for a viewer who does not have a ticket: the linked event's real
|
|
21
|
+
* checkout, opened from the paywall.
|
|
22
|
+
*
|
|
23
|
+
* Without this the pass box is a dead end. Someone who followed a link to a paid
|
|
24
|
+
* broadcast, has no code, and is looking at a locked page has no route to buy
|
|
25
|
+
* one, which is the difference between a paywall and a closed door.
|
|
26
|
+
*
|
|
27
|
+
* Renders nothing until the linked event resolves, so a broadcast whose event
|
|
28
|
+
* has been deleted shows a ticket box and no misleading buy button.
|
|
29
|
+
*/
|
|
30
|
+
export function BroadcastTicketPurchase({
|
|
31
|
+
broadcast,
|
|
32
|
+
formatAmount,
|
|
33
|
+
finalisePath,
|
|
34
|
+
onComplete,
|
|
35
|
+
}: BroadcastTicketPurchaseProps) {
|
|
36
|
+
const t = useForgeT();
|
|
37
|
+
const theme = useForgeTheme();
|
|
38
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
39
|
+
const { data: event } = useEvent(broadcast.events?.[0]?.eventId);
|
|
40
|
+
|
|
41
|
+
if (!event) return null;
|
|
42
|
+
|
|
43
|
+
const from = minTicketPrice(broadcast);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
data-testid="broadcast-buy-tickets"
|
|
48
|
+
style={{
|
|
49
|
+
width: "100%",
|
|
50
|
+
display: "flex",
|
|
51
|
+
flexDirection: "column",
|
|
52
|
+
gap: 8,
|
|
53
|
+
padding: 16,
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
flexShrink: 0,
|
|
56
|
+
color: theme.colors.text,
|
|
57
|
+
border: `1px solid ${theme.colors.primary}80`,
|
|
58
|
+
borderRadius: theme.cornerRadius,
|
|
59
|
+
backgroundColor: theme.colors.background,
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<EventTickets
|
|
63
|
+
slug={event.slug}
|
|
64
|
+
triggerText={t("forge.broadcast_ticket_purchase.buy_tickets")}
|
|
65
|
+
formatAmount={fmt}
|
|
66
|
+
finalisePath={finalisePath}
|
|
67
|
+
onComplete={onComplete}
|
|
68
|
+
/>
|
|
69
|
+
{from !== null && <p>{t("forge.broadcast_ticket_purchase.from_price", { price: fmt(from) })}</p>}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default BroadcastTicketPurchase;
|