Files
Nitro-V3/src/components/toolbar/YouTubePlayerView.tsx
T
simoleo89 532cb28ca7 feat(hooks): useIsModerator() + migrate 6 component reads
Adds a reactive `useIsModerator()` derived from
`useUserDataSnapshot().securityLevel >= SecurityLevel.MODERATOR`
(mirrors the renderer-side getter at SessionDataManager.ts:684), and
migrates the six React component-body reads of
`GetSessionDataManager().isModerator`:

- ToolbarView (mod-only chat-input button)
- CatalogClassicView, CatalogModernView (admin toggles in catalog
  header)
- ChooserWidgetView (room-object id column visibility)
- YouTubePlayerView (room-control affordance — hook moved above the
  `if (!isOpen) return null` early return so the hook order stays
  stable when the player opens/closes)
- CalendarView (mod-only "open all" affordance)

UX impact: any future promote/demote that flips
SESSION_DATA_UPDATED now re-renders the mod-only UI live, instead of
requiring an F5. Imperative call sites
(AvatarInfoUtilities.populate*, CanManipulateFurniture,
RoomChatHandler) still read the manager directly — they run at click
time, not in a React render, so reactivity has no upside there.

Five of the six call sites are top-level component-body reads (no
early-return interaction). YouTubePlayerView has an
`if (!isOpen) return null` below the hook list, so the hook had to
move ABOVE it; same shape as the recent CatalogPurchaseWidgetView and
CatalogItemGridWidgetView fixes.

Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
209/209.
2026-05-19 18:07:17 +02:00

814 lines
36 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from '../../common';
import { useFurnitureYoutubeWidget, useIsModerator, 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<number>(100);
const [playlist, setPlaylist] = useState<string[]>([]);
const [history, setHistory] = useState<string[]>([]);
const [showVolumeSlider, setShowVolumeSlider] = useState(true);
const playerRef = useRef<ReactPlayer | null>(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<string[]>([]);
const [watcherIds, setWatcherIds] = useState<Set<number>>(new Set());
const [youtubeEnabled, setYoutubeEnabled] = useState(getYoutubeRoomEnabled());
// Reactive — must sit above the `if (!isOpen) return null` below
// so the hook order stays stable across renders.
const isModerator = useIsModerator();
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
{
setYoutubeEnabled(event.getParser().youtubeEnabled);
});
useMessageEvent<YouTubeRoomBroadcastEvent>(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>(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<number>();
for (let i = 0; i < 500; i++)
{
const userData = roomSession.userDataManager.getUserDataByIndex(i);
if (userData && userData.name && userData.type === 1 && !seen.has(userData.webID))
{
seen.add(userData.webID);
users.push({ id: userData.webID, 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 = isModerator || (roomSession && roomSession.isRoomOwner);
const QuickVolumeButton = ({
value,
label,
}: {
value: number;
label: string;
}) => (
<button
onClick={() =>
{
setVolume(value);
setVolumePreset(value);
}}
className={`px-2 py-1 rounded text-xs ${volumePreset === value ? 'bg-amber-600 text-white' : 'bg-gray-700 text-gray-300'}`}
>
{label}
</button>
);
return (
<NitroCardView
className={`youtube-player-modal ${isFullscreen ? '!fixed inset-0 w-full h-full z-[9999] rounded-none' : 'w-[550px]'}`}
>
<NitroCardHeaderView
headerText={isRoomMode ? '📺 YouTube TV' : '▶ YouTube'}
onCloseClick={() => setIsOpen(false)}
/>
<NitroCardContentView>
<div className="flex gap-1 mb-3 border-b border-gray-700 pb-2 flex-wrap">
<button
onClick={() => setTab('player')}
className={`px-3 py-1 rounded text-sm ${tab === 'player' ? 'bg-amber-600 text-white' : 'bg-gray-700 text-gray-300'}`}
>
</button>
<button
onClick={() => setTab('playlist')}
className={`px-3 py-1 rounded text-sm ${tab === 'playlist' ? 'bg-amber-600 text-white' : 'bg-gray-700 text-gray-300'}`}
>
📋 {playlist.length}
</button>
<button
onClick={() => setTab('history')}
className={`px-3 py-1 rounded text-sm ${tab === 'history' ? 'bg-amber-600 text-white' : 'bg-gray-700 text-gray-300'}`}
>
🕐 {history.length}
</button>
<button
onClick={() => setTab('share')}
className={`px-3 py-1 rounded text-sm ${tab === 'share' ? 'bg-amber-600 text-white' : 'bg-gray-700 text-gray-300'}`}
>
📤
</button>
{watcherIds.size > 0 && (
<button
onClick={() =>
{
setTab('spectators'); loadRoomUsers();
}}
className={`px-3 py-1 rounded text-sm ${tab === 'spectators' ? 'bg-amber-600 text-white' : 'bg-gray-700 text-gray-300'}`}
>
📺 {watcherIds.size}
</button>
)}
<button
onClick={() => setTab('settings')}
className={`px-3 py-1 rounded text-sm ${tab === 'settings' ? 'bg-amber-600 text-white' : 'bg-gray-700 text-gray-300'}`}
>
</button>
</div>
{tab === 'player' && (
<>
{isRoomMode && (
<div className="mb-2 p-2 bg-blue-900/50 rounded flex justify-between text-sm">
<span className="text-blue-300">
📺 Connected with YouTube TV
</span>
<div className="flex gap-2">
{isPlaying && (
<span className="text-green-400">
{ LocalizeText('connection.login.play') }
</span>
)}
{isPaused && (
<span className="text-yellow-400">
{ LocalizeText('wiredfurni.params.clock_control.3') }
</span>
)}
{isMyRoom && (
<span className="text-green-400 text-xs">
{ LocalizeText('navigator.filter.owner') }
</span>
)}
</div>
</div>
)}
{videoId ? (
<ReactPlayer
ref={ref =>
{
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)}
/>
) : (
<div className="h-[280px] flex items-center justify-center bg-gray-800 text-gray-500">
{ LocalizeText('widget.furni.video_viewer.no_videos') }
</div>
)}
{isRoomMode && hasControl && (
<div className="mt-2 flex gap-2 justify-center">
<button
onClick={handlePrev}
className="px-3 py-1 bg-gray-700 rounded text-white text-sm"
>
</button>
<button
onClick={
isPlaying ? handlePause : handlePlay
}
className="px-4 py-1 bg-amber-600 rounded text-white font-bold text-sm"
>
{isPlaying ? '⏸' : '▶'}
</button>
<button
onClick={handleNext}
className="px-3 py-1 bg-gray-700 rounded text-white text-sm"
>
</button>
</div>
)}
{broadcastVideo && broadcastSender && (
<div className="mt-2 p-2 bg-purple-900/50 rounded text-sm flex justify-between items-center">
<span className="text-purple-300">📡 {broadcastSender} broadcasting</span>
{isMyRoom && (
<button
onClick={() =>
{
try
{
SendMessageComposer(new YouTubeRoomPlayComposer('', []));
}
catch(e)
{}
setBroadcastVideo('');
setBroadcastSender('');
setBroadcastPlaylist([]);
}}
className="px-2 py-0.5 bg-red-700 hover:bg-red-600 rounded text-white text-xs"
>
{ LocalizeText('useproduct.widget.cancel') }
</button>
)}
</div>
)}
<div className="mt-3 flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => 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 && (
<button
onClick={() =>
{
try
{
SendMessageComposer(new YouTubeRoomPlayComposer(videoId, playlist));
}
catch(e)
{}
}}
className="px-3 bg-purple-600 rounded text-white text-sm whitespace-nowrap"
title="Speel deze video voor iedereen in de kamer"
>
📡 { LocalizeText('wiredchests.logs.type.1') }
</button>
)}
</div>
</>
)}
{tab === 'playlist' && (
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => 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()
}
/>
<button
onClick={addToPlaylist}
className="px-4 bg-purple-600 rounded text-white"
>
+
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => setInputValue('')}
className="flex-1 px-3 py-2 bg-gray-700 rounded text-white text-sm"
>
🔄 New video
</button>
<button
onClick={() => setPlaylist([])}
className="px-3 py-2 bg-red-900 rounded text-white text-sm"
>
🗑 Clear
</button>
</div>
{playlist.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
Playlist is empty
</div>
) : (
<div className="max-h-[250px] overflow-y-auto space-y-1">
{playlist.map((id, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-gray-800 rounded hover:bg-gray-700 cursor-pointer"
onClick={() =>
{
setInputValue(id);
setTab('player');
}}
>
<span className="text-amber-500 text-sm w-6">
{i + 1}.
</span>
<div className="flex-1 min-w-0 text-white text-sm truncate font-mono">
{id}
</div>
<button
onClick={(e) =>
{
e.stopPropagation();
setPlaylist((p) =>
p.filter((x) => x !== id),
);
}}
className="text-red-500 px-2"
>
</button>
</div>
))}
</div>
)}
</div>
)}
{tab === 'history' && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="text-gray-400 text-sm">
🕐 Watch history ({history.length})
</div>
<button
onClick={() => setHistory([])}
className="text-red-400 text-xs hover:text-red-300"
>
🗑 Clear
</button>
</div>
{history.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
No videos watched yet
</div>
) : (
<div className="max-h-[300px] overflow-y-auto space-y-1">
{history.map((id, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-gray-800 rounded hover:bg-gray-700 cursor-pointer"
onClick={() =>
{
setInputValue(id);
setTab('player');
}}
>
<div className="flex-1 min-w-0 text-white text-sm truncate font-mono">
{id}
</div>
</div>
))}
</div>
)}
</div>
)}
{tab === 'share' && (
<div className="space-y-3">
<div className="p-3 bg-gray-800 rounded">
<div className="text-gray-400 text-sm mb-2">
📤 Share video
</div>
{videoId ? (
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={`https://youtube.com/watch?v=${videoId}`}
readOnly
className="flex-1 p-2 bg-gray-700 text-white rounded text-sm"
/>
<button
onClick={() =>
{
navigator.clipboard.writeText(
`https://youtube.com/watch?v=${videoId}`,
);
}}
className="px-3 bg-blue-600 rounded text-white text-sm"
>
📋
</button>
</div>
</div>
) : (
<div className="text-gray-500 text-sm text-center py-4">
Select a video first to share
</div>
)}
</div>
<div className="p-3 bg-gray-800 rounded">
<div className="text-gray-400 text-sm mb-2">
📋 Quick share
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() =>
{
if (videoId)
{
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(
'Now watching: https://youtube.com/watch?v=${videoId}',
)}`;
window.open(url, '_blank');
}
}}
disabled={!videoId}
className="px-3 py-2 bg-blue-600 rounded text-white text-sm disabled:opacity-50"
>
🐦 Twitter
</button>
</div>
</div>
</div>
)}
{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.webID, name: ud.name, look: ud.figure });
}
}
}
return (
<div className="p-3 bg-gray-800 rounded">
<div className="flex justify-between items-center mb-2">
<div className="text-gray-400 text-sm">
📺 {watchers.length} watching
</div>
<button
onClick={loadRoomUsers}
className="text-gray-400 hover:text-white text-xs"
>
🔄
</button>
</div>
{watchers.length === 0 ? (
<div className="text-gray-500 text-sm text-center py-4">
No one is watching
</div>
) : (
<div className="max-h-[200px] overflow-y-auto space-y-1">
{watchers.map((user) => (
<div
key={user.id}
className="flex items-center gap-2 p-2 bg-gray-700 rounded"
>
<div className="shrink-0 overflow-hidden">
<LayoutAvatarImageView figure={user.look} headOnly direction={2} scale={1} className="!w-[45px] !h-[65px] -mt-[5px] -ml-[5px]" />
</div>
<span className="text-white text-sm flex-1">
{user.name}
</span>
<span className="text-amber-400 text-xs">📺</span>
</div>
))}
</div>
)}
</div>
);
})()}
{tab === 'settings' && (
<div className="space-y-3">
<div>
<div className="flex justify-between items-center mb-1">
<label className="text-white text-sm">
🔊 Volume: {volume}%
</label>
<button
onClick={() =>
setShowVolumeSlider(!showVolumeSlider)
}
className="text-gray-400 text-xs"
>
{showVolumeSlider ? '▼' : '▲'}
</button>
</div>
{showVolumeSlider && (
<input
type="range"
min="0"
max="100"
value={volume}
onChange={(e) =>
{
setVolume(parseInt(e.target.value));
setVolumePreset(
parseInt(e.target.value),
);
}}
className="w-full"
/>
)}
<div className="flex gap-1 mt-2">
<QuickVolumeButton value={0} label="🔇" />
<QuickVolumeButton value={25} label="25%" />
<QuickVolumeButton value={50} label="50%" />
<QuickVolumeButton value={75} label="75%" />
<QuickVolumeButton value={100} label="100%" />
</div>
</div>
<div className="flex flex-wrap gap-3">
<label className="flex items-center gap-2 text-white text-sm cursor-pointer">
<input
type="checkbox"
checked={isMuted}
onChange={(e) =>
setIsMuted(e.target.checked)
}
className="w-4 h-4"
/>
🔇 Mute
</label>
<label className="flex items-center gap-2 text-white text-sm cursor-pointer">
<input
type="checkbox"
checked={isLooping}
onChange={(e) =>
setIsLooping(e.target.checked)
}
className="w-4 h-4"
/>
🔁 Loop
</label>
<label className="flex items-center gap-2 text-white text-sm cursor-pointer">
<input
type="checkbox"
checked={isFullscreen}
onChange={(e) =>
setIsFullscreen(e.target.checked)
}
className="w-4 h-4"
/>
🖥 Fullscreen
</label>
</div>
<div className="p-2 bg-gray-800 rounded text-xs text-gray-400">
<div className="font-bold mb-1"> Info</div>
<div>
📡 Broadcast:{' '}
{broadcastVideo
? <span className="text-green-400"> Active ({broadcastSender} playing)</span>
: <span className="text-gray-500"> No video</span>}
</div>
<div>
🎮 Controle:{' '}
{isMyRoom
? <span className="text-green-400"> You are the owner</span>
: <span className="text-gray-500"> Viewing only</span>}
</div>
<div>
👁 Viewers:{' '}
<span className="text-amber-400">{watcherIds.size}</span>
</div>
</div>
</div>
)}
</NitroCardContentView>
</NitroCardView>
);
};