+ { isDragOverInventory && isDraggingFromActive && (
+
+
+ { LocalizeText('inventory.badges.clearbadge') }
+
+ ) }
columnCount={ 5 }
estimateSize={ 50 }
diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx
new file mode 100644
index 0000000..5d151d3
--- /dev/null
+++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx
@@ -0,0 +1,58 @@
+import { YouTubeRoomSettingsComposer, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
+import { FC, useState } from 'react';
+import { getYoutubeRoomEnabled, IRoomData, LocalizeText, SendMessageComposer, setYoutubeRoomEnabled } from '../../../../api';
+import { useMessageEvent } from '../../../../hooks';
+
+interface NavigatorRoomSettingsMiscTabViewProps
+{
+ roomData: IRoomData;
+}
+
+export const NavigatorRoomSettingsMiscTabView: FC = props =>
+{
+ const { roomData = null } = props;
+ const [ youtubeEnabled, setYoutubeEnabled ] = useState(getYoutubeRoomEnabled());
+ const [ cooldown, setCooldown ] = useState(false);
+
+ useMessageEvent(YouTubeRoomSettingsEvent, event =>
+ {
+ setYoutubeEnabled(event.getParser().youtubeEnabled);
+ });
+
+ const toggleYouTube = (enabled: boolean) =>
+ {
+ if (cooldown) return;
+ setYoutubeEnabled(enabled);
+ setYoutubeRoomEnabled(enabled);
+ SendMessageComposer(new YouTubeRoomSettingsComposer(enabled));
+ setCooldown(true);
+ setTimeout(() => setCooldown(false), 300);
+ };
+
+ return (
+ <>
+
+
{ LocalizeText('product.type.other') }
+
+
+
+
+
+
đē YouTube TV
+
Allow YouTube video broadcasting in this room
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx
index 29b7f85..2db729f 100644
--- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx
+++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx
@@ -7,6 +7,7 @@ import { NavigatorRoomSettingsAccessTabView } from './NavigatorRoomSettingsAcces
import { NavigatorRoomSettingsBasicTabView } from './NavigatorRoomSettingsBasicTabView';
import { NavigatorRoomSettingsModTabView } from './NavigatorRoomSettingsModTabView';
import { NavigatorRoomSettingsRightsTabView } from './NavigatorRoomSettingsRightsTabView';
+import { NavigatorRoomSettingsMiscTabView } from './NavigatorRoomSettingsMiscTabView';
import { NavigatorRoomSettingsVipChatTabView } from './NavigatorRoomSettingsVipChatTabView';
const TABS: string[] = [
@@ -14,7 +15,8 @@ const TABS: string[] = [
'navigator.roomsettings.tab.2',
'navigator.roomsettings.tab.3',
'navigator.roomsettings.tab.4',
- 'navigator.roomsettings.tab.5'
+ 'navigator.roomsettings.tab.5',
+ 'product.type.other'
];
export const NavigatorRoomSettingsView: FC<{}> = props =>
@@ -205,6 +207,8 @@ export const NavigatorRoomSettingsView: FC<{}> = props =>
}
{ (currentTab === TABS[4]) &&
}
+ { (currentTab === TABS[5]) &&
+ }
);
diff --git a/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx b/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx
index f1298a4..f86f0f6 100644
--- a/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx
+++ b/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx
@@ -1,4 +1,5 @@
import { NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
+import { NotificationBadgeReceivedBubbleView } from './NotificationBadgeReceivedBubbleView';
import { NotificationClubGiftBubbleView } from './NotificationClubGiftBubbleView';
import { NotificationDefaultBubbleView } from './NotificationDefaultBubbleView';
@@ -10,6 +11,8 @@ export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => voi
switch(item.notificationType)
{
+ case NotificationBubbleType.BADGE_RECEIVED:
+ return ;
case NotificationBubbleType.CLUBGIFT:
return ;
default:
diff --git a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx
new file mode 100644
index 0000000..8c1154e
--- /dev/null
+++ b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx
@@ -0,0 +1,70 @@
+import { RequestBadgesComposer } from '@nitrots/nitro-renderer';
+import { FC, useEffect } from 'react';
+import { LocalizeText, NotificationBubbleItem, SendMessageComposer } from '../../../../api';
+import { Flex, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common';
+import { useInventoryBadges } from '../../../../hooks';
+
+export interface NotificationBadgeReceivedBubbleViewProps extends LayoutNotificationBubbleViewProps
+{
+ item: NotificationBubbleItem;
+}
+
+export const NotificationBadgeReceivedBubbleView: FC = props =>
+{
+ const { item = null, onClose = null, ...rest } = props;
+ const { badgeCodes = [], toggleBadge = null } = useInventoryBadges();
+
+ useEffect(() =>
+ {
+ if(badgeCodes.length === 0) SendMessageComposer(new RequestBadgesComposer());
+ }, []);
+
+ const handleWear = (event: React.MouseEvent) =>
+ {
+ event.stopPropagation();
+
+ if(item.linkUrl)
+ {
+ toggleBadge(item.linkUrl);
+ }
+
+ if(onClose) onClose();
+ };
+
+ const handleDismiss = (event: React.MouseEvent) =>
+ {
+ event.stopPropagation();
+ if(onClose) onClose();
+ };
+
+ return (
+
+ e.stopPropagation() }>
+
+
+ { item.iconUrl &&
}
+
+
+
+ { item.senderName
+ ? LocalizeText('notifications.text.received.badge', [ 'user_name' ], [ item.senderName ])
+ : LocalizeText('prereg.reward.you.received') }
+
+ { item.message }
+
+
+
+
+
+ { LocalizeText('notifications.button.later') }
+
+
+
+
+ );
+};
diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx
index 6a504d2..267f535 100644
--- a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx
+++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx
@@ -1,6 +1,6 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { FaPlus } from 'react-icons/fa';
-import { LocalizeText } from '../../../../../api';
+import { GetConfigurationValue, LocalizeText } from '../../../../../api';
import { LayoutBadgeImageView } from '../../../../../common';
import { useInventoryBadges } from '../../../../../hooks';
@@ -14,7 +14,7 @@ interface InfoStandBadgeSlotProps
const BadgeMiniPicker: FC<{
onSelect: (badgeCode: string) => void;
onClose: () => void;
- activeBadgeCodes: string[];
+ activeBadgeCodes: (string | null)[];
}> = ({ onSelect, onClose, activeBadgeCodes }) =>
{
const { badgeCodes = [], requestBadges = null } = useInventoryBadges();
@@ -26,7 +26,8 @@ const BadgeMiniPicker: FC<{
if(badgeCodes.length === 0) requestBadges();
}, []);
- const availableBadges = badgeCodes.filter(code => !activeBadgeCodes.includes(code));
+ const activeSet = new Set(activeBadgeCodes.filter(Boolean));
+ const availableBadges = badgeCodes.filter(code => !activeSet.has(code));
const filtered = search.length > 0
? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase()))
: availableBadges;
@@ -78,12 +79,24 @@ const BadgeMiniPicker: FC<{
export const InfoStandBadgeSlotView: FC = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) =>
{
- const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges();
+ const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null, removeBadge = null, requestBadges = null } = useInventoryBadges();
const [ isDragOver, setIsDragOver ] = useState(false);
+ const [ isDragging, setIsDragging ] = useState(false);
+ const [ justDropped, setJustDropped ] = useState(false);
const [ showPicker, setShowPicker ] = useState(false);
- const hookBadge = activeBadgeCodes.length > 0 ? (activeBadgeCodes[slotIndex] ?? null) : null;
- const badgeCode = isOwnUser ? (hookBadge ?? badgeCodeFromProps ?? null) : (badgeCodeFromProps ?? null);
+ const hookInitialized = activeBadgeCodes.length > 0;
+
+ // Load badge data for own user so hook is initialized before any DnD
+ useEffect(() =>
+ {
+ if(isOwnUser && !hookInitialized) requestBadges();
+ }, [ isOwnUser, hookInitialized, requestBadges ]);
+ const hookBadge = hookInitialized ? (activeBadgeCodes[slotIndex] ?? null) : null;
+ // Once hook has data, use ONLY hook data for own user (no stale props fallback)
+ const badgeCode = isOwnUser
+ ? (hookInitialized ? hookBadge : (badgeCodeFromProps ?? null))
+ : (badgeCodeFromProps ?? null);
const onDragStart = useCallback((event: React.DragEvent) =>
{
@@ -91,8 +104,16 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex,
event.dataTransfer.setData('badgeCode', badgeCode);
event.dataTransfer.setData('infostandSlot', slotIndex.toString());
event.dataTransfer.effectAllowed = 'move';
+ setIsDragging(true);
+
+ const badgeUrl = GetConfigurationValue('badge.asset.url').replace('%badgename%', badgeCode);
+ const img = new Image();
+ img.src = badgeUrl;
+ event.dataTransfer.setDragImage(img, 20, 20);
}, [ badgeCode, slotIndex, isOwnUser ]);
+ const onDragEnd = useCallback(() => setIsDragging(false), []);
+
const onDragOver = useCallback((event: React.DragEvent) =>
{
if(!isOwnUser) return;
@@ -124,6 +145,9 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex,
{
setBadgeAtSlot(droppedBadgeCode, slotIndex);
}
+
+ setJustDropped(true);
+ setTimeout(() => setJustDropped(false), 300);
}, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]);
const handleSlotClick = useCallback(() =>
@@ -133,6 +157,13 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex,
setShowPicker(true);
}, [ isOwnUser, badgeCode ]);
+ const handleDoubleClick = useCallback(() =>
+ {
+ if(!isOwnUser || !badgeCode) return;
+
+ removeBadge(badgeCode);
+ }, [ isOwnUser, badgeCode, removeBadge ]);
+
const handlePickerSelect = useCallback((code: string) =>
{
setBadgeAtSlot(code, slotIndex);
@@ -145,15 +176,19 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex,
className={ `flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center transition-all duration-150
${ isOwnUser && badgeCode ? 'cursor-grab active:cursor-grabbing' : '' }
${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' }
- ${ isOwnUser ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' }
- ${ isDragOver ? 'scale-115 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15' : '' }
+ ${ isDragging ? 'opacity-30 scale-90' : '' }
+ ${ isOwnUser && !isDragging ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' }
+ ${ isDragOver ? 'scale-110 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15 animate-pulse-glow' : '' }
+ ${ justDropped ? 'animate-drop-settle' : '' }
${ isOwnUser && !badgeCode ? 'opacity-40 hover:opacity-70 border border-dashed border-white/20 rounded-sm' : '' }` }
draggable={ isOwnUser && !!badgeCode }
+ onDragEnd={ onDragEnd }
onDragLeave={ onDragLeave }
onDragOver={ onDragOver }
onDragStart={ onDragStart }
onDrop={ onDrop }
- onClick={ handleSlotClick }>
+ onClick={ handleSlotClick }
+ onDoubleClick={ handleDoubleClick }>
{ badgeCode
?
: isOwnUser && }
diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx
index 12aca46..91ced43 100644
--- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx
+++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx
@@ -1,5 +1,6 @@
+import React from 'react';
import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomSessionFavoriteGroupUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserFigureUpdateEvent, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
-import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useState } from 'react';
+import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
import { FaPencilAlt, FaTimes } from 'react-icons/fa';
import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api';
import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common';
@@ -56,15 +57,23 @@ export const InfoStandWidgetUserView: FC = props =
useNitroEvent(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => {
if (!avatarInfo || avatarInfo.webID !== event.userId) return;
+ // Deduplicate badges from server
+ const seen = new Set();
+ const dedupedBadges = event.badges.map(code => {
+ if (!code || seen.has(code)) return '';
+ seen.add(code);
+ return code;
+ });
+
const oldBadges = avatarInfo.badges.join('');
- if (oldBadges === event.badges.join('')) return;
+ if (oldBadges === dedupedBadges.join('')) return;
setAvatarInfo(prevValue => {
if (!prevValue) return prevValue;
const newValue = CloneObject(prevValue);
- newValue.badges = event.badges;
+ newValue.badges = dedupedBadges;
return newValue;
});
});
@@ -165,43 +174,38 @@ export const InfoStandWidgetUserView: FC = props =
/>
)}
- { GetConfigurationValue('user.badges.group.slot.enabled', true)
- ? (
- <>
-
-
- 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
- {avatarInfo.groupId > 0 &&
- }
-
-
-
-
-
+ { (() => {
+ const maxSlots = GetConfigurationValue('user.badges.max.slots', 5);
+ const isOwnUser = avatarInfo.type === AvatarInfoUser.OWN_USER;
+ const showGroup = maxSlots <= 5;
+
+ const items: React.ReactNode[] = [];
+ items.push();
+
+ if(showGroup) {
+ items.push(
+ 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
+ {avatarInfo.groupId > 0 && }
-
-
-
-
- >
- )
- : (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )
- }
+ );
+ } else {
+ items.push();
+ }
+
+ const startIdx = showGroup ? 1 : 2;
+ for(let i = startIdx; i < maxSlots; i++) {
+ items.push();
+ }
+
+ const rows: React.ReactNode[][] = [];
+ for(let i = 0; i < items.length; i += 2) {
+ rows.push(items.slice(i, i + 2));
+ }
+
+ return rows.map((row, idx) => (
+ {row}
+ ));
+ })() }
diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx
index 3f8e76c..3e7b98d 100644
--- a/src/components/toolbar/ToolbarView.tsx
+++ b/src/components/toolbar/ToolbarView.tsx
@@ -1,17 +1,19 @@
-import { CreateLinkEvent, Dispose, DropBounce, EaseOut, GetSessionDataManager, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait } from '@nitrots/nitro-renderer';
+import { CreateLinkEvent, Dispose, DropBounce, EaseOut, GetSessionDataManager, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
import { AnimatePresence, motion } from 'framer-motion';
-import { FC, useState } from 'react';
-import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, VisitDesktop } from '../../api';
+import { FC, useEffect, useState } from 'react';
+import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
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 =>
{
const { isInRoom } = props;
const [ isMeExpanded, setMeExpanded ] = useState(false);
const [ useGuideTool, setUseGuideTool ] = useState(false);
+ const [ youtubeEnabled, setYoutubeEnabled ] = useState(false);
const { userFigure = null } = useSessionInfo();
const { getFullCount = 0 } = useInventoryUnseenTracker();
const { getTotalUnseen = 0 } = useAchievements();
@@ -19,6 +21,25 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
const isMod = GetSessionDataManager().isModerator;
+ useMessageEvent
(YouTubeRoomSettingsEvent, event =>
+ {
+ const enabled = event.getParser().youtubeEnabled;
+ setYoutubeEnabled(enabled);
+ setYoutubeRoomEnabled(enabled);
+ });
+
+ useEffect(() => {
+ if (!isInRoom) {
+ setYoutubeEnabled(false);
+ setYoutubeRoomEnabled(false);
+ }
+ }, [isInRoom]);
+
+ const openYouTubePlayer = () =>
+ {
+ window.dispatchEvent(new CustomEvent('youtube:toggle'));
+ };
+
useMessageEvent(PerkAllowancesMessageEvent, event =>
{
setUseGuideTool(event.getParser().isAllowed(PerkEnum.USE_GUIDE_TOOL));
@@ -65,6 +86,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
return (
<>
+ { youtubeEnabled && }
{ isMeExpanded && (
)}
@@ -94,6 +116,8 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ isInRoom &&
CreateLinkEvent('camera/toggle') } /> }
+ { youtubeEnabled &&
+ }
{ 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..029b36f
--- /dev/null
+++ b/src/components/toolbar/YouTubePlayerView.tsx
@@ -0,0 +1,740 @@
+import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from "@nitrots/nitro-renderer";
+import { FC, useEffect, useRef, useState } from "react";
+import YouTube from "react-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 = e.target;
+ 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}
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/css/chat/Chats.css b/src/css/chat/Chats.css
index c6cdde2..84ca763 100644
--- a/src/css/chat/Chats.css
+++ b/src/css/chat/Chats.css
@@ -9,7 +9,6 @@
&.type-0 {
- // normal
.message {
font-weight: 400;
}
@@ -17,7 +16,6 @@
&.type-1 {
- // whisper
.message {
font-weight: 400;
font-style: italic;
@@ -27,7 +25,6 @@
&.type-2 {
- // shout
.message {
font-weight: 700;
}
@@ -1097,4 +1094,4 @@
&.bubble-53 {
background-image: url('@/assets/images/chat/chatbubbles/bubble_53.png');
}
-}
\ No newline at end of file
+}
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/css/index.css b/src/css/index.css
index 760427e..a857640 100644
--- a/src/css/index.css
+++ b/src/css/index.css
@@ -2,6 +2,33 @@
@config "../../tailwind.config.js";
+@theme {
+ --animate-pulse-glow: pulseGlow 1.2s ease-in-out infinite;
+ --animate-pulse-glow-red: pulseGlowRed 1.2s ease-in-out infinite;
+ --animate-drop-settle: dropSettle 0.3s ease-out;
+ --animate-pulse-glow-gold: pulseGlowGold 1.5s ease-in-out infinite;
+}
+
+@keyframes pulseGlow {
+ 0%, 100% { box-shadow: 0 0 6px rgba(59, 130, 246, 0.3); }
+ 50% { box-shadow: 0 0 14px rgba(59, 130, 246, 0.6); }
+}
+
+@keyframes pulseGlowRed {
+ 0%, 100% { box-shadow: 0 0 6px rgba(239, 68, 68, 0.3); }
+ 50% { box-shadow: 0 0 14px rgba(239, 68, 68, 0.6); }
+}
+
+@keyframes dropSettle {
+ 0% { transform: scale(1.15); }
+ 100% { transform: scale(1); }
+}
+
+@keyframes pulseGlowGold {
+ 0%, 100% { box-shadow: 0 0 6px rgba(255, 193, 7, 0.4); }
+ 50% { box-shadow: 0 0 14px rgba(255, 193, 7, 0.7); }
+}
+
@font-face {
font-family: Ubuntu;
src: url("@/assets/webfonts/Ubuntu-C.ttf");
diff --git a/src/css/widgets/FurnitureWidgets.css b/src/css/widgets/FurnitureWidgets.css
index 71eae75..e5f9c4c 100644
--- a/src/css/widgets/FurnitureWidgets.css
+++ b/src/css/widgets/FurnitureWidgets.css
@@ -177,7 +177,6 @@
&.stickie-yellow {
background-image: url('@/assets/images/room-widgets/stickie-widget/stickie-yellow.png');
- //background-position: -191px -184px;
}
&.stickie-green {
diff --git a/src/hooks/inventory/useInventoryBadges.ts b/src/hooks/inventory/useInventoryBadges.ts
index 39e0667..f456c1f 100644
--- a/src/hooks/inventory/useInventoryBadges.ts
+++ b/src/hooks/inventory/useInventoryBadges.ts
@@ -11,19 +11,31 @@ const useInventoryBadgesState = () =>
const [ needsUpdate, setNeedsUpdate ] = useState(true);
const [ badgeCodes, setBadgeCodes ] = useState([]);
const [ badgeIds, setBadgeIds ] = useState