@flamingo-stack/openframe-frontend-core 0.0.177 → 0.0.178-snapshot.20260514174355
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/dist/{chunk-C6ZMI4UB.cjs → chunk-4TM2SBMX.cjs} +884 -1513
- package/dist/chunk-4TM2SBMX.cjs.map +1 -0
- package/dist/{chunk-WX7PT5C7.cjs → chunk-ALW3D72O.cjs} +61 -2
- package/dist/chunk-ALW3D72O.cjs.map +1 -0
- package/dist/{chunk-KB2N44BY.js → chunk-FMWHOUFE.js} +61 -2
- package/dist/chunk-FMWHOUFE.js.map +1 -0
- package/dist/{chunk-6LDN3CIY.js → chunk-ZMQP3UZJ.js} +3701 -4330
- package/dist/chunk-ZMQP3UZJ.js.map +1 -0
- package/dist/components/features/entity-video-section.d.ts +54 -0
- package/dist/components/features/entity-video-section.d.ts.map +1 -0
- package/dist/components/features/index.cjs +17 -3
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +4 -2
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +22 -8
- package/dist/components/features/video-bites-display.d.ts +38 -0
- package/dist/components/features/video-bites-display.d.ts.map +1 -0
- package/dist/components/features/video-ratio-tabs.d.ts +62 -0
- package/dist/components/features/video-ratio-tabs.d.ts.map +1 -0
- package/dist/components/features/video.d.ts +94 -0
- package/dist/components/features/video.d.ts.map +1 -0
- package/dist/components/index.cjs +17 -3
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +22 -8
- package/dist/components/media-carousel.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/hooks/index.cjs +4 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/use-near-viewport.d.ts +42 -0
- package/dist/hooks/use-near-viewport.d.ts.map +1 -0
- package/dist/index.cjs +19 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +24 -8
- package/package.json +2 -2
- package/src/components/features/entity-video-section.tsx +175 -0
- package/src/components/features/index.ts +9 -2
- package/src/components/features/video-bites-display.tsx +216 -0
- package/src/components/features/video-ratio-tabs.tsx +174 -0
- package/src/components/features/video.tsx +474 -0
- package/src/components/media-carousel.tsx +43 -236
- package/src/components/shared/product-release/release-detail-page.tsx +26 -19
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-near-viewport.ts +118 -0
- package/dist/chunk-6LDN3CIY.js.map +0 -1
- package/dist/chunk-C6ZMI4UB.cjs.map +0 -1
- package/dist/chunk-KB2N44BY.js.map +0 -1
- package/dist/chunk-WX7PT5C7.cjs.map +0 -1
- package/dist/components/features/video-player.d.ts +0 -47
- package/dist/components/features/video-player.d.ts.map +0 -1
- package/dist/components/features/youtube-embed.d.ts +0 -17
- package/dist/components/features/youtube-embed.d.ts.map +0 -1
- package/dist/utils/lite-youtube-embed-stub.d.ts +0 -8
- package/dist/utils/lite-youtube-embed-stub.d.ts.map +0 -1
- package/dist/utils/lite-youtube-embed.d.ts +0 -9
- package/dist/utils/lite-youtube-embed.d.ts.map +0 -1
- package/src/components/features/.video-player.md +0 -44
- package/src/components/features/.youtube-embed.md +0 -40
- package/src/components/features/__tests__/video-player.test.tsx +0 -142
- package/src/components/features/video-player.tsx +0 -1030
- package/src/components/features/youtube-embed.tsx +0 -275
- package/src/utils/lite-youtube-embed-stub.tsx +0 -21
- package/src/utils/lite-youtube-embed.tsx +0 -46
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState, useRef, useEffect, memo, useCallback } from 'react';
|
|
4
4
|
import { cn } from "../utils/cn";
|
|
5
5
|
import { MediaItem } from '../utils/media-carousel-utils-stub';
|
|
6
|
-
import {
|
|
6
|
+
import { Video, extractYouTubeId } from './features/video';
|
|
7
7
|
|
|
8
8
|
// Navigation icons
|
|
9
9
|
const ChevronLeftIcon = () => (
|
|
@@ -18,17 +18,6 @@ const ChevronRightIcon = () => (
|
|
|
18
18
|
</svg>
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
const PlayIcon = () => (
|
|
22
|
-
<svg width="24" height="24" fill="white" viewBox="0 0 24 24">
|
|
23
|
-
<path d="M8 5v14l11-7z"/>
|
|
24
|
-
</svg>
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
function getYouTubeVideoId(url: string): string | null {
|
|
28
|
-
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\n?#]+)/);
|
|
29
|
-
return match ? match[1] : null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
21
|
interface MediaCarouselProps {
|
|
33
22
|
media: MediaItem[];
|
|
34
23
|
className?: string;
|
|
@@ -40,71 +29,6 @@ interface MediaCarouselProps {
|
|
|
40
29
|
objectFit?: 'contain' | 'cover';
|
|
41
30
|
}
|
|
42
31
|
|
|
43
|
-
// Carousel-specific YouTube embed component
|
|
44
|
-
const CarouselYouTubeEmbed = ({ videoId, title }: { videoId: string; title: string }) => {
|
|
45
|
-
const [mounted, setMounted] = useState(false);
|
|
46
|
-
const [isActivated, setIsActivated] = useState(false);
|
|
47
|
-
const [isLoaded, setIsLoaded] = useState(false);
|
|
48
|
-
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
setMounted(true);
|
|
51
|
-
}, []);
|
|
52
|
-
|
|
53
|
-
if (!mounted) {
|
|
54
|
-
return (
|
|
55
|
-
<div className="absolute inset-0 bg-black rounded-lg overflow-hidden">
|
|
56
|
-
<div className="absolute inset-0 flex items-center justify-center">
|
|
57
|
-
<div className="text-white text-sm">Loading video...</div>
|
|
58
|
-
</div>
|
|
59
|
-
</div>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const thumbnailUrl = `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`;
|
|
64
|
-
const embedUrl = `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&playsinline=1&rel=0&modestbranding=1`;
|
|
65
|
-
|
|
66
|
-
const handleActivate = () => {
|
|
67
|
-
setIsActivated(true);
|
|
68
|
-
setTimeout(() => setIsLoaded(true), 100);
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
return (
|
|
72
|
-
<div className="absolute inset-0 bg-black">
|
|
73
|
-
{!isActivated ? (
|
|
74
|
-
<button
|
|
75
|
-
onClick={handleActivate}
|
|
76
|
-
className="absolute inset-0 cursor-pointer group"
|
|
77
|
-
aria-label={`Play video: ${title}`}
|
|
78
|
-
>
|
|
79
|
-
<img
|
|
80
|
-
src={thumbnailUrl}
|
|
81
|
-
alt={title}
|
|
82
|
-
className="w-full h-full object-cover transition-opacity group-hover:opacity-90"
|
|
83
|
-
loading="lazy"
|
|
84
|
-
/>
|
|
85
|
-
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/30 transition-colors">
|
|
86
|
-
<div className="bg-red-600 rounded-full p-4 group-hover:scale-110 transition-transform">
|
|
87
|
-
<PlayIcon />
|
|
88
|
-
</div>
|
|
89
|
-
</div>
|
|
90
|
-
</button>
|
|
91
|
-
) : (
|
|
92
|
-
<div className="absolute inset-0">
|
|
93
|
-
{isLoaded && (
|
|
94
|
-
<iframe
|
|
95
|
-
src={embedUrl}
|
|
96
|
-
title={title}
|
|
97
|
-
className="w-full h-full"
|
|
98
|
-
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
99
|
-
allowFullScreen
|
|
100
|
-
/>
|
|
101
|
-
)}
|
|
102
|
-
</div>
|
|
103
|
-
)}
|
|
104
|
-
</div>
|
|
105
|
-
);
|
|
106
|
-
};
|
|
107
|
-
|
|
108
32
|
export const MediaCarousel = memo(function MediaCarousel({
|
|
109
33
|
media,
|
|
110
34
|
className,
|
|
@@ -116,10 +40,21 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
116
40
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
117
41
|
const [touchStart, setTouchStart] = useState<number | null>(null);
|
|
118
42
|
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
|
119
|
-
const [playingVideos, setPlayingVideos] = useState<Set<number>>(new Set());
|
|
120
43
|
const carouselRef = useRef<HTMLDivElement>(null);
|
|
121
44
|
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
|
122
45
|
|
|
46
|
+
// Clamp `currentIndex` whenever `media` shrinks (e.g. an admin removes
|
|
47
|
+
// a slide while the user is on the last one) so the thumbnail active
|
|
48
|
+
// state + `currentItem` lookup stay in sync. Without this, the
|
|
49
|
+
// `media[currentIndex] || media[0]` fallback renders the first slide
|
|
50
|
+
// but every thumbnail's `isActive = index === currentIndex` reads
|
|
51
|
+
// false — visually nothing is selected.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (currentIndex >= media.length && media.length > 0) {
|
|
54
|
+
setCurrentIndex(media.length - 1);
|
|
55
|
+
}
|
|
56
|
+
}, [media.length, currentIndex]);
|
|
57
|
+
|
|
123
58
|
// Early return if no media provided
|
|
124
59
|
if (!media || media.length === 0) {
|
|
125
60
|
return null;
|
|
@@ -132,84 +67,19 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
132
67
|
return null;
|
|
133
68
|
}
|
|
134
69
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (item.type !== 'video') return;
|
|
139
|
-
|
|
140
|
-
// Find the video element
|
|
141
|
-
const videoElements = document.querySelectorAll(`video[data-video-index="${index}"]`);
|
|
142
|
-
const video = videoElements[0] as HTMLVideoElement;
|
|
143
|
-
|
|
144
|
-
if (!video) {
|
|
145
|
-
console.log('❌ Video element not found for index:', index);
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (video.paused) {
|
|
150
|
-
const playPromise = video.play();
|
|
151
|
-
|
|
152
|
-
if (playPromise !== undefined) {
|
|
153
|
-
playPromise
|
|
154
|
-
.then(() => {
|
|
155
|
-
const playButton = video.parentElement?.querySelector('.video-play-button');
|
|
156
|
-
if (playButton) {
|
|
157
|
-
(playButton as HTMLElement).style.display = 'none';
|
|
158
|
-
}
|
|
159
|
-
})
|
|
160
|
-
.catch((error) => {
|
|
161
|
-
if (error.name === 'NotSupportedError' || error.name === 'MediaElementError') {
|
|
162
|
-
const fallbackDiv = document.createElement('div');
|
|
163
|
-
fallbackDiv.className = 'absolute inset-0 flex items-center justify-center bg-ods-card text-center p-4';
|
|
164
|
-
fallbackDiv.innerHTML = `
|
|
165
|
-
<div>
|
|
166
|
-
<p class="text-ods-text-primary text-sm mb-2">Video format not supported</p>
|
|
167
|
-
<a href="${item.src}" target="_blank" rel="noopener noreferrer"
|
|
168
|
-
class="text-ods-accent hover:text-[#FFD700] text-sm">
|
|
169
|
-
Open Video Directly
|
|
170
|
-
</a>
|
|
171
|
-
</div>
|
|
172
|
-
`;
|
|
173
|
-
video.parentElement?.appendChild(fallbackDiv);
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
} else {
|
|
178
|
-
video.pause();
|
|
179
|
-
const playButton = video.parentElement?.querySelector('.video-play-button');
|
|
180
|
-
if (playButton) {
|
|
181
|
-
(playButton as HTMLElement).style.display = 'flex';
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}, [media]);
|
|
185
|
-
|
|
186
|
-
// Navigation functions
|
|
70
|
+
// Navigation functions — `<Video>` (MuxPlayer/YT facade) owns its own
|
|
71
|
+
// play/pause lifecycle, so the previous bare-`<video>`-element pause
|
|
72
|
+
// logic in nextSlide/prevSlide/selectSlide is no longer needed.
|
|
187
73
|
const nextSlide = useCallback(() => {
|
|
188
|
-
const currentVideo = document.querySelector(`[data-video-index="${currentIndex}"]`) as HTMLVideoElement;
|
|
189
|
-
if (currentVideo && !currentVideo.paused) {
|
|
190
|
-
currentVideo.pause();
|
|
191
|
-
}
|
|
192
|
-
setPlayingVideos(new Set());
|
|
193
74
|
setCurrentIndex((prev) => (prev + 1) % media.length);
|
|
194
|
-
}, [
|
|
75
|
+
}, [media.length]);
|
|
195
76
|
|
|
196
77
|
const prevSlide = useCallback(() => {
|
|
197
|
-
const currentVideo = document.querySelector(`[data-video-index="${currentIndex}"]`) as HTMLVideoElement;
|
|
198
|
-
if (currentVideo && !currentVideo.paused) {
|
|
199
|
-
currentVideo.pause();
|
|
200
|
-
}
|
|
201
|
-
setPlayingVideos(new Set());
|
|
202
78
|
setCurrentIndex((prev) => (prev - 1 + media.length) % media.length);
|
|
203
|
-
}, [
|
|
79
|
+
}, [media.length]);
|
|
204
80
|
|
|
205
81
|
const selectSlide = useCallback((index: number) => {
|
|
206
82
|
if (index === currentIndex) return;
|
|
207
|
-
|
|
208
|
-
const currentVideo = document.querySelector(`[data-video-index="${currentIndex}"]`) as HTMLVideoElement;
|
|
209
|
-
if (currentVideo && !currentVideo.paused) {
|
|
210
|
-
currentVideo.pause();
|
|
211
|
-
}
|
|
212
|
-
setPlayingVideos(new Set());
|
|
213
83
|
setCurrentIndex(index);
|
|
214
84
|
}, [currentIndex]);
|
|
215
85
|
|
|
@@ -253,92 +123,29 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
253
123
|
}
|
|
254
124
|
};
|
|
255
125
|
|
|
256
|
-
// Render YouTube embed
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
</div>
|
|
268
|
-
</div>
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return <CarouselYouTubeEmbed videoId={videoId} title={item.alt || `Video ${index + 1}`} />;
|
|
273
|
-
};
|
|
126
|
+
// Render YouTube embed via the SSoT `<Video>` — same lite-youtube
|
|
127
|
+
// facade everywhere, no carousel-local fork.
|
|
128
|
+
const renderYouTubeEmbed = (item: MediaItem, index: number) => (
|
|
129
|
+
<Video
|
|
130
|
+
kind="youtube"
|
|
131
|
+
url={item.src}
|
|
132
|
+
title={item.alt || `Video ${index + 1}`}
|
|
133
|
+
layout="fill"
|
|
134
|
+
priority={index === currentIndex}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
274
137
|
|
|
275
|
-
// Render video
|
|
138
|
+
// Render video via the SSoT `<Video>` — MuxPlayer handles both HLS
|
|
139
|
+
// and plain MP4, so the carousel no longer needs its own bare
|
|
140
|
+
// `<video>` element + custom play overlay + 60 LOC of error handling.
|
|
276
141
|
const renderVideo = (item: MediaItem, index: number) => (
|
|
277
|
-
<
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
muted
|
|
285
|
-
data-video-index={index}
|
|
286
|
-
onClick={() => handleVideoClick(index)}
|
|
287
|
-
onPlay={() => {
|
|
288
|
-
const playButton = document.querySelector(`[data-video-index="${index}"]`)?.parentElement?.querySelector('.video-play-button');
|
|
289
|
-
if (playButton) {
|
|
290
|
-
(playButton as HTMLElement).style.display = 'none';
|
|
291
|
-
}
|
|
292
|
-
}}
|
|
293
|
-
onPause={() => {
|
|
294
|
-
const playButton = document.querySelector(`[data-video-index="${index}"]`)?.parentElement?.querySelector('.video-play-button');
|
|
295
|
-
if (playButton) {
|
|
296
|
-
(playButton as HTMLElement).style.display = 'flex';
|
|
297
|
-
}
|
|
298
|
-
}}
|
|
299
|
-
onLoadedMetadata={(e) => {
|
|
300
|
-
const video = e.target as HTMLVideoElement;
|
|
301
|
-
video.currentTime = 1;
|
|
302
|
-
}}
|
|
303
|
-
onError={(e) => {
|
|
304
|
-
const target = e.target as HTMLVideoElement;
|
|
305
|
-
|
|
306
|
-
if (target.crossOrigin) {
|
|
307
|
-
target.crossOrigin = '';
|
|
308
|
-
target.load();
|
|
309
|
-
} else {
|
|
310
|
-
target.style.display = 'none';
|
|
311
|
-
const fallbackDiv = document.createElement('div');
|
|
312
|
-
fallbackDiv.className = 'absolute inset-0 flex items-center justify-center bg-ods-card text-center p-4';
|
|
313
|
-
fallbackDiv.innerHTML = `
|
|
314
|
-
<div>
|
|
315
|
-
<p class="text-ods-text-primary text-sm mb-2">Video could not be loaded</p>
|
|
316
|
-
<a href="${item.src}" target="_blank" rel="noopener noreferrer"
|
|
317
|
-
class="text-ods-accent hover:text-[#FFD700] text-sm">
|
|
318
|
-
Open Video Directly
|
|
319
|
-
</a>
|
|
320
|
-
</div>
|
|
321
|
-
`;
|
|
322
|
-
target.parentElement?.appendChild(fallbackDiv);
|
|
323
|
-
}
|
|
324
|
-
}}
|
|
325
|
-
>
|
|
326
|
-
<source src={item.src} type="video/mp4" />
|
|
327
|
-
<source src={item.src} type="video/webm" />
|
|
328
|
-
<source src={item.src} type="video/ogg" />
|
|
329
|
-
Your browser does not support the video tag.
|
|
330
|
-
</video>
|
|
331
|
-
|
|
332
|
-
{/* Play Button Overlay */}
|
|
333
|
-
<div
|
|
334
|
-
className="video-play-button absolute inset-0 flex items-center justify-center bg-black bg-opacity-20 cursor-pointer transition-opacity hover:bg-opacity-30"
|
|
335
|
-
onClick={() => handleVideoClick(index)}
|
|
336
|
-
>
|
|
337
|
-
<div className="w-16 h-16 bg-black bg-opacity-60 rounded-full flex items-center justify-center">
|
|
338
|
-
<PlayIcon />
|
|
339
|
-
</div>
|
|
340
|
-
</div>
|
|
341
|
-
</div>
|
|
142
|
+
<Video
|
|
143
|
+
url={item.src}
|
|
144
|
+
poster={item.poster}
|
|
145
|
+
muted
|
|
146
|
+
layout="fill"
|
|
147
|
+
priority={index === currentIndex}
|
|
148
|
+
/>
|
|
342
149
|
);
|
|
343
150
|
|
|
344
151
|
// Render image
|
|
@@ -350,7 +157,6 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
350
157
|
className={`w-full h-full object-${objectFit}`}
|
|
351
158
|
loading="lazy"
|
|
352
159
|
onError={(e) => {
|
|
353
|
-
console.log('❌ Image failed to load:', item.src);
|
|
354
160
|
const target = e.target as HTMLImageElement;
|
|
355
161
|
target.style.display = 'none';
|
|
356
162
|
}}
|
|
@@ -360,8 +166,6 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
360
166
|
|
|
361
167
|
// Render main media item
|
|
362
168
|
const renderMainMedia = (item: MediaItem, index: number) => {
|
|
363
|
-
console.log('🎬 Rendering media item:', { type: item.type, src: item.src.substring(0, 100) + '...' });
|
|
364
|
-
|
|
365
169
|
switch (item.type) {
|
|
366
170
|
case 'youtube':
|
|
367
171
|
return renderYouTubeEmbed(item, index);
|
|
@@ -379,7 +183,10 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
379
183
|
|
|
380
184
|
let thumbnailSrc = item.src;
|
|
381
185
|
if (item.type === 'youtube') {
|
|
382
|
-
|
|
186
|
+
// Use the SSoT `extractYouTubeId` (strict URL parsing, ReDoS-safe)
|
|
187
|
+
// rather than a local regex so YouTube id extraction has a single
|
|
188
|
+
// implementation across the lib.
|
|
189
|
+
const videoId = extractYouTubeId(item.src);
|
|
383
190
|
thumbnailSrc = videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : item.src;
|
|
384
191
|
} else if (item.type === 'video' && item.poster) {
|
|
385
192
|
thumbnailSrc = item.poster;
|
|
@@ -11,8 +11,7 @@ import { ImageGalleryModal } from '../../ui/image-gallery-modal';
|
|
|
11
11
|
import { GitHubIcon } from '../../icons/github-icon';
|
|
12
12
|
import { AlertTriangle, ExternalLink, BookMarked } from 'lucide-react';
|
|
13
13
|
import { formatReleaseDate } from '../../../utils/date-formatters';
|
|
14
|
-
import {
|
|
15
|
-
import { VideoPlayer } from '../../features/video-player';
|
|
14
|
+
import { Video } from '../../features/video';
|
|
16
15
|
import { DetailPageSkeleton } from '../detail-page-skeleton';
|
|
17
16
|
import type { ChangelogEntry } from '../../../types/product-release';
|
|
18
17
|
import type { VideoTeaser } from '../../../types/video-processing';
|
|
@@ -308,7 +307,7 @@ export function ReleaseDetailPage({
|
|
|
308
307
|
}}
|
|
309
308
|
>
|
|
310
309
|
{mediaItem.media_type === 'video' || mediaItem.media_type === 'demo' ? (
|
|
311
|
-
<
|
|
310
|
+
<Video url={mediaItem.media_url} layout="native" />
|
|
312
311
|
) : (
|
|
313
312
|
<img src={mediaItem.media_url} alt={mediaItem.title || `Media ${index + 1}`} className="w-full h-full object-cover" />
|
|
314
313
|
)}
|
|
@@ -340,25 +339,33 @@ export function ReleaseDetailPage({
|
|
|
340
339
|
/>
|
|
341
340
|
) : (
|
|
342
341
|
<>
|
|
343
|
-
{
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
342
|
+
{/*
|
|
343
|
+
Fallback when no `VideoDisplaySection` is injected. `<Video>` is the
|
|
344
|
+
SSoT for every video surface — single source of truth across YouTube,
|
|
345
|
+
HLS, and MP4 paths.
|
|
346
|
+
*/}
|
|
347
|
+
{youtubeUrl && (
|
|
348
|
+
<Video
|
|
349
|
+
kind="youtube"
|
|
350
|
+
url={youtubeUrl}
|
|
351
|
+
title={`${releaseTitle} - Video`}
|
|
352
|
+
layout="native"
|
|
353
|
+
/>
|
|
354
|
+
)}
|
|
349
355
|
{!youtubeUrl && mainVideoUrl && (
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
356
|
+
<Video
|
|
357
|
+
url={mainVideoUrl}
|
|
358
|
+
srtContent={release?.srt_content as string | undefined}
|
|
359
|
+
captionsUrl={release?.captionsUrl as string | undefined}
|
|
360
|
+
layout="centered"
|
|
361
|
+
/>
|
|
355
362
|
)}
|
|
356
363
|
{highlightVideoUrl && (
|
|
357
|
-
<
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
364
|
+
<Video
|
|
365
|
+
url={highlightVideoUrl}
|
|
366
|
+
poster={highlightVideoThumbnail}
|
|
367
|
+
layout="centered"
|
|
368
|
+
/>
|
|
362
369
|
)}
|
|
363
370
|
</>
|
|
364
371
|
)}
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useNearViewport — module-level shared IntersectionObserver in hook form.
|
|
3
|
+
*
|
|
4
|
+
* Single IO instance per `rootMargin` value, shared across every component
|
|
5
|
+
* that mounts the hook. Reduces overhead vs. one IO per component on
|
|
6
|
+
* grid/list pages where many subscribers observe the viewport with the same
|
|
7
|
+
* margin. Promoted from the inline singleton at
|
|
8
|
+
* `multi-platform-hub/components/shared/video-bites-display.tsx:21-43`,
|
|
9
|
+
* which is the only IO pattern in either repo today.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* ```tsx
|
|
13
|
+
* function MyCard() {
|
|
14
|
+
* const { ref, isNear } = useNearViewport('500px');
|
|
15
|
+
* return <div ref={ref}>{isNear ? <HeavyChild /> : <Placeholder />}</div>;
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* StrictMode safety: cleanup uses an identity check on the registered
|
|
20
|
+
* callback so React's dev double-mount (mount → cleanup → re-mount) does
|
|
21
|
+
* not drop the second mount's freshly-set subscription. The IO callback
|
|
22
|
+
* also checks `subscribers.get(target)` before invoking so a fire that
|
|
23
|
+
* races with unmount cannot crash on a torn-down component.
|
|
24
|
+
*
|
|
25
|
+
* The hook fires once — on first intersection it sets `isNear=true` and
|
|
26
|
+
* unobserves the element. Callers that need re-observation should
|
|
27
|
+
* unmount and remount (or fork the hook for two-way behavior).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
31
|
+
|
|
32
|
+
// Per-rootMargin IO map. Multiple call sites with different margins each
|
|
33
|
+
// get their own singleton observer.
|
|
34
|
+
const observers = new Map<string, IntersectionObserver>();
|
|
35
|
+
const subscribers = new WeakMap<Element, () => void>();
|
|
36
|
+
|
|
37
|
+
function getObserverFor(rootMargin: string): IntersectionObserver {
|
|
38
|
+
const existing = observers.get(rootMargin);
|
|
39
|
+
if (existing) return existing;
|
|
40
|
+
|
|
41
|
+
const io = new IntersectionObserver(
|
|
42
|
+
(entries) => {
|
|
43
|
+
entries.forEach((entry) => {
|
|
44
|
+
if (!entry.isIntersecting) return;
|
|
45
|
+
// Race-safe: re-read the callback at fire time. A late IO firing
|
|
46
|
+
// after cleanup must not invoke a stale callback.
|
|
47
|
+
const cb = subscribers.get(entry.target);
|
|
48
|
+
if (cb) {
|
|
49
|
+
cb();
|
|
50
|
+
io.unobserve(entry.target);
|
|
51
|
+
subscribers.delete(entry.target);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
{ rootMargin }
|
|
56
|
+
);
|
|
57
|
+
observers.set(rootMargin, io);
|
|
58
|
+
return io;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UseNearViewportResult<T extends Element = HTMLElement> {
|
|
62
|
+
/** Ref to attach to the element you want to gate on visibility. */
|
|
63
|
+
ref: (node: T | null) => void;
|
|
64
|
+
/** Flips to `true` once the element enters within `rootMargin` of the viewport. Never flips back. */
|
|
65
|
+
isNear: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param rootMargin Margin around the viewport (CSS-style string).
|
|
70
|
+
* '500px' = element starts mounting 500px before scroll-in.
|
|
71
|
+
* '1000px' = a full viewport's worth of lookahead.
|
|
72
|
+
* '0px' = strict on-screen detection.
|
|
73
|
+
*/
|
|
74
|
+
export function useNearViewport<T extends Element = HTMLElement>(
|
|
75
|
+
rootMargin: string = '500px'
|
|
76
|
+
): UseNearViewportResult<T> {
|
|
77
|
+
const [isNear, setIsNear] = useState(false);
|
|
78
|
+
const elRef = useRef<T | null>(null);
|
|
79
|
+
|
|
80
|
+
// Subscribe/unsubscribe on element change.
|
|
81
|
+
const ref = useCallback(
|
|
82
|
+
(node: T | null) => {
|
|
83
|
+
const prev = elRef.current;
|
|
84
|
+
|
|
85
|
+
// Unsubscribe previous, if any. Identity-check the callback so a
|
|
86
|
+
// StrictMode re-mount that has already re-registered keeps its sub.
|
|
87
|
+
if (prev) {
|
|
88
|
+
const stillOurs = subscribers.get(prev);
|
|
89
|
+
if (stillOurs) {
|
|
90
|
+
subscribers.delete(prev);
|
|
91
|
+
observers.get(rootMargin)?.unobserve(prev);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
elRef.current = node;
|
|
96
|
+
if (!node) return;
|
|
97
|
+
|
|
98
|
+
const cb = () => setIsNear(true);
|
|
99
|
+
subscribers.set(node, cb);
|
|
100
|
+
getObserverFor(rootMargin).observe(node);
|
|
101
|
+
},
|
|
102
|
+
[rootMargin]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Unsubscribe on unmount. Identity check guards the StrictMode race.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
return () => {
|
|
108
|
+
const el = elRef.current;
|
|
109
|
+
if (!el) return;
|
|
110
|
+
if (subscribers.get(el)) {
|
|
111
|
+
subscribers.delete(el);
|
|
112
|
+
observers.get(rootMargin)?.unobserve(el);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}, [rootMargin]);
|
|
116
|
+
|
|
117
|
+
return { ref, isNear };
|
|
118
|
+
}
|