import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from "@nitrots/nitro-renderer"; import { FC, useEffect, useRef, useState } from "react"; import ReactPlayer from "react-player/youtube"; import { GetRoomSession, getYoutubeRoomEnabled, GetSessionDataManager, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from "../../api"; import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from "../../common"; import { useFurnitureYoutubeWidget, useMessageEvent } from "../../hooks"; const CONTROL_COMMAND_PREVIOUS_VIDEO = 0; const CONTROL_COMMAND_NEXT_VIDEO = 1; const CONTROL_COMMAND_PAUSE_VIDEO = 2; const CONTROL_COMMAND_CONTINUE_VIDEO = 3; const extractVideoId = (input: string): string => { const patterns = [ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, /^([a-zA-Z0-9_-]{11})$/, ]; for (const pattern of patterns) { const match = input.match(pattern); if (match) return match[1]; } return input; }; export const YouTubePlayerView: FC<{}> = () => { const [isOpen, setIsOpen] = useState(false); const [tab, setTab] = useState< | "player" | "playlist" | "spectators" | "settings" | "history" | "share" >("player"); const [inputValue, setInputValue] = useState(""); const [isRoomMode, setIsRoomMode] = useState(false); const [volume, setVolume] = useState(100); const [isMuted, setIsMuted] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [isLooping, setIsLooping] = useState(false); const [volumePreset, setVolumePreset] = useState(100); const [playlist, setPlaylist] = useState([]); const [history, setHistory] = useState([]); const [showVolumeSlider, setShowVolumeSlider] = useState(true); const playerRef = useRef(null); const { objectId: youtubeObjectId, videoId: roomVideoId, currentVideoState, hasControl } = useFurnitureYoutubeWidget(); const [spectators, setSpectators] = useState< { id: number; name: string; look: string }[] >([]); const [broadcastVideo, setBroadcastVideo] = useState(""); const [broadcastSender, setBroadcastSender] = useState(""); const [broadcastPlaylist, setBroadcastPlaylist] = useState([]); const [watcherIds, setWatcherIds] = useState>(new Set()); const [youtubeEnabled, setYoutubeEnabled] = useState(getYoutubeRoomEnabled()); useMessageEvent(YouTubeRoomSettingsEvent, event => { setYoutubeEnabled(event.getParser().youtubeEnabled); }); useMessageEvent(YouTubeRoomBroadcastEvent, event => { const parser = event.getParser(); setBroadcastVideo(parser.videoId); setBroadcastSender(parser.senderName); setBroadcastPlaylist(parser.playlist); if (parser.videoId) { setInputValue(parser.videoId); setIsOpen(true); setTab("player"); } else { setInputValue(""); setBroadcastVideo(""); setBroadcastSender(""); setBroadcastPlaylist([]); } }); useMessageEvent(YouTubeRoomWatchersEvent, event => { setWatcherIds(new Set(event.getParser().watcherIds)); loadRoomUsers(); }); const sentWatchingRef = useRef(false); const hasVideo = !!(inputValue && extractVideoId(inputValue)); useEffect(() => { if (isOpen && hasVideo && !sentWatchingRef.current) { try { SendMessageComposer(new YouTubeRoomWatchingComposer(true)); } catch(e) {} sentWatchingRef.current = true; } else if ((!isOpen || !hasVideo) && sentWatchingRef.current) { try { SendMessageComposer(new YouTubeRoomWatchingComposer(false)); } catch(e) {} sentWatchingRef.current = false; } }, [isOpen, hasVideo]); const loadRoomUsers = () => { try { const roomSession = GetRoomSession(); if (!roomSession) { setSpectators([]); return; } const users: { id: number; name: string; look: string }[] = []; const seen = new Set(); for (let i = 0; i < 500; i++) { const userData = roomSession.userDataManager.getUserDataByIndex(i); if (userData && userData.name && userData.type === 1 && !seen.has(userData.userId)) { seen.add(userData.userId); users.push({ id: userData.userId, name: userData.name, look: userData.figure }); } } setSpectators(users); } catch (e) { setSpectators([]); } }; useEffect(() => { if (isOpen) loadRoomUsers(); }, [isOpen]); useEffect(() => { if (youtubeObjectId && youtubeObjectId !== -1) { setIsRoomMode(true); if (roomVideoId) { setInputValue(roomVideoId); } } else { setIsRoomMode(false); } }, [youtubeObjectId, roomVideoId]); useEffect(() => { const handler = () => setIsOpen((p) => !p); window.addEventListener("youtube:toggle", handler); return () => window.removeEventListener("youtube:toggle", handler); }, []); useEffect(() => { const savedHistory = localStorage.getItem("youtube_history"); if (savedHistory) { try { const parsed = JSON.parse(savedHistory); if (Array.isArray(parsed)) { setHistory(parsed.map((entry: any) => typeof entry === "string" ? entry : entry?.id).filter(Boolean)); } } catch (e) {} } const savedPlaylist = localStorage.getItem("youtube_playlist"); if (savedPlaylist) { try { const parsed = JSON.parse(savedPlaylist); if (Array.isArray(parsed)) { setPlaylist(parsed.map((entry: any) => typeof entry === "string" ? entry : entry?.id).filter(Boolean)); } } catch (e) {} } }, []); useEffect(() => { localStorage.setItem( "youtube_history", JSON.stringify(history.slice(0, 50)), ); }, [history]); useEffect(() => { localStorage.setItem("youtube_playlist", JSON.stringify(playlist)); }, [playlist]); const addToHistory = (id: string) => { if (!id) return; setHistory((prev) => { const filtered = prev.filter((v) => v !== id); return [id, ...filtered].slice(0, 50); }); }; const handlePlay = () => isRoomMode && youtubeObjectId && hasControl && SendMessageComposer( new ControlYoutubeDisplayPlaybackMessageComposer( youtubeObjectId, CONTROL_COMMAND_CONTINUE_VIDEO, ), ); const handlePause = () => isRoomMode && youtubeObjectId && hasControl && SendMessageComposer( new ControlYoutubeDisplayPlaybackMessageComposer( youtubeObjectId, CONTROL_COMMAND_PAUSE_VIDEO, ), ); const handlePrev = () => isRoomMode && youtubeObjectId && hasControl && SendMessageComposer( new ControlYoutubeDisplayPlaybackMessageComposer( youtubeObjectId, CONTROL_COMMAND_PREVIOUS_VIDEO, ), ); const handleNext = () => isRoomMode && youtubeObjectId && hasControl && SendMessageComposer( new ControlYoutubeDisplayPlaybackMessageComposer( youtubeObjectId, CONTROL_COMMAND_NEXT_VIDEO, ), ); const addToPlaylist = () => { const id = extractVideoId(inputValue); if (id && !playlist.includes(id)) { setPlaylist((p) => [...p, id]); } }; if (!isOpen) return null; const videoId = extractVideoId(inputValue); const isPlaying = currentVideoState === YoutubeVideoPlaybackStateEnum.PLAYING; const isPaused = currentVideoState === YoutubeVideoPlaybackStateEnum.PAUSED; const roomSession = GetRoomSession(); const isMyRoom = GetSessionDataManager().isModerator || (roomSession && roomSession.isRoomOwner); const QuickVolumeButton = ({ value, label, }: { value: number; label: string; }) => ( ); return ( setIsOpen(false)} />
{watcherIds.size > 0 && ( )}
{tab === "player" && ( <> {isRoomMode && (
đŸ“ē Connected with YouTube TV
{isPlaying && ( â–ļ { LocalizeText('connection.login.play') } )} {isPaused && ( ⏸ { LocalizeText('wiredfurni.params.clock_control.3') } )} {isMyRoom && ( ✓ { LocalizeText('navigator.filter.owner') } )}
)} {videoId ? ( { playerRef.current = ref; }} url={`https://www.youtube.com/watch?v=${videoId}`} width="100%" height={isFullscreen ? "100%" : 280} playing muted={isMuted} loop={isLooping} volume={Math.max(0, Math.min(1, volume / 100))} config={{ playerVars: { autoplay: 1, loop: isLooping ? 1 : 0, }, }} onReady={() => addToHistory(videoId)} /> ) : (
{ LocalizeText('widget.furni.video_viewer.no_videos') }
)} {isRoomMode && hasControl && (
)} {broadcastVideo && broadcastSender && (
📡 {broadcastSender} broadcasting {isMyRoom && ( )}
)}
setInputValue(e.target.value)} disabled={!!broadcastVideo && !isMyRoom} className={`flex-1 p-2 rounded text-white text-sm ${(!!broadcastVideo && !isMyRoom) ? "bg-gray-800" : "bg-gray-700"}`} placeholder="YouTube URL / video ID" /> {isMyRoom && youtubeEnabled && videoId && ( )}
)} {tab === "playlist" && (
setInputValue(e.target.value)} placeholder="Add video URL..." className="flex-1 p-2 bg-gray-700 text-white rounded text-sm" onKeyDown={(e) => e.key === "Enter" && addToPlaylist() } />
{playlist.length === 0 ? (
Playlist is empty
) : (
{playlist.map((id, i) => (
{ setInputValue(id); setTab("player"); }} > {i + 1}.
{id}
))}
)}
)} {tab === "history" && (
🕐 Watch history ({history.length})
{history.length === 0 ? (
No videos watched yet
) : (
{history.map((id, i) => (
{ setInputValue(id); setTab("player"); }} >
{id}
))}
)}
)} {tab === "share" && (
📤 Share video
{videoId ? (
) : (
Select a video first to share
)}
📋 Quick share
)} {tab === "spectators" && (() => { const watchers: { id: number; name: string; look: string }[] = []; const rs = GetRoomSession(); if (rs) { for (const uid of watcherIds) { const ud = rs.userDataManager.getUserData(uid); if (ud && ud.name) { watchers.push({ id: ud.userId, name: ud.name, look: ud.figure }); } } } return (
đŸ“ē {watchers.length} watching
{watchers.length === 0 ? (
No one is watching
) : (
{watchers.map((user) => (
{user.name} đŸ“ē
))}
)}
); })()} {tab === "settings" && (
{showVolumeSlider && ( { setVolume(parseInt(e.target.value)); setVolumePreset( parseInt(e.target.value), ); }} className="w-full" /> )}
â„šī¸ Info
📡 Broadcast:{" "} {broadcastVideo ? ✓ Active ({broadcastSender} playing) : ✕ No video}
🎮 Controle:{" "} {isMyRoom ? ✓ You are the owner : ✕ Viewing only}
đŸ‘ī¸ Viewers:{" "} {watcherIds.size}
)}
); };