@flamingo-stack/openframe-frontend-core 0.0.178 → 0.0.179-snapshot.20260514181702

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/{chunk-AAX27BCR.js → chunk-DV2GT7RI.js} +3703 -4168
  2. package/dist/chunk-DV2GT7RI.js.map +1 -0
  3. package/dist/{chunk-L4T24AN4.cjs → chunk-JFGORTXV.cjs} +868 -1333
  4. package/dist/chunk-JFGORTXV.cjs.map +1 -0
  5. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  6. package/dist/components/features/entity-video-section.d.ts +54 -0
  7. package/dist/components/features/entity-video-section.d.ts.map +1 -0
  8. package/dist/components/features/index.cjs +18 -2
  9. package/dist/components/features/index.cjs.map +1 -1
  10. package/dist/components/features/index.d.ts +4 -2
  11. package/dist/components/features/index.d.ts.map +1 -1
  12. package/dist/components/features/index.js +21 -5
  13. package/dist/components/features/video-bites-display.d.ts +38 -0
  14. package/dist/components/features/video-bites-display.d.ts.map +1 -0
  15. package/dist/components/features/video-ratio-tabs.d.ts +62 -0
  16. package/dist/components/features/video-ratio-tabs.d.ts.map +1 -0
  17. package/dist/components/features/video.d.ts +94 -0
  18. package/dist/components/features/video.d.ts.map +1 -0
  19. package/dist/components/index.cjs +18 -2
  20. package/dist/components/index.cjs.map +1 -1
  21. package/dist/components/index.js +21 -5
  22. package/dist/components/media-carousel.d.ts.map +1 -1
  23. package/dist/components/navigation/index.cjs +2 -2
  24. package/dist/components/navigation/index.js +1 -1
  25. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  26. package/dist/components/ui/index.cjs +2 -2
  27. package/dist/components/ui/index.js +1 -1
  28. package/dist/index.cjs +18 -2
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.js +21 -5
  31. package/package.json +2 -2
  32. package/src/components/chat/chat-message-list.tsx +62 -18
  33. package/src/components/features/entity-video-section.tsx +175 -0
  34. package/src/components/features/index.ts +9 -2
  35. package/src/components/features/video-bites-display.tsx +216 -0
  36. package/src/components/features/video-ratio-tabs.tsx +174 -0
  37. package/src/components/features/video.tsx +474 -0
  38. package/src/components/media-carousel.tsx +43 -236
  39. package/src/components/shared/product-release/release-detail-page.tsx +26 -19
  40. package/dist/chunk-AAX27BCR.js.map +0 -1
  41. package/dist/chunk-L4T24AN4.cjs.map +0 -1
  42. package/dist/components/features/video-player.d.ts +0 -44
  43. package/dist/components/features/video-player.d.ts.map +0 -1
  44. package/dist/components/features/youtube-embed.d.ts +0 -31
  45. package/dist/components/features/youtube-embed.d.ts.map +0 -1
  46. package/dist/utils/lite-youtube-embed-stub.d.ts +0 -8
  47. package/dist/utils/lite-youtube-embed-stub.d.ts.map +0 -1
  48. package/dist/utils/lite-youtube-embed.d.ts +0 -9
  49. package/dist/utils/lite-youtube-embed.d.ts.map +0 -1
  50. package/src/components/features/.video-player.md +0 -44
  51. package/src/components/features/.youtube-embed.md +0 -40
  52. package/src/components/features/video-player.tsx +0 -893
  53. package/src/components/features/youtube-embed.tsx +0 -158
  54. package/src/utils/lite-youtube-embed-stub.tsx +0 -21
  55. 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 { LiteYouTubeEmbed } from '../utils/lite-youtube-embed-stub';
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
- // Handle video play/pause
136
- const handleVideoClick = useCallback((index: number) => {
137
- const item = media[index];
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
- }, [currentIndex, media.length]);
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
- }, [currentIndex, media.length]);
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
- const renderYouTubeEmbed = (item: MediaItem, index: number) => {
258
- const videoId = getYouTubeVideoId(item.src);
259
- if (!videoId) {
260
- return (
261
- <div className="absolute inset-0 flex items-center justify-center bg-ods-card text-center p-4">
262
- <div>
263
- <p className="text-ods-text-primary text-sm mb-2">Invalid YouTube URL</p>
264
- <a href={item.src} target="_blank" rel="noopener noreferrer" className="text-ods-accent text-sm">
265
- Open Link Directly
266
- </a>
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
- <div className="absolute inset-0 bg-black">
278
- <video
279
- className={`w-full h-full object-${objectFit}`}
280
- poster={item.poster}
281
- preload="metadata"
282
- playsInline
283
- controls={false}
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
- const videoId = getYouTubeVideoId(item.src);
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 { YouTubeEmbed, extractYouTubeId } from '../../features/youtube-embed';
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
- <VideoPlayer url={mediaItem.media_url} useNativeAspectRatio={false} />
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
- {youtubeUrl && (() => {
344
- const videoId = extractYouTubeId(youtubeUrl);
345
- return videoId ? (
346
- <YouTubeEmbed videoId={videoId} title={`${releaseTitle} - Video`} showTitle={false} showMeta={true} />
347
- ) : null;
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
- <div className="flex justify-center w-full">
351
- <div className="w-full max-w-3xl">
352
- <VideoPlayer url={mainVideoUrl} srtContent={release?.srt_content as string | undefined} captionsUrl={release?.captionsUrl as string | undefined} />
353
- </div>
354
- </div>
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
- <div className="flex justify-center w-full">
358
- <div className="w-full max-w-3xl">
359
- <VideoPlayer url={highlightVideoUrl} poster={highlightVideoThumbnail} />
360
- </div>
361
- </div>
364
+ <Video
365
+ url={highlightVideoUrl}
366
+ poster={highlightVideoThumbnail}
367
+ layout="centered"
368
+ />
362
369
  )}
363
370
  </>
364
371
  )}