Merge branch 'Dev' into feat/wired-fixes-apr08

This commit is contained in:
DuckieTM
2026-04-13 16:58:14 +02:00
committed by GitHub
48 changed files with 1692 additions and 881 deletions
-2
View File
@@ -31,7 +31,6 @@ import { UserProfileView } from './user-profile/UserProfileView';
import { UserSettingsView } from './user-settings/UserSettingsView';
import { WiredView } from './wired/WiredView';
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
import { YoutubeTvView } from './youtube-tv/YoutubeTvView';
export const MainView: FC<{}> = props =>
{
@@ -124,7 +123,6 @@ export const MainView: FC<{}> = props =>
<GameCenterView />
<FloorplanEditorView />
<FurniEditorView />
<YoutubeTvView />
<ExternalPluginLoader />
</>
);
@@ -1,8 +1,10 @@
import { IPartColor } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo, useRef } from 'react';
import { FC, useCallback, useEffect, useMemo, useRef } from 'react';
import { ColorUtils, GetClubMemberLevel, IAvatarEditorCategory } from '../../../api';
import { useAvatarEditor } from '../../../hooks';
const DEBOUNCE_MS = 150;
const findNearestColor = (hex: string, colors: IPartColor[]): IPartColor | null =>
{
const r = parseInt(hex.slice(1, 3), 16);
@@ -34,6 +36,12 @@ export const AvatarEditorAdvancedColorView: FC<{
{
const { selectedColorParts = null, selectEditorColor = null } = useAvatarEditor();
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() =>
{
return () => { if(debounceRef.current) clearTimeout(debounceRef.current); };
}, []);
const selectedColor = useMemo(() =>
{
@@ -52,9 +60,16 @@ export const AvatarEditorAdvancedColorView: FC<{
if(!colors) return;
const nearest = findNearestColor(e.target.value, colors);
const value = e.target.value;
if(nearest) selectEditorColor(category.setType, paletteIndex, nearest.id);
if(debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() =>
{
const nearest = findNearestColor(value, colors);
if(nearest) selectEditorColor(category.setType, paletteIndex, nearest.id);
}, DEBOUNCE_MS);
}, [ category, paletteIndex, selectEditorColor ]);
return (
@@ -1,5 +1,5 @@
import { FC, PropsWithChildren } from 'react';
import { UnseenItemCategory } from '../../../../api';
import { FC, PropsWithChildren, useState } from 'react';
import { GetConfigurationValue, UnseenItemCategory } from '../../../../api';
import { LayoutBadgeImageView } from '../../../../common';
import { useInventoryBadges, useInventoryUnseenTracker } from '../../../../hooks';
import { InfiniteGrid } from '../../../../layout';
@@ -10,20 +10,31 @@ export const InventoryBadgeItemView: FC<PropsWithChildren<{ badgeCode: string }>
const { selectedBadgeCode = null, setSelectedBadgeCode = null, toggleBadge = null, getBadgeId = null } = useInventoryBadges();
const { isUnseen = null } = useInventoryUnseenTracker();
const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode));
const [ isDragging, setIsDragging ] = useState(false);
const onDragStart = (event: React.DragEvent<HTMLDivElement>) =>
{
event.dataTransfer.setData('badgeCode', badgeCode);
event.dataTransfer.setData('source', 'inventory');
event.dataTransfer.effectAllowed = 'move';
setIsDragging(true);
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode);
const img = new Image();
img.src = badgeUrl;
event.dataTransfer.setDragImage(img, 20, 20);
};
const onDragEnd = () => setIsDragging(false);
return (
<InfiniteGrid.Item
draggable
className={ `cursor-grab active:cursor-grabbing ${ isDragging ? 'opacity-40 scale-95' : '' }` }
itemActive={ (selectedBadgeCode === badgeCode) }
itemUnseen={ unseen }
onDoubleClick={ event => toggleBadge(selectedBadgeCode) }
onDragEnd={ onDragEnd }
onDragStart={ onDragStart }
onMouseDown={ event => setSelectedBadgeCode(badgeCode) }
{ ...rest }>
@@ -1,7 +1,7 @@
import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa';
import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api';
import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api';
import { LayoutBadgeImageView } from '../../../../common';
import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks';
import { InfiniteGrid, NitroButton } from '../../../../layout';
@@ -18,6 +18,8 @@ const ActiveBadgeSlot: FC<{
}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) =>
{
const [ isDragOver, setIsDragOver ] = useState(false);
const [ isDragging, setIsDragging ] = useState(false);
const [ justDropped, setJustDropped ] = useState(false);
const onDragOver = useCallback((event: React.DragEvent) =>
{
@@ -35,24 +37,36 @@ const ActiveBadgeSlot: FC<{
const droppedBadgeCode = event.dataTransfer.getData('badgeCode');
const sourceSlotStr = event.dataTransfer.getData('activeSlot');
const sourceSlot = sourceSlotStr ? parseInt(sourceSlotStr) : undefined;
const sourceSlot = sourceSlotStr !== '' ? parseInt(sourceSlotStr) : undefined;
if(droppedBadgeCode) onDropBadge(droppedBadgeCode, slotIndex, sourceSlot);
if(droppedBadgeCode)
{
onDropBadge(droppedBadgeCode, slotIndex, sourceSlot);
setJustDropped(true);
setTimeout(() => setJustDropped(false), 300);
}
}, [ slotIndex, onDropBadge ]);
const onDragStart = useCallback((event: React.DragEvent) =>
{
if(!badgeCode) return;
onDragStartFromSlot(event, badgeCode, slotIndex);
setIsDragging(true);
}, [ badgeCode, slotIndex, onDragStartFromSlot ]);
const onDragEnd = useCallback(() => setIsDragging(false), []);
return (
<div
className={ `flex items-center justify-center rounded-md border-2 cursor-pointer aspect-square transition-colors
${ isDragOver ? 'border-blue-400 bg-blue-400/20' : '' }
className={ `flex items-center justify-center rounded-md border-2 aspect-square transition-all duration-150
${ badgeCode ? 'cursor-grab active:cursor-grabbing' : 'cursor-default' }
${ isDragging ? 'opacity-30 scale-95' : '' }
${ isDragOver ? 'border-blue-400 bg-blue-400/20 animate-pulse-glow scale-105' : '' }
${ justDropped ? 'animate-drop-settle' : '' }
${ isSelected && badgeCode ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
${ !badgeCode ? 'border-dashed opacity-60' : '' }` }
draggable={ !!badgeCode }
onDragEnd={ onDragEnd }
onDragLeave={ onDragLeave }
onDragOver={ onDragOver }
onDragStart={ onDragStart }
@@ -73,8 +87,9 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker();
const { showConfirm = null } = useNotification();
const [ isDragOverInventory, setIsDragOverInventory ] = useState(false);
const [ isDraggingFromActive, setIsDraggingFromActive ] = useState(false);
const maxSlots = 5;
const maxSlots = useMemo(() => GetConfigurationValue<number>('user.badges.max.slots', 5), []);
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
const attemptDeleteBadge = () =>
@@ -95,12 +110,10 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
{
if(sourceSlot !== undefined)
{
// Reorder within active badges
reorderBadges(sourceSlot, slotIndex);
}
else
{
// Drop from inventory to active slot
setBadgeAtSlot(badgeCode, slotIndex);
}
}, [ setBadgeAtSlot, reorderBadges ]);
@@ -111,6 +124,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
event.dataTransfer.setData('activeSlot', slotIndex.toString());
event.dataTransfer.setData('source', 'active');
event.dataTransfer.effectAllowed = 'move';
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode);
const img = new Image();
img.src = badgeUrl;
event.dataTransfer.setDragImage(img, 20, 20);
}, []);
const handleRemoveBadge = useCallback((badgeCode: string) =>
@@ -121,18 +139,24 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
// Handle drop on inventory area (remove from active)
const onInventoryDragOver = useCallback((event: React.DragEvent) =>
{
const source = event.dataTransfer.types.includes('activeslot') ? 'active' : '';
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const fromActive = event.dataTransfer.types.includes('activeslot');
setIsDraggingFromActive(fromActive);
setIsDragOverInventory(true);
}, []);
const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []);
const onInventoryDragLeave = useCallback(() =>
{
setIsDragOverInventory(false);
setIsDraggingFromActive(false);
}, []);
const onInventoryDrop = useCallback((event: React.DragEvent) =>
{
event.preventDefault();
setIsDragOverInventory(false);
setIsDraggingFromActive(false);
const badgeCode = event.dataTransfer.getData('badgeCode');
const source = event.dataTransfer.getData('source');
@@ -169,10 +193,18 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
return (
<div className="grid h-full grid-cols-12 gap-2">
<div
className={ `flex flex-col col-span-7 gap-1 overflow-hidden rounded transition-colors ${ isDragOverInventory ? 'bg-blue-400/10' : '' }` }
className={ `relative flex flex-col col-span-7 gap-1 overflow-hidden rounded transition-all duration-200
${ isDragOverInventory && isDraggingFromActive ? 'bg-red-500/10 ring-2 ring-inset ring-red-400/30 animate-pulse-glow-red' : '' }
${ isDragOverInventory && !isDraggingFromActive ? 'bg-blue-400/10' : '' }` }
onDragLeave={ onInventoryDragLeave }
onDragOver={ onInventoryDragOver }
onDrop={ onInventoryDrop }>
{ isDragOverInventory && isDraggingFromActive && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center pointer-events-none">
<FaTrashAlt className="text-red-400/60 text-2xl mb-1" />
<span className="text-red-400/60 text-xs font-medium">{ LocalizeText('inventory.badges.clearbadge') }</span>
</div>
) }
<InfiniteGrid<string>
columnCount={ 5 }
estimateSize={ 50 }
@@ -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<NavigatorRoomSettingsMiscTabViewProps> = props =>
{
const { roomData = null } = props;
const [ youtubeEnabled, setYoutubeEnabled ] = useState(getYoutubeRoomEnabled());
const [ cooldown, setCooldown ] = useState(false);
useMessageEvent<YouTubeRoomSettingsEvent>(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 (
<>
<div className="mb-3">
<div className="font-bold text-sm mb-2">{ LocalizeText('product.type.other') }</div>
</div>
<div className="flex flex-col gap-3">
<div className={`p-3 rounded transition-colors ${cooldown ? 'bg-gray-200 opacity-60' : 'bg-gray-100'}`}>
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-sm">📺 YouTube TV</div>
<div className="text-xs text-gray-500">Allow YouTube video broadcasting in this room</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={ youtubeEnabled }
disabled={ cooldown }
onChange={ e => toggleYouTube(e.target.checked) }
className="w-5 h-5"
/>
</label>
</div>
</div>
</div>
</>
);
};
@@ -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 =>
<NavigatorRoomSettingsVipChatTabView handleChange={ handleChange } roomData={ roomData } /> }
{ (currentTab === TABS[4]) &&
<NavigatorRoomSettingsModTabView handleChange={ handleChange } roomData={ roomData } /> }
{ (currentTab === TABS[5]) &&
<NavigatorRoomSettingsMiscTabView roomData={ roomData } /> }
</NitroCardContentView>
</NitroCardView>
);
@@ -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 <NotificationBadgeReceivedBubbleView key={ item.id } { ...props } />;
case NotificationBubbleType.CLUBGIFT:
return <NotificationClubGiftBubbleView key={ item.id } { ...props } />;
default:
@@ -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<NotificationBadgeReceivedBubbleViewProps> = 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 (
<LayoutNotificationBubbleView className="flex-col" onClose={ onClose } { ...rest }>
<div onClick={ (e) => e.stopPropagation() }>
<Flex alignItems="center" gap={ 2 } className="mb-2">
<Flex center className="w-[50px] h-[50px] shrink-0">
{ item.iconUrl && <img alt="" className="no-select" src={ item.iconUrl } /> }
</Flex>
<Flex column gap={ 0 }>
<Text bold variant="white">
{ item.senderName
? LocalizeText('notifications.text.received.badge', [ 'user_name' ], [ item.senderName ])
: LocalizeText('prereg.reward.you.received') }
</Text>
<Text variant="white" small>{ item.message }</Text>
</Flex>
</Flex>
<Flex alignItems="center" justifyContent="end" gap={ 2 }>
<button
className="btn btn-success w-full btn-sm"
type="button"
onClick={ handleWear }>
{ LocalizeText('inventory.badges.wearbadge') }
</button>
<span className="underline cursor-pointer text-nowrap" onClick={ handleDismiss }>
{ LocalizeText('notifications.button.later') }
</span>
</Flex>
</div>
</LayoutNotificationBubbleView>
);
};
@@ -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<InfoStandBadgeSlotProps> = ({ 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<InfoStandBadgeSlotProps> = ({ slotIndex,
event.dataTransfer.setData('badgeCode', badgeCode);
event.dataTransfer.setData('infostandSlot', slotIndex.toString());
event.dataTransfer.effectAllowed = 'move';
setIsDragging(true);
const badgeUrl = GetConfigurationValue<string>('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<InfoStandBadgeSlotProps> = ({ 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<InfoStandBadgeSlotProps> = ({ 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<InfoStandBadgeSlotProps> = ({ 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
? <LayoutBadgeImageView badgeCode={ badgeCode } showInfo={ true } />
: isOwnUser && <FaPlus className="text-white/30 text-[10px]" /> }
@@ -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<InfoStandWidgetUserViewProps> = props =
useNitroEvent<RoomSessionUserBadgesEvent>(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => {
if (!avatarInfo || avatarInfo.webID !== event.userId) return;
// Deduplicate badges from server
const seen = new Set<string>();
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<InfoStandWidgetUserViewProps> = props =
/>
)}
<Column grow alignItems="center" gap={0}>
{ GetConfigurationValue<boolean>('user.badges.group.slot.enabled', true)
? (
<>
<div className="flex gap-1">
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<Flex center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
{avatarInfo.groupId > 0 &&
<LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
</Flex>
</div>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
{ (() => {
const maxSlots = GetConfigurationValue<number>('user.badges.max.slots', 5);
const isOwnUser = avatarInfo.type === AvatarInfoUser.OWN_USER;
const showGroup = maxSlots <= 5;
const items: React.ReactNode[] = [];
items.push(<InfoStandBadgeSlotView key={0} slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={isOwnUser} />);
if(showGroup) {
items.push(
<Flex key="group" center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
{avatarInfo.groupId > 0 && <LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
</Flex>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
</>
)
: (
<>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={5} badgeCode={avatarInfo.badges[5]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
</>
)
}
);
} else {
items.push(<InfoStandBadgeSlotView key="slot1" slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={isOwnUser} />);
}
const startIdx = showGroup ? 1 : 2;
for(let i = startIdx; i < maxSlots; i++) {
items.push(<InfoStandBadgeSlotView key={i} slotIndex={i} badgeCode={avatarInfo.badges[i]} isOwnUser={isOwnUser} />);
}
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) => (
<Flex key={idx} center gap={1}>{row}</Flex>
));
})() }
</Column>
</div>
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
@@ -279,7 +279,7 @@ export const ChatInputView: FC<{}> = props =>
return (
createPortal(
<div className="nitro-chat-input-container relative flex h-[38px] w-full items-center justify-between overflow-visible rounded-[12px] border-2 border-black bg-white pr-[8px]">
<div className="nitro-chat-input-container flex justify-between items-center h-10 border-2 border-black bg-gray-200 pr-2.5 overflow-visible rounded-lg lg:relative lg:w-full max-lg:fixed max-lg:bottom-[70px] max-lg:left-1/2 max-lg:-translate-x-1/2 max-lg:z-50 max-lg:w-[80vw] max-lg:max-w-[500px] max-lg:shadow-lg">
{ commandSelectorVisible &&
<ChatInputCommandSelectorView
commands={ filteredCommands }
+74 -210
View File
@@ -1,11 +1,12 @@
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, useWiredTools } from '../../hooks';
import { ToolbarItemView } from './ToolbarItemView';
import { ToolbarMeView } from './ToolbarMeView';
import { YouTubePlayerView } from './YouTubePlayerView';
const containerVariants = {
hidden: {},
@@ -25,6 +26,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
const [ isMeExpanded, setMeExpanded ] = useState(false);
const [ isToolbarOpen, setIsToolbarOpen ] = useState(false);
const [ useGuideTool, setUseGuideTool ] = useState(false);
const [ youtubeEnabled, setYoutubeEnabled ] = useState(false);
const { userFigure = null } = useSessionInfo();
const { getFullCount = 0 } = useInventoryUnseenTracker();
const { getTotalUnseen = 0 } = useAchievements();
@@ -35,6 +37,25 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
const hasDesktopUnifiedShell = (isInRoom && isToolbarOpen);
const showDesktopShell = (isToolbarOpen || !isInRoom);
useMessageEvent<YouTubeRoomSettingsEvent>(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>(PerkAllowancesMessageEvent, event =>
{
setUseGuideTool(event.getParser().isAllowed(PerkEnum.USE_GUIDE_TOOL));
@@ -79,213 +100,56 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
return (
<>
<style>{ TOOLBAR_STYLES }</style>
{ isInRoom &&
<div className={ `fixed bottom-0 left-0 right-0 z-40 flex h-[52px] items-end px-0 pt-[2px] pb-0 pointer-events-none md:left-1/2 md:right-auto md:h-[52px] md:w-[420px] md:-translate-x-1/2 md:items-center md:px-[6px] md:py-[4px] lg:w-[460px] ${ isToolbarOpen ? (hasDesktopUnifiedShell ? 'md:rounded-none md:border-0 md:bg-transparent md:shadow-none rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : 'rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]') : 'border-0 bg-transparent shadow-none md:border-0 md:bg-transparent md:shadow-none' }` }>
<motion.div
className="tb-toggle pointer-events-auto mr-2 mb-[4px] flex-shrink-0 md:mb-0"
onClick={ () => setIsToolbarOpen(value => !value) }
whileTap={ { scale: 0.9 } }>
<svg
className={ `h-3.5 w-3.5 text-white/70 transition-transform duration-300 ${ isToolbarOpen ? 'rotate-180 md:-rotate-90' : 'rotate-0 md:rotate-90' }` }
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={ 2.5 } d="M5 15l7-7 7 7" />
</svg>
</motion.div>
<Flex
alignItems="center"
justifyContent="center"
className="pointer-events-auto h-full w-full min-w-0 flex-1 px-[6px] md:px-0"
id="toolbar-chat-input-container" />
<div className="pointer-events-auto relative mr-[6px] shrink-0 md:hidden">
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
{ (requests.length > 0) &&
<LayoutItemCountView count={ requests.length } className="absolute -right-1 top-0" /> }
</div>
</div> }
<AnimatePresence>
{ (isToolbarOpen || !isInRoom) &&
<>
{ showDesktopShell &&
<motion.div
key="desktop-unified-shell"
initial={ { opacity: 0, y: 8 } }
animate={ { opacity: 1, y: 0 } }
exit={ { opacity: 0, y: 8 } }
transition={ { type: 'spring', stiffness: 260, damping: 26 } }
className="pointer-events-none fixed bottom-0 left-0 right-0 z-[39] hidden h-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)] md:block" /> }
<motion.div
key="left-nav"
initial={ { opacity: 0, x: isInRoom ? -10 : 0, y: isInRoom ? 0 : 8 } }
animate={ { opacity: 1, x: 0, y: 0 } }
exit={ { opacity: 0, x: isInRoom ? -10 : 0, y: isInRoom ? 0 : 8 } }
transition={ { type: 'spring', stiffness: 300, damping: 28 } }
className="fixed bottom-0 left-0 z-40 hidden h-[52px] max-w-[calc(50vw-242px)] items-center overflow-visible pl-3 pointer-events-auto md:flex">
<motion.div variants={ containerVariants } initial="hidden" animate="visible" exit="exit" className={ `tb-open-shell flex h-[52px] max-w-full items-center gap-2 overflow-visible px-[8px] pt-[10px] pb-[2px] ${ showDesktopShell ? 'bg-transparent' : 'rounded-t-[10px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' }` }>
<motion.div variants={ itemVariants }>
{ isInRoom
? <ToolbarItemView icon="habbo" onClick={ () => VisitDesktop() } className="tb-icon" />
: <ToolbarItemView icon="house" onClick={ () => CreateLinkEvent('navigator/goto/home') } className="tb-icon" /> }
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="rooms" onClick={ () => CreateLinkEvent('navigator/toggle') } className="tb-icon" />
</motion.div>
{ GetConfigurationValue('game.center.enabled') &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="game" onClick={ () => CreateLinkEvent('games/toggle') } className="tb-icon" />
</motion.div> }
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="inventory" onClick={ () => CreateLinkEvent('inventory/toggle') } className="tb-icon" />
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div>
<motion.div variants={ itemVariants } className="relative">
<AnimatePresence>
{ isMeExpanded &&
<motion.div
initial={ { opacity: 0, y: 6, scale: 0.97 } }
animate={ { opacity: 1, y: 0, scale: 1 } }
exit={ { opacity: 0, y: 6, scale: 0.97 } }
transition={ { type: 'spring', stiffness: 420, damping: 28 } }
className="pointer-events-auto absolute bottom-[calc(100%+8px)] left-1/2 z-50 -translate-x-1/2">
<ToolbarMeView setMeExpanded={ setMeExpanded } unseenAchievementCount={ getTotalUnseen } useGuideTool={ useGuideTool } />
</motion.div> }
</AnimatePresence>
<motion.div whileHover={ { scale: 1.08 } } whileTap={ { scale: 0.95 } } className="cursor-pointer" onClick={ event => { setMeExpanded(value => !value); event.stopPropagation(); } }>
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[44px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: '4px' } } />
</motion.div>
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div>
{ (isInRoom && showToolbarButton) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" />
</motion.div> }
{ isInRoom &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="camera" onClick={ () => CreateLinkEvent('camera/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="furnieditor" onClick={ () => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
</motion.div> }
</motion.div>
</motion.div>
<motion.div
key="right-nav"
initial={ { opacity: 0, x: 10 } }
animate={ { opacity: 1, x: 0 } }
exit={ { opacity: 0, x: 10 } }
transition={ { type: 'spring', stiffness: 300, damping: 28 } }
className={ `fixed bottom-0 z-40 hidden h-[52px] max-w-[calc(50vw-242px)] items-center overflow-visible pr-3 pointer-events-auto md:flex ${ isInRoom ? 'right-0' : 'right-3' }` }>
<motion.div variants={ containerVariants } initial="hidden" animate="visible" exit="exit" className="tb-open-shell flex h-[52px] max-w-full items-center gap-3 overflow-visible bg-transparent px-[8px] pt-[10px] pb-[2px]">
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
{ (requests.length > 0) &&
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
</motion.div>
{ ((iconState === MessengerIconState.SHOW) || (iconState === MessengerIconState.UNREAD)) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView className={ `tb-icon ${ iconState === MessengerIconState.UNREAD ? 'is-unseen animate-pulse' : '' }` } icon="message" onClick={ () => OpenMessengerChat() } />
</motion.div> }
<div className="mx-1 hidden h-5 w-[1px] bg-white/20 md:block" />
<div className="hidden h-full shrink-0 md:block" id="toolbar-friend-bar-container-desktop" />
</motion.div>
</motion.div>
<motion.div
key="mobile-nav"
initial={ { opacity: 0, y: 8 } }
animate={ { opacity: 1, y: 0 } }
exit={ { opacity: 0, y: 8 } }
transition={ { type: 'spring', stiffness: 300, damping: 28 } }
className={ `fixed left-1/2 z-40 flex w-[95vw] -translate-x-1/2 items-center overflow-visible pointer-events-auto md:hidden ${ isInRoom ? 'bottom-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] px-[6px] py-[4px] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : 'bottom-0' }` }>
<motion.div variants={ containerVariants } initial="hidden" animate="visible" exit="exit" className="tb-bar-scroll flex h-full min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-visible px-1">
<motion.div variants={ itemVariants }>
{ isInRoom
? <ToolbarItemView icon="habbo" onClick={ () => VisitDesktop() } className="tb-icon" />
: <ToolbarItemView icon="house" onClick={ () => CreateLinkEvent('navigator/goto/home') } className="tb-icon" /> }
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="rooms" onClick={ () => CreateLinkEvent('navigator/toggle') } className="tb-icon" />
</motion.div>
{ GetConfigurationValue('game.center.enabled') &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="game" onClick={ () => CreateLinkEvent('games/toggle') } className="tb-icon" />
</motion.div> }
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="inventory" onClick={ () => CreateLinkEvent('inventory/toggle') } className="tb-icon" />
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div>
</motion.div>
<motion.div variants={ itemVariants } className="relative mx-[2px] shrink-0">
<AnimatePresence>
{ isMeExpanded &&
<motion.div
initial={ { opacity: 0, y: 6, scale: 0.97 } }
animate={ { opacity: 1, y: 0, scale: 1 } }
exit={ { opacity: 0, y: 6, scale: 0.97 } }
transition={ { type: 'spring', stiffness: 420, damping: 28 } }
className="pointer-events-auto absolute bottom-[calc(100%+10px)] left-1/2 z-[70] -translate-x-1/2">
<ToolbarMeView setMeExpanded={ setMeExpanded } unseenAchievementCount={ getTotalUnseen } useGuideTool={ useGuideTool } />
</motion.div> }
</AnimatePresence>
<motion.div whileHover={ { scale: 1.08 } } whileTap={ { scale: 0.95 } } className="cursor-pointer" onClick={ event => { setMeExpanded(value => !value); event.stopPropagation(); } }>
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[44px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: '4px' } } />
</motion.div>
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div>
<motion.div variants={ containerVariants } initial="hidden" animate="visible" exit="exit" className="tb-bar-scroll flex h-full items-center gap-2 overflow-x-auto overflow-y-visible px-1">
{ (isInRoom && showToolbarButton) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" />
</motion.div> }
{ isInRoom &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="camera" onClick={ () => CreateLinkEvent('camera/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
</motion.div> }
{ isMod &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="furnieditor" onClick={ () => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
</motion.div> }
{ !isInRoom &&
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
{ (requests.length > 0) &&
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
</motion.div> }
</motion.div>
</motion.div>
</> }
</AnimatePresence>
{ youtubeEnabled && <YouTubePlayerView /> }
<AnimatePresence> { isMeExpanded && ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }}>
<ToolbarMeView setMeExpanded={ setMeExpanded } unseenAchievementCount={ getTotalUnseen } useGuideTool={ useGuideTool } />
</motion.div> )}
</AnimatePresence>
<Flex alignItems="center" className="absolute bottom-0 left-0 w-full h-[55px] bg-[rgba(28,28,32,.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] py-1 px-3" gap={ 2 }>
<Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
<Flex center pointer className={ 'relative w-[50px] h-[45px] overflow-hidden ' + (isMeExpanded ? 'active ' : '') } onClick={ event =>
{
setMeExpanded(!isMeExpanded);
event.stopPropagation();
} }>
<LayoutAvatarImageView className="-ml-[5px] mt-[25px]" direction={ 2 } figure={ userFigure } position="absolute" />
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } /> }
</Flex>
{ isInRoom &&
<ToolbarItemView icon="habbo" onClick={ event => VisitDesktop() } /> }
{ !isInRoom &&
<ToolbarItemView icon="house" onClick={ event => CreateLinkEvent('navigator/goto/home') } /> }
<ToolbarItemView icon="rooms" onClick={ event => CreateLinkEvent('navigator/toggle') } />
{ GetConfigurationValue('game.center.enabled') &&
<ToolbarItemView icon="game" onClick={ event => CreateLinkEvent('games/toggle') } /> }
<ToolbarItemView icon="catalog" onClick={ event => CreateLinkEvent('catalog/toggle') } />
<ToolbarItemView icon="inventory" onClick={ event => CreateLinkEvent('inventory/toggle') }>
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } /> }
</ToolbarItemView>
{ isInRoom &&
<ToolbarItemView icon="camera" onClick={ event => CreateLinkEvent('camera/toggle') } /> }
{ youtubeEnabled &&
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } /> }
{ isMod &&
<ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> }
{ isMod &&
<ToolbarItemView icon="furnieditor" onClick={ event => CreateLinkEvent('furni-editor/toggle') } /> }
</Flex>
<Flex alignItems="center" justifyContent="center" className="lg:flex-1 lg:min-w-0 lg:max-w-[600px] lg:mx-auto max-lg:flex-none max-lg:w-0 max-lg:max-w-0 max-lg:overflow-visible" id="toolbar-chat-input-container" />
<Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
<Flex gap={ 2 }>
<ToolbarItemView icon="friendall" onClick={ event => CreateLinkEvent('friends/toggle') }>
{ (requests.length > 0) &&
<LayoutItemCountView count={ requests.length } /> }
</ToolbarItemView>
{ ((iconState === MessengerIconState.SHOW) || (iconState === MessengerIconState.UNREAD)) &&
<ToolbarItemView className={ (iconState === MessengerIconState.UNREAD) && 'is-unseen' } icon="message" onClick={ event => OpenMessengerChat() } /> }
</Flex>
<div className="hidden lg:block" id="toolbar-friend-bar-container" />
</Flex>
</Flex>
</>
);
};
@@ -0,0 +1,726 @@
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<number>(100);
const [playlist, setPlaylist] = useState<string[]>([]);
const [history, setHistory] = useState<string[]>([]);
const [showVolumeSlider, setShowVolumeSlider] = useState(true);
const playerRef = useRef<any>(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());
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.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;
}) => (
<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 ? (
<YouTube
videoId={videoId}
opts={{
width: "100%",
height: isFullscreen ? "100%" : "280",
playerVars: {
autoplay: 1,
volume: volume,
muted: isMuted ? 1 : 0,
loop: isLooping ? 1 : 0,
},
}}
onReady={(e) => {
playerRef.current = e.target;
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.userId, 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>
);
};
@@ -1,57 +0,0 @@
import { AddLinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { GetConfigurationValue } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
export const YoutubeTvView: FC<{}> = props =>
{
const [ videoId, setVideoId ] = useState<string>('');
const [ isVisible, setIsVisible ] = useState<boolean>(false);
const close = () => setIsVisible(false);
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 3) return;
switch(parts[1])
{
case 'show':
setVideoId(parts[2]);
setIsVisible(true);
return;
}
},
eventUrlPrefix: 'youtube-tv/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
const originUrl = useMemo(() => GetConfigurationValue<string>('url.prefix', ''), []);
if(!isVisible) return null;
return (
<NitroCardView className="w-[560px] h-[345px]" uniqueKey="youtube-tv">
<NitroCardHeaderView headerText="YouTube TV" onCloseClick={ close } />
<NitroCardContentView grow gap={ 0 } overflow="hidden">
{ (videoId.length > 0) &&
<iframe
className="w-full h-full border-0"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
src={ `https://www.youtube.com/embed/${ videoId }?autoplay=1&mute=0&controls=1&origin=${ originUrl }&playsinline=1&showinfo=0&rel=0&iv_load_policy=3&modestbranding=1&disablekb=1&enablejsapi=1&widgetid=3` }
/>
}
</NitroCardContentView>
</NitroCardView>
);
};
-1
View File
@@ -1 +0,0 @@
export * from './YoutubeTvView';