From bbd4ccf30cf295e651bcd7103ea89e3f871274a0 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 9 Apr 2026 11:54:57 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=99=20Stage=201=20Youtube=20broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/images/toolbar/icons/youtube.svg | 4 + src/components/toolbar/ToolbarView.tsx | 8 + src/components/toolbar/YouTubePlayerView.tsx | 748 ++++++++++++++++++ src/css/icons/icons.css | 7 + .../furniture/useFurnitureYoutubeWidget.ts | 2 +- 5 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 src/assets/images/toolbar/icons/youtube.svg create mode 100644 src/components/toolbar/YouTubePlayerView.tsx diff --git a/src/assets/images/toolbar/icons/youtube.svg b/src/assets/images/toolbar/icons/youtube.svg new file mode 100644 index 0000000..fea9d48 --- /dev/null +++ b/src/assets/images/toolbar/icons/youtube.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 3f8e76c..1788b76 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -6,6 +6,7 @@ import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; import { useAchievements, useFriends, useInventoryUnseenTracker, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo } from '../../hooks'; import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarMeView } from './ToolbarMeView'; +import { YouTubePlayerView } from './YouTubePlayerView'; export const ToolbarView: FC<{ isInRoom: boolean }> = props => { @@ -19,6 +20,11 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const isMod = GetSessionDataManager().isModerator; + const openYouTubePlayer = () => + { + window.dispatchEvent(new CustomEvent('youtube:toggle')); + }; + useMessageEvent(PerkAllowancesMessageEvent, event => { setUseGuideTool(event.getParser().isAllowed(PerkEnum.USE_GUIDE_TOOL)); @@ -65,6 +71,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => return ( <> + { isMeExpanded && ( )} @@ -94,6 +101,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { isInRoom && CreateLinkEvent('camera/toggle') } /> } + { isMod && CreateLinkEvent('mod-tools/toggle') } /> } { isMod && diff --git a/src/components/toolbar/YouTubePlayerView.tsx b/src/components/toolbar/YouTubePlayerView.tsx new file mode 100644 index 0000000..b06bc49 --- /dev/null +++ b/src/components/toolbar/YouTubePlayerView.tsx @@ -0,0 +1,748 @@ +import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from "@nitrots/nitro-renderer"; +import { FC, useEffect, useRef, useState } from "react"; +import YouTube from "react-youtube"; +import { + GetRoomSession, + GetSessionDataManager, + SendMessageComposer, + YoutubeVideoPlaybackStateEnum, +} from "../../api"; +import { + NitroCardContentView, + NitroCardHeaderView, + NitroCardView, +} 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 }[] + >([]); + // Room broadcast state: set when someone broadcasts a video to the room + const [broadcastVideo, setBroadcastVideo] = useState(""); + const [broadcastSender, setBroadcastSender] = useState(""); + const [broadcastPlaylist, setBroadcastPlaylist] = useState([]); + const [watcherIds, setWatcherIds] = useState>(new Set()); + + // Listen for room-wide YouTube broadcast from the server + useMessageEvent(YouTubeRoomBroadcastEvent, event => { + const parser = event.getParser(); + setBroadcastVideo(parser.videoId); + setBroadcastSender(parser.senderName); + setBroadcastPlaylist(parser.playlist); + // Auto-open the player and load the broadcast video + if (parser.videoId) { + setInputValue(parser.videoId); + setIsOpen(true); + setTab("player"); + } + }); + + // Listen for updated watcher list from the server + useMessageEvent(YouTubeRoomWatchersEvent, event => { + setWatcherIds(new Set(event.getParser().watcherIds)); + loadRoomUsers(); // refresh spectator list so we can mark watchers + }); + + // Notify server when we open/close the YouTube player + useEffect(() => { + if (isOpen) { + try { SendMessageComposer(new YouTubeRoomWatchingComposer(true)); } catch(e) {} + } + return () => { + try { SendMessageComposer(new YouTubeRoomWatchingComposer(false)); } catch(e) {} + }; + }, [isOpen]); + + // Enumerate room users via the session's userDataManager. Uses the + // same brute-force index scan that the old FurnitureYoutubeDisplayView + // used (and which worked). The fancier GetRoomEngine().getRoomObjects() + // approach doesn't reliably return objects when called from the toolbar + // context (outside the room widget tree). + const loadRoomUsers = () => { + try { + const roomSession = GetRoomSession(); + if (!roomSession) { setSpectators([]); return; } + const users: { id: number; name: string; look: string }[] = []; + for (let i = 0; i < 500; i++) { + const userData = roomSession.userDataManager.getUserDataByIndex(i); + if (userData && userData.name && userData.type === 1) { + users.push({ id: userData.userId, name: userData.name, look: userData.figure }); + } + } + setSpectators(users); + } catch (e) { + setSpectators([]); + } + }; + + // Load room users when the player opens so the spectators count + // is visible on the tab button immediately. + useEffect(() => { + if (isOpen) loadRoomUsers(); + }, [isOpen]); + + useEffect(() => { + if (youtubeObjectId && youtubeObjectId !== -1) { + setIsRoomMode(true); + if (roomVideoId) { + setInputValue(roomVideoId); + } + } else { + setIsRoomMode(false); + } + }, [youtubeObjectId, roomVideoId]); + + useEffect(() => { + // Hold the same handler reference for both add and remove. Using a + // fresh arrow function in the cleanup is a no-op because + // removeEventListener requires reference equality; every mount + // would otherwise leak a permanent listener on window. + 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)) { + // Accept both legacy {id,title,...} objects and plain string[] + 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 isMyRoom = GetSessionDataManager().isModerator || hasControl; + + const QuickVolumeButton = ({ + value, + label, + }: { + value: number; + label: string; + }) => ( + + ); + + return ( + + setIsOpen(false)} + /> + +
+ + + + + {spectators.length > 0 && ( + + )} + +
+ + {tab === "player" && ( + <> + {isRoomMode && ( +
+ + πŸ“Ί Verbonden met YouTube TV + +
+ {isPlaying && ( + + β–Ά Speelt + + )} + {isPaused && ( + + ⏸ Gepauzeerd + + )} + {isMyRoom && ( + + βœ“ Jij bent eigenaar + + )} +
+
+ )} + + {videoId ? ( + { + playerRef.current = e.target; + addToHistory(videoId); + }} + /> + ) : ( +
+ Geen video geladen +
+ )} + + {isRoomMode && hasControl && ( +
+ + + +
+ )} + + {broadcastVideo && broadcastSender && ( +
+ πŸ“‘ {broadcastSender} speelt voor de kamer +
+ )} + +
+ 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 of video ID" + /> + {isMyRoom && videoId && ( + + )} +
+ + )} + + {tab === "playlist" && ( +
+
+ setInputValue(e.target.value)} + placeholder="Video URL toevoegen..." + className="flex-1 p-2 bg-gray-700 text-white rounded text-sm" + onKeyDown={(e) => + e.key === "Enter" && addToPlaylist() + } + /> + +
+
+ + +
+ {playlist.length === 0 ? ( +
+ Playlist is leeg +
+ ) : ( +
+ {playlist.map((id, i) => ( +
{ + setInputValue(id); + setTab("player"); + }} + > + + {i + 1}. + +
+ {id} +
+ +
+ ))} +
+ )} +
+ )} + + {tab === "history" && ( +
+
+
+ πŸ• Bekeken video's ({history.length}) +
+ +
+ {history.length === 0 ? ( +
+ Nog geen video's bekeken +
+ ) : ( +
+ {history.map((id, i) => ( +
{ + setInputValue(id); + setTab("player"); + }} + > +
+ {id} +
+
+ ))} +
+ )} +
+ )} + + {tab === "share" && ( +
+
+
+ πŸ“€ Video delen +
+ {videoId ? ( +
+
+ + +
+
+ ) : ( +
+ Selecteer eerst een video om te delen +
+ )} +
+
+
+ πŸ“‹ Snel delen +
+
+ + +
+
+
+ )} + + {tab === "spectators" && ( +
+
+
+ πŸ‘οΈ Gebruikers in kamer ({spectators.length}) +
+ +
+ {spectators.length === 0 ? ( +
+ Geen gebruikers in deze kamer +
+ ) : ( +
+ {spectators.map((user) => ( +
+ {user.name} { + ( + e.target as HTMLImageElement + ).src = + "data:image/svg+xml,"; + }} + /> + + {user.name} + + {watcherIds.has(user.id) && ( + πŸ“Ί + )} +
+ ))} +
+ )} +
+ )} + + {tab === "settings" && ( +
+
+
+ + +
+ {showVolumeSlider && ( + { + setVolume(parseInt(e.target.value)); + setVolumePreset( + parseInt(e.target.value), + ); + }} + className="w-full" + /> + )} +
+ + + + + +
+
+ +
+ + + +
+ +
+
ℹ️ Info
+
+ Room Mode:{" "} + {isRoomMode ? "βœ“ Actief" : "βœ• Niet actief"} +
+
+ Controle:{" "} + {hasControl + ? "βœ“ Je hebt controle" + : "βœ• Alleen kijken"} +
+
+
+ )} +
+
+ ); +}; diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 2feaedf..7e2fcc7 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -172,6 +172,13 @@ height: 45px; } +.nitro-icon.icon-youtube { + background-image: url("@/assets/images/toolbar/icons/youtube.svg"); + background-size: contain; + width: 36px; + height: 36px; +} + .nitro-icon.icon-message { background-image: url("@/assets/images/toolbar/icons/message.png"); width: 36px; diff --git a/src/hooks/rooms/widgets/furniture/useFurnitureYoutubeWidget.ts b/src/hooks/rooms/widgets/furniture/useFurnitureYoutubeWidget.ts index 0069063..b24470d 100644 --- a/src/hooks/rooms/widgets/furniture/useFurnitureYoutubeWidget.ts +++ b/src/hooks/rooms/widgets/furniture/useFurnitureYoutubeWidget.ts @@ -127,7 +127,7 @@ const useFurnitureYoutubeWidgetState = () => onClose(); }); - return { objectId, videoId, videoStart, videoEnd, currentVideoState, selectedVideo, playlists, onClose, previous, next, pause, play, selectVideo }; + return { objectId, videoId, videoStart, videoEnd, currentVideoState, selectedVideo, playlists, hasControl, onClose, previous, next, pause, play, selectVideo }; }; export const useFurnitureYoutubeWidget = useFurnitureYoutubeWidgetState;