A design system in a box. hip-ui.tngl.io/docs/introduction
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

custom captions renderer

+301 -8
+301 -8
packages/hip-ui/src/components/video/index.tsx
··· 1 1 "use client"; 2 2 3 3 import * as stylex from "@stylexjs/stylex"; 4 + import { useEffect, useRef, useState } from "react"; 4 5 import { 6 + MediaCaptionsButton, 5 7 MediaControlBar, 6 8 MediaController, 7 9 MediaFullscreenButton, ··· 14 16 MediaTimeRange, 15 17 MediaVolumeRange, 16 18 } from "media-chrome/react"; 17 - import { 18 - MediaCaptionsMenu, 19 - MediaCaptionsMenuButton, 20 - } from "media-chrome/react/menu"; 21 19 22 20 import type { StyleXComponentProps } from "../theme/types"; 23 21 ··· 29 27 size, 30 28 verticalSpace, 31 29 } from "../theme/semantic-spacing.stylex"; 30 + import { fontFamily } from "../theme/typography.stylex"; 32 31 33 32 const DEFAULT_SEEK_OFFSET = 10; 33 + const CAPTION_CONTROLS_VISIBLE_OFFSET = `calc(${size.xl} + (${verticalSpace.lg} * 2) + ${verticalSpace["2xl"]})`; 34 + const CAPTION_CONTROLS_HIDDEN_OFFSET = verticalSpace["2xl"]; 34 35 type VideoTrackKind = 35 36 | "captions" 36 37 | "chapters" ··· 91 92 "--media-tooltip-border": `1px solid ${uiColor.border1}`, 92 93 "--media-tooltip-border-radius": radius.xs, 93 94 "--media-tooltip-padding": `${verticalSpace.xs} ${horizontalSpace.md}`, 95 + opacity: { 96 + "[slot='media']::-webkit-media-text-track-display": 0, 97 + }, 98 + transform: { 99 + "[slot='media']::-webkit-media-text-track-display": "translateY(200%)", 100 + }, 94 101 }, 95 102 media: { 96 103 display: "block", ··· 101 108 aspectRatio: (aspectRatio: number) => ({ 102 109 aspectRatio, 103 110 }), 111 + subtitleOverlay: (subtitleOffset: string) => ({ 112 + position: "absolute", 113 + left: horizontalSpace["2xl"], 114 + right: horizontalSpace["2xl"], 115 + bottom: 0, 116 + display: "flex", 117 + justifyContent: "center", 118 + pointerEvents: "none", 119 + transform: `translateY(calc(-1 * ${subtitleOffset}))`, 120 + transitionDelay: "0.05s", 121 + transitionDuration: "0.15s", 122 + transitionProperty: "transform", 123 + transitionTimingFunction: "linear", 124 + zIndex: 1, 125 + }), 126 + subtitleText: { 127 + backgroundColor: "rgba(0, 0, 0, 0.8)", 128 + borderRadius: radius.sm, 129 + color: "white", 130 + fontSize: size.lg, 131 + fontFamily: fontFamily["sans"], 132 + lineHeight: 1.4, 133 + maxWidth: "100%", 134 + paddingLeft: horizontalSpace.lg, 135 + paddingRight: horizontalSpace.lg, 136 + paddingTop: verticalSpace.sm, 137 + paddingBottom: verticalSpace.sm, 138 + textAlign: "center", 139 + whiteSpace: "pre-wrap", 140 + }, 104 141 }); 105 142 106 143 function getMediaTag(src?: string, mediaType?: VideoMediaTag): VideoMediaTag { ··· 269 306 * and otherwise fall back to the native `video` element. 270 307 */ 271 308 mediaType?: VideoMediaTag; 309 + /** 310 + * Vertical spacing for the custom subtitle layer. 311 + * @default CAPTION_CONTROLS_VISIBLE_OFFSET while controls are visible, 312 + * CAPTION_CONTROLS_HIDDEN_OFFSET otherwise 313 + */ 314 + subtitleOffset?: string; 315 + } 316 + 317 + type SubtitleMediaElement = HTMLElement & { 318 + textTracks?: TextTrackList; 319 + }; 320 + 321 + interface UseVideoSubtitlesOptions { 322 + rootRef: React.RefObject<HTMLDivElement | null>; 323 + mediaRef: React.RefObject<SubtitleMediaElement | null>; 324 + src?: string; 325 + subtitleTracks: Array<VideoSubtitleTrack>; 326 + } 327 + 328 + function isSubtitleTrack(track: TextTrack): boolean { 329 + return track.kind === "captions" || track.kind === "subtitles"; 330 + } 331 + 332 + function getActiveCueText(track: TextTrack): string { 333 + const activeCues = track.activeCues; 334 + 335 + if (!activeCues?.length) { 336 + return ""; 337 + } 338 + 339 + return Array.from(activeCues) 340 + .map((cue) => { 341 + if ("text" in cue && typeof cue.text === "string") { 342 + return cue.text.trim(); 343 + } 344 + 345 + return ""; 346 + }) 347 + .filter(Boolean) 348 + .join("\n"); 349 + } 350 + 351 + function useVideoSubtitles({ 352 + rootRef, 353 + mediaRef, 354 + src, 355 + subtitleTracks, 356 + }: UseVideoSubtitlesOptions) { 357 + const hasSubtitleTracks = subtitleTracks.length > 0; 358 + const [activeSubtitleText, setActiveSubtitleText] = useState(""); 359 + const [controlsVisible, setControlsVisible] = useState(true); 360 + const defaultCaptionsEnabled = subtitleTracks.some((track) => track.default); 361 + const [captionsEnabled, setCaptionsEnabled] = useState( 362 + defaultCaptionsEnabled, 363 + ); 364 + const defaultSubtitleTrackIndex = subtitleTracks.findIndex( 365 + (track) => track.default, 366 + ); 367 + const selectedSubtitleTrackIndex = 368 + defaultSubtitleTrackIndex >= 0 ? defaultSubtitleTrackIndex : 0; 369 + 370 + useEffect(() => { 371 + setCaptionsEnabled(hasSubtitleTracks && defaultCaptionsEnabled); 372 + setActiveSubtitleText(""); 373 + }, [defaultCaptionsEnabled, hasSubtitleTracks, src]); 374 + 375 + useEffect(() => { 376 + const captionsButton = rootRef.current?.querySelector( 377 + "media-captions-button", 378 + ); 379 + 380 + if (!captionsButton) { 381 + return; 382 + } 383 + 384 + captionsButton.setAttribute("aria-checked", String(captionsEnabled)); 385 + }, [captionsEnabled, rootRef]); 386 + 387 + useEffect(() => { 388 + const controller = rootRef.current?.querySelector("media-controller"); 389 + 390 + if (!controller || !hasSubtitleTracks) { 391 + return; 392 + } 393 + 394 + const stopNativeSubtitleToggle = (event: Event) => { 395 + event.preventDefault(); 396 + event.stopPropagation(); 397 + event.stopImmediatePropagation(); 398 + setCaptionsEnabled((enabled) => !enabled); 399 + }; 400 + 401 + const showCustomSubtitles = (event: Event) => { 402 + event.preventDefault(); 403 + event.stopPropagation(); 404 + event.stopImmediatePropagation(); 405 + setCaptionsEnabled(true); 406 + }; 407 + 408 + const hideCustomSubtitles = (event: Event) => { 409 + event.preventDefault(); 410 + event.stopPropagation(); 411 + event.stopImmediatePropagation(); 412 + setCaptionsEnabled(false); 413 + }; 414 + 415 + controller.addEventListener( 416 + "mediatogglesubtitlesrequest", 417 + stopNativeSubtitleToggle, 418 + true, 419 + ); 420 + controller.addEventListener( 421 + "mediashowsubtitlesrequest", 422 + showCustomSubtitles, 423 + true, 424 + ); 425 + controller.addEventListener( 426 + "mediadisablesubtitlesrequest", 427 + hideCustomSubtitles, 428 + true, 429 + ); 430 + 431 + return () => { 432 + controller.removeEventListener( 433 + "mediatogglesubtitlesrequest", 434 + stopNativeSubtitleToggle, 435 + true, 436 + ); 437 + controller.removeEventListener( 438 + "mediashowsubtitlesrequest", 439 + showCustomSubtitles, 440 + true, 441 + ); 442 + controller.removeEventListener( 443 + "mediadisablesubtitlesrequest", 444 + hideCustomSubtitles, 445 + true, 446 + ); 447 + }; 448 + }, [hasSubtitleTracks, rootRef]); 449 + 450 + useEffect(() => { 451 + const controller = rootRef.current?.querySelector("media-controller"); 452 + 453 + if (!controller) { 454 + return; 455 + } 456 + 457 + const syncControlsVisibility = () => { 458 + setControlsVisible(!controller.hasAttribute("userinactive")); 459 + }; 460 + 461 + syncControlsVisibility(); 462 + 463 + const observer = new MutationObserver(syncControlsVisibility); 464 + observer.observe(controller, { 465 + attributeFilter: ["userinactive"], 466 + attributes: true, 467 + }); 468 + 469 + return () => { 470 + observer.disconnect(); 471 + }; 472 + }, [rootRef]); 473 + 474 + useEffect(() => { 475 + const media = mediaRef.current; 476 + const textTracks = media?.textTracks; 477 + 478 + if (!textTracks) { 479 + setActiveSubtitleText(""); 480 + return; 481 + } 482 + 483 + const cueListeners = new Map<TextTrack, () => void>(); 484 + 485 + const removeCueListeners = () => { 486 + for (const [track, listener] of cueListeners) { 487 + track.removeEventListener("cuechange", listener); 488 + } 489 + cueListeners.clear(); 490 + }; 491 + 492 + const syncSubtitleState = () => { 493 + const availableSubtitleTracks = 494 + Array.from(textTracks).filter(isSubtitleTrack); 495 + const selectedTrack = 496 + captionsEnabled && 497 + selectedSubtitleTrackIndex < availableSubtitleTracks.length 498 + ? availableSubtitleTracks[selectedSubtitleTrackIndex] 499 + : null; 500 + 501 + for (const track of availableSubtitleTracks) { 502 + track.mode = track === selectedTrack ? "hidden" : "disabled"; 503 + } 504 + 505 + setActiveSubtitleText( 506 + selectedTrack ? getActiveCueText(selectedTrack) : "", 507 + ); 508 + }; 509 + 510 + const bindCueListeners = () => { 511 + removeCueListeners(); 512 + 513 + for (const track of Array.from(textTracks).filter(isSubtitleTrack)) { 514 + track.addEventListener("cuechange", syncSubtitleState); 515 + cueListeners.set(track, syncSubtitleState); 516 + } 517 + }; 518 + 519 + const handleTrackListChange = () => { 520 + bindCueListeners(); 521 + syncSubtitleState(); 522 + }; 523 + 524 + handleTrackListChange(); 525 + 526 + media.addEventListener("loadedmetadata", handleTrackListChange); 527 + textTracks.addEventListener("addtrack", handleTrackListChange); 528 + textTracks.addEventListener("change", syncSubtitleState); 529 + textTracks.addEventListener("removetrack", handleTrackListChange); 530 + 531 + return () => { 532 + media.removeEventListener("loadedmetadata", handleTrackListChange); 533 + textTracks.removeEventListener("addtrack", handleTrackListChange); 534 + textTracks.removeEventListener("change", syncSubtitleState); 535 + textTracks.removeEventListener("removetrack", handleTrackListChange); 536 + removeCueListeners(); 537 + }; 538 + }, [captionsEnabled, mediaRef, selectedSubtitleTrackIndex, src]); 539 + 540 + return { 541 + activeSubtitleText, 542 + controlsVisible, 543 + hasSubtitleTracks, 544 + }; 272 545 } 273 546 274 547 export function Video({ ··· 280 553 aspectRatio = 16 / 9, 281 554 mediaType, 282 555 subtitleTracks = [], 556 + subtitleOffset, 283 557 ...props 284 558 }: VideoProps) { 285 559 const { src, ...videoProps } = props; 286 - const hasSubtitleTracks = subtitleTracks.length > 0; 287 560 const mediaTag = getMediaTag(src, mediaType); 288 561 const MediaTag = mediaTag as unknown as React.ElementType; 562 + const rootRef = useRef<HTMLDivElement | null>(null); 563 + const mediaRef = useRef<SubtitleMediaElement | null>(null); 564 + const { activeSubtitleText, controlsVisible, hasSubtitleTracks } = 565 + useVideoSubtitles({ 566 + rootRef, 567 + mediaRef, 568 + src, 569 + subtitleTracks, 570 + }); 571 + const resolvedSubtitleOffset = 572 + subtitleOffset ?? 573 + (controlsVisible 574 + ? CAPTION_CONTROLS_VISIBLE_OFFSET 575 + : CAPTION_CONTROLS_HIDDEN_OFFSET); 289 576 290 577 return ( 291 578 <div 579 + ref={rootRef} 292 580 {...stylex.props(styles.root, rounded && styles.rounded, ui.bgDim, style)} 293 581 > 294 582 <MediaController ··· 297 585 {/* Caption tracks are app-specific, so the wrapper forwards native video props instead of forcing a track API. */} 298 586 {/* oxlint-disable-next-line jsx_a11y/media-has-caption */} 299 587 <MediaTag 588 + ref={mediaRef} 589 + data-custom-subtitle-renderer 300 590 {...videoProps} 301 591 preload={preload} 302 592 slot="media" ··· 306 596 {subtitleTracks.map((track) => ( 307 597 <track 308 598 key={track.id ?? track.src} 309 - default={track.default} 310 599 kind={track.kind ?? "subtitles"} 311 600 label={track.label} 312 601 src={track.src} ··· 314 603 /> 315 604 ))} 316 605 </MediaTag> 317 - {hasSubtitleTracks ? <MediaCaptionsMenu anchor="auto" hidden /> : null} 318 606 {children ?? ( 319 607 <MediaControlBar> 320 608 <MediaPlayButton /> ··· 324 612 <MediaTimeDisplay showDuration /> 325 613 <MediaMuteButton /> 326 614 <MediaVolumeRange /> 327 - {hasSubtitleTracks ? <MediaCaptionsMenuButton /> : null} 615 + {hasSubtitleTracks ? <MediaCaptionsButton /> : null} 328 616 <MediaPlaybackRateButton /> 329 617 <MediaFullscreenButton /> 330 618 </MediaControlBar> 331 619 )} 332 620 </MediaController> 621 + {activeSubtitleText ? ( 622 + <div {...stylex.props(styles.subtitleOverlay(resolvedSubtitleOffset))}> 623 + <div {...stylex.props(styles.subtitleText)}>{activeSubtitleText}</div> 624 + </div> 625 + ) : null} 333 626 </div> 334 627 ); 335 628 }