/** @jsxImportSource react */ import Hls from "hls.js"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type CSSProperties, type ReactNode } from "react"; import type { MediaSurfaceHandle, MediaSurfaceProps } from "../../../../ui/host"; import { cleanDomProps, commonStyle } from "./style"; function postYoutubeCommand(frame: HTMLIFrameElement | null, func: string, args: unknown[] = []): void { frame?.contentWindow?.postMessage(JSON.stringify({ event: "command", func, args }), "*"); } export const WebMediaSurface = forwardRef(function WebMediaSurface(rawProps, forwardedRef) { const { children, src, title, poster, autoPlay = false, muted = false, mediaHandleRef, onPlaybackStateChange, onMutedChange, onError, ...props } = rawProps as MediaSurfaceProps & { children?: ReactNode; src?: string; title?: string; poster?: string; autoPlay?: boolean; muted?: boolean; mediaHandleRef?: MediaSurfaceProps["mediaHandleRef"]; onPlaybackStateChange?: (state: "idle" | "loading" | "playing" | "paused" | "error") => void; onMutedChange?: (muted: boolean) => void; onError?: (message: string) => void; }; const videoRef = useRef(null); const iframeRef = useRef(null); const youtubeMutedRef = useRef(muted); const youtubePlayingRef = useRef(autoPlay); const [failed, setFailed] = useState(false); const mediaSrc = typeof src === "string" ? src.trim() : ""; const youtubeEmbed = /^https?:\/\/(?:www\.)?youtube(?:-nocookie)?\.com\/embed\//.test(mediaSrc); const baseStyle = commonStyle(props); useImperativeHandle(forwardedRef, () => videoRef.current as HTMLVideoElement, []); useImperativeHandle(mediaHandleRef, (): MediaSurfaceHandle => ({ async play() { if (youtubeEmbed) { postYoutubeCommand(iframeRef.current, "playVideo"); youtubePlayingRef.current = true; onPlaybackStateChange?.("playing"); return; } if (videoRef.current) await videoRef.current.play(); }, pause() { if (youtubeEmbed) { postYoutubeCommand(iframeRef.current, "pauseVideo"); youtubePlayingRef.current = false; onPlaybackStateChange?.("paused"); return; } videoRef.current?.pause(); }, async toggle() { if (youtubeEmbed) { if (youtubePlayingRef.current) { postYoutubeCommand(iframeRef.current, "pauseVideo"); youtubePlayingRef.current = false; onPlaybackStateChange?.("paused"); } else { postYoutubeCommand(iframeRef.current, "playVideo"); youtubePlayingRef.current = true; onPlaybackStateChange?.("playing"); } return; } const video = videoRef.current; if (!video) return; if (video.paused) { await video.play(); } else { video.pause(); } }, toggleMuted() { if (youtubeEmbed) { youtubeMutedRef.current = !youtubeMutedRef.current; postYoutubeCommand(iframeRef.current, youtubeMutedRef.current ? "mute" : "unMute"); onMutedChange?.(youtubeMutedRef.current); return youtubeMutedRef.current; } const video = videoRef.current; if (!video) return muted; video.muted = !video.muted; return video.muted; }, }), [muted, onMutedChange, onPlaybackStateChange, youtubeEmbed]); // Keys typed into a cross-origin frame never reach the app, so a click on // the player would take Tab, the command bar and every pane key with it. // Its controls work by pointer without focus; hand the keyboard back. useEffect(() => { if (!youtubeEmbed) return; let timer: ReturnType | null = null; const releaseFrameFocus = () => { timer = null; const frame = iframeRef.current; if (!frame || document.activeElement !== frame) return; frame.blur(); if (document.activeElement === frame) window.focus(); }; // The window blurs as the frame takes focus, on the press; the release // waits a task so the frame's own focus handling has run. const handleWindowBlur = () => { if (timer !== null) clearTimeout(timer); timer = setTimeout(releaseFrameFocus, 0); }; window.addEventListener("blur", handleWindowBlur); return () => { window.removeEventListener("blur", handleWindowBlur); if (timer !== null) clearTimeout(timer); }; }, [youtubeEmbed]); useEffect(() => { youtubeMutedRef.current = muted; if (youtubeEmbed) { setFailed(false); onPlaybackStateChange?.(autoPlay ? "playing" : "paused"); onMutedChange?.(muted); return; } const video = videoRef.current; if (!video || !mediaSrc) { onPlaybackStateChange?.("idle"); return; } setFailed(false); onPlaybackStateChange?.("loading"); let hls: Hls | null = null; let active = true; const fail = (message: string) => { if (!active) return; setFailed(true); onPlaybackStateChange?.("error"); onError?.(message); }; const handlePlaying = () => onPlaybackStateChange?.("playing"); const handlePause = () => onPlaybackStateChange?.("paused"); const handleWaiting = () => onPlaybackStateChange?.("loading"); const handleVolumeChange = () => onMutedChange?.(video.muted); const handleError = () => fail("The live stream could not be played."); video.addEventListener("playing", handlePlaying); video.addEventListener("pause", handlePause); video.addEventListener("waiting", handleWaiting); video.addEventListener("volumechange", handleVolumeChange); video.addEventListener("error", handleError); video.defaultMuted = muted; video.muted = muted; onMutedChange?.(video.muted); const startPlayback = () => { if (!autoPlay) return; void video.play().catch(() => { if (active) onPlaybackStateChange?.("paused"); }); }; if (video.canPlayType("application/vnd.apple.mpegurl")) { video.src = mediaSrc; video.load(); startPlayback(); } else if (Hls.isSupported()) { hls = new Hls({ backBufferLength: 30, enableWorker: true, lowLatencyMode: true, // A pane is a fraction of the screen, so the top rendition would be // decoded and then scaled away. Re-evaluated when the pane is resized. capLevelToPlayerSize: true, }); hls.on(Hls.Events.ERROR, (_event, data) => { if (!data.fatal) return; fail(data.details ? `Stream playback failed: ${data.details}` : "Stream playback failed."); }); hls.on(Hls.Events.MANIFEST_PARSED, startPlayback); hls.loadSource(mediaSrc); hls.attachMedia(video); } else { fail("HLS playback is unavailable in this desktop runtime."); } return () => { active = false; hls?.destroy(); video.removeEventListener("playing", handlePlaying); video.removeEventListener("pause", handlePause); video.removeEventListener("waiting", handleWaiting); video.removeEventListener("volumechange", handleVolumeChange); video.removeEventListener("error", handleError); video.pause(); video.removeAttribute("src"); video.load(); }; }, [autoPlay, mediaSrc, onError, onMutedChange, onPlaybackStateChange, youtubeEmbed]); return (
{mediaSrc && !failed && youtubeEmbed ? (