mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge remote-tracking branch 'duckie-temp/main' into duckie-merge-2026-04-21
# Conflicts: # src/components/room/widgets/chat-input/ChatInputView.tsx # src/components/toolbar/ToolbarView.tsx # src/css/chat/Chats.css # src/css/nitrocard/NitroCardView.css # src/css/purse/PurseView.css # src/css/room/RoomWidgets.css
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
## Badge System Rework (2026-04-04)
|
||||
|
||||
### Bug Fixes
|
||||
- **Slot 0 drag bug**: Dragging from slot 0 no longer causes badges to disappear. The root cause was `'0'` being falsy in JavaScript, which made the drop handler take the wrong code path and overwrite the target badge.
|
||||
- **Badge duplication**: Fixed badges appearing in multiple slots when dragging in the InfoStand. The issue was a stale props fallback — after a drag operation, the hook updated correctly but the component fell back to old server props for empty slots, showing ghost copies.
|
||||
- **Race condition**: Replaced single boolean `localChangeRef` with a counter (`pendingUpdatesRef`) to correctly handle rapid sequential drag operations without the server overwriting local state.
|
||||
- **Badge deduplication**: `toFixedSlots()` now deduplicates badges, preventing the same badge from appearing in multiple slots even if the server returns duplicates.
|
||||
- **Server badge dedup in InfoStand**: `RoomSessionUserBadgesEvent` handler now deduplicates badges from the server before updating the avatar info.
|
||||
|
||||
### Drag & Drop Visual Feedback
|
||||
- **Custom drag preview**: Badge image is used as the drag ghost instead of the browser default (via `setDragImage`).
|
||||
- **Source opacity**: The dragged item becomes semi-transparent (`opacity-40`) during drag.
|
||||
- **Pulsing glow on drop targets**: Valid drop targets pulse with a blue glow animation (`animate-pulse-glow`).
|
||||
- **Drop settle animation**: A brief scale-down animation (`animate-drop-settle`, 300ms) plays when a badge lands in a slot.
|
||||
- **Remove indicator**: Dragging an active badge over the inventory area shows a red pulsing background with a trash icon overlay.
|
||||
- **Grab cursor**: All draggable badge elements now show `cursor-grab` / `cursor-grabbing`.
|
||||
|
||||
### Sparse Slot Support
|
||||
- `activeBadgeCodes` changed from compact `string[]` to fixed-size `(string | null)[]` array. Empty slots are `null` instead of being collapsed, allowing gaps between badges.
|
||||
- All operations (`setBadgeAtSlot`, `removeBadge`, `reorderBadges`, `swapBadges`, `toggleBadge`) work on the fixed-size array without compaction.
|
||||
|
||||
### New Badge Glow (Feature)
|
||||
- Unseen (newly received) badges in the inventory now pulse with a **gold glow** (`animate-pulse-glow-gold`) instead of the previous flat green background.
|
||||
- The glow disappears when the badge is selected (unseen status cleared).
|
||||
|
||||
### Badge Received Toast Notification (Feature)
|
||||
- When a new badge is received, a bubble notification appears with:
|
||||
- Badge image and localized name
|
||||
- **"Indossa" / "Wear"** button that directly equips the badge via `toggleBadge` and closes the notification
|
||||
- **"Non ora" / "Later"** link to dismiss
|
||||
- Auto-fades after 8 seconds (standard bubble behavior).
|
||||
- Uses the existing `NotificationBubbleType.BADGE_RECEIVED` (was defined but unused).
|
||||
- New component: `NotificationBadgeReceivedBubbleView`.
|
||||
|
||||
### Dynamic Badge Slot Count
|
||||
- Badge slot count is now fully driven by `user.badges.max.slots` config (default: 5).
|
||||
- **5 slots**: 5 badge slots + group badge in InfoStand (6 boxes total)
|
||||
- **6 slots**: 6 badge slots, group badge is replaced by the 6th slot
|
||||
- Both the inventory grid and InfoStand layout adapt automatically.
|
||||
- Removed all hardcoded `maxSlots = 5` references.
|
||||
|
||||
### InfoStand Double-Click to Remove
|
||||
- Double-clicking a badge in the InfoStand removes it from active badges (own user only).
|
||||
|
||||
### Localization
|
||||
- Added `notification.badge.received` key:
|
||||
- IT: "Nuovo Distintivo!"
|
||||
- EN: "New Badge!"
|
||||
- Located in `public/nitro-assets/config/UITexts.json` and `UITexts_en.json`.
|
||||
|
||||
### Files Modified
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/hooks/inventory/useInventoryBadges.ts` | Sparse slots, dedup, race condition fix, toFixedSlots |
|
||||
| `src/hooks/notification/useNotification.ts` | BadgeReceivedEvent listener |
|
||||
| `src/components/inventory/views/badge/InventoryBadgeView.tsx` | Visual feedback, dynamic maxSlots, fix '0' falsy |
|
||||
| `src/components/inventory/views/badge/InventoryBadgeItemView.tsx` | Drag preview, opacity, cursor |
|
||||
| `src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx` | Visual feedback, double-click remove, no stale props |
|
||||
| `src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx` | Dynamic layout, server badge dedup |
|
||||
| `src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx` | BADGE_RECEIVED routing |
|
||||
| `src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx` | New component |
|
||||
| `src/layout/InfiniteGrid.tsx` | Gold glow for unseen items |
|
||||
| `tailwind.config.js` | Custom keyframes and animations |
|
||||
|
||||
### Configuration
|
||||
```json
|
||||
{
|
||||
"user.badges.max.slots": 5
|
||||
}
|
||||
```
|
||||
Set to `6` to replace the group badge slot with a 6th badge slot.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notification.badge.received": "New Badge!"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notification.badge.received": "Nuovo Distintivo!"
|
||||
}
|
||||
+1
-3
@@ -38,7 +38,6 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -48,7 +47,6 @@
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-tsconfig-paths": "^6.1.1"
|
||||
"vite": "^8.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -2,7 +2,6 @@
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {}
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,61 @@
|
||||
import { AvatarFigurePartType, AvatarScaleType, AvatarSetType, GetAssetManager, GetAvatarRenderManager, IFigurePart, IGraphicAsset, IPartColor, NitroAlphaFilter, NitroContainer, NitroRectangle, NitroSprite, TextureUtils } from '@nitrots/nitro-renderer';
|
||||
import { IAvatarEditorCategoryPartItem } from './IAvatarEditorCategoryPartItem';
|
||||
|
||||
const MAX_CACHE_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
class LRUImageCache
|
||||
{
|
||||
private _cache: Map<string, string> = new Map();
|
||||
private _currentBytes: number = 0;
|
||||
|
||||
public get(key: string): string | undefined
|
||||
{
|
||||
const value = this._cache.get(key);
|
||||
|
||||
if(value !== undefined)
|
||||
{
|
||||
this._cache.delete(key);
|
||||
this._cache.set(key, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public set(key: string, value: string): void
|
||||
{
|
||||
if(this._cache.has(key))
|
||||
{
|
||||
const old = this._cache.get(key);
|
||||
|
||||
this._currentBytes -= (key.length + old.length) * 2;
|
||||
this._cache.delete(key);
|
||||
}
|
||||
|
||||
const entryBytes = (key.length + value.length) * 2;
|
||||
|
||||
while(this._currentBytes + entryBytes > MAX_CACHE_BYTES && this._cache.size > 0)
|
||||
{
|
||||
const firstKey = this._cache.keys().next().value;
|
||||
const firstValue = this._cache.get(firstKey);
|
||||
|
||||
this._currentBytes -= (firstKey.length + firstValue.length) * 2;
|
||||
this._cache.delete(firstKey);
|
||||
}
|
||||
|
||||
this._cache.set(key, value);
|
||||
this._currentBytes += entryBytes;
|
||||
}
|
||||
|
||||
public clear(): void
|
||||
{
|
||||
this._cache.clear();
|
||||
this._currentBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class AvatarEditorThumbnailsHelper
|
||||
{
|
||||
private static THUMBNAIL_CACHE: Map<string, string> = new Map();
|
||||
private static THUMBNAIL_CACHE: LRUImageCache = new LRUImageCache();
|
||||
private static THUMB_DIRECTIONS: number[] = [ 2, 6, 0, 4, 3, 1 ];
|
||||
private static ALPHA_FILTER: NitroAlphaFilter = new NitroAlphaFilter({ alpha: 0.2 });
|
||||
private static DRAW_ORDER: string[] = [
|
||||
@@ -37,9 +89,18 @@ export class AvatarEditorThumbnailsHelper
|
||||
'ptr',
|
||||
];
|
||||
|
||||
private static getThumbnailKey(setType: string, part: IAvatarEditorCategoryPartItem): string
|
||||
private static getThumbnailKey(setType: string, part: IAvatarEditorCategoryPartItem, partColors?: IPartColor[], isDisabled?: boolean): string
|
||||
{
|
||||
return `${ setType }-${ part.partSet.id }`;
|
||||
let key = `${ setType }-${ part.partSet.id }`;
|
||||
|
||||
if(partColors?.length)
|
||||
{
|
||||
key += '-' + partColors.map(c => c?.rgb?.toString(16) ?? '0').join(',');
|
||||
}
|
||||
|
||||
if(isDisabled) key += '-d';
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
public static clearCache(): void
|
||||
@@ -51,7 +112,7 @@ export class AvatarEditorThumbnailsHelper
|
||||
{
|
||||
if(!setType || !setType.length || !part || !part.partSet || !part.partSet.parts || !part.partSet.parts.length) return null;
|
||||
|
||||
const thumbnailKey = this.getThumbnailKey(setType, part);
|
||||
const thumbnailKey = this.getThumbnailKey(setType, part, useColors ? partColors : null, isDisabled);
|
||||
const cached = this.THUMBNAIL_CACHE.get(thumbnailKey);
|
||||
|
||||
if(cached) return cached;
|
||||
@@ -145,7 +206,7 @@ export class AvatarEditorThumbnailsHelper
|
||||
{
|
||||
if(!figureString || !figureString.length) return null;
|
||||
|
||||
const thumbnailKey = figureString;
|
||||
const thumbnailKey = figureString + (isDisabled ? '-d' : '');
|
||||
const cached = this.THUMBNAIL_CACHE.get(thumbnailKey);
|
||||
|
||||
if(cached) return cached;
|
||||
|
||||
@@ -26,3 +26,4 @@ export * from './room/widgets';
|
||||
export * from './user';
|
||||
export * from './utils';
|
||||
export * from './wired';
|
||||
export * from './youtube';
|
||||
|
||||
@@ -9,8 +9,9 @@ export class NotificationBubbleItem
|
||||
private _notificationType: string;
|
||||
private _iconUrl: string;
|
||||
private _linkUrl: string;
|
||||
private _senderName: string;
|
||||
|
||||
constructor(message: string, notificationType: string = NotificationBubbleType.INFO, iconUrl: string = null, linkUrl: string = null)
|
||||
constructor(message: string, notificationType: string = NotificationBubbleType.INFO, iconUrl: string = null, linkUrl: string = null, senderName: string = '')
|
||||
{
|
||||
NotificationBubbleItem.ITEM_ID += 1;
|
||||
|
||||
@@ -19,6 +20,7 @@ export class NotificationBubbleItem
|
||||
this._notificationType = notificationType;
|
||||
this._iconUrl = iconUrl;
|
||||
this._linkUrl = linkUrl;
|
||||
this._senderName = senderName;
|
||||
}
|
||||
|
||||
public get id(): number
|
||||
@@ -45,4 +47,9 @@ export class NotificationBubbleItem
|
||||
{
|
||||
return this._linkUrl;
|
||||
}
|
||||
|
||||
public get senderName(): string
|
||||
{
|
||||
return this._senderName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { GetConfigurationValue } from '../nitro/GetConfigurationValue';
|
||||
import { LocalizeText } from './LocalizeText';
|
||||
|
||||
const allowedColours: Map<string, string> = new Map();
|
||||
@@ -89,16 +88,6 @@ export const RoomChatFormatter = (content: string) =>
|
||||
content = applyWiredTextMarkup(content);
|
||||
//content = (joypixels.shortnameToUnicode(content) as string)
|
||||
|
||||
if(!GetConfigurationValue<boolean>('youtube.publish.disabled', false))
|
||||
{
|
||||
const labelShared = LocalizeText('widget.room.youtube.shared');
|
||||
const labelOpen = LocalizeText('widget.room.youtube.open_video');
|
||||
content = content.replace(
|
||||
/(?:http:\/\/|https:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?.*v=|shorts\/)?([a-zA-Z0-9_-]{11})/g,
|
||||
`<div style="margin:2px 0"><strong>📺 ${ labelShared }</strong></div><div><a href="https://youtu.be/$1" target="_blank" style="background-color:red;color:white;padding:3px 8px;border-radius:4px;text-decoration:none;font-size:12px">▶ ${ labelOpen }</a></div>`
|
||||
);
|
||||
}
|
||||
|
||||
if(content.startsWith('@') && content.indexOf('@', 1) > -1)
|
||||
{
|
||||
let match = null;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
let _youtubeEnabled = false;
|
||||
|
||||
export const getYoutubeRoomEnabled = () => _youtubeEnabled;
|
||||
export const setYoutubeRoomEnabled = (enabled: boolean) => { _youtubeEnabled = enabled; };
|
||||
@@ -0,0 +1 @@
|
||||
export * from './YouTubeRoomState';
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="YouTube">
|
||||
<path fill="#FF0000" d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.378.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.12 2.136c1.873.505 9.378.505 9.378.505s7.505 0 9.378-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
@@ -80,12 +80,10 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
|
||||
const windowHeight = elementRef.current.offsetHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const maxOutX = windowWidth * DRAG_OUTSIDE_PERCENT;
|
||||
const maxOutY = windowHeight * DRAG_OUTSIDE_PERCENT;
|
||||
|
||||
const clampedX = Math.max(-maxOutX, Math.min(newX, viewportWidth - windowWidth + maxOutX));
|
||||
const clampedY = Math.max(-maxOutY, Math.min(newY, viewportHeight - windowHeight + maxOutY));
|
||||
const clampedY = Math.max(BOUNDS_THRESHOLD_TOP, Math.min(newY, viewportHeight - windowHeight + maxOutY));
|
||||
|
||||
return { x: clampedX, y: clampedY };
|
||||
}, []);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, NitroEventType } from '@nitrots/nitro-renderer';
|
||||
import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager } from '@nitrots/nitro-renderer';
|
||||
import { CSSProperties, FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Base, BaseProps } from '../Base';
|
||||
import { useNitroEvent } from '../../hooks/events';
|
||||
|
||||
const AVATAR_CACHE_MAX_SIZE = 200;
|
||||
const AVATAR_IMAGE_CACHE: Map<string, string> = new Map();
|
||||
|
||||
export interface LayoutAvatarImageViewProps extends BaseProps<HTMLDivElement>
|
||||
@@ -19,18 +19,7 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
|
||||
const { figure = '', gender = '', headOnly = false, direction = 0, scale = 1, classNames = [], style = {}, ...rest } = props;
|
||||
const [ avatarUrl, setAvatarUrl ] = useState<string>(null);
|
||||
const [ isReady, setIsReady ] = useState<boolean>(false);
|
||||
const [ updateId, setUpdateId ] = useState<number>(0);
|
||||
const isDisposed = useRef(false);
|
||||
const figureKeyRef = useRef<string>(null);
|
||||
|
||||
useNitroEvent(NitroEventType.AVATAR_ASSET_LOADED, () =>
|
||||
{
|
||||
if(figureKeyRef.current)
|
||||
{
|
||||
AVATAR_IMAGE_CACHE.delete(figureKeyRef.current);
|
||||
setUpdateId(prev => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const getClassNames = useMemo(() =>
|
||||
{
|
||||
@@ -65,44 +54,48 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
|
||||
|
||||
const figureKey = [ figure, gender, direction, headOnly ].join('-');
|
||||
|
||||
figureKeyRef.current = figureKey;
|
||||
|
||||
if(AVATAR_IMAGE_CACHE.has(figureKey))
|
||||
{
|
||||
setAvatarUrl(AVATAR_IMAGE_CACHE.get(figureKey));
|
||||
}
|
||||
else
|
||||
{
|
||||
const avatarImage = GetAvatarRenderManager().createAvatarImage(figure, AvatarScaleType.LARGE, gender, {
|
||||
resetFigure: (figure: string) =>
|
||||
{
|
||||
if(isDisposed.current) return;
|
||||
|
||||
AVATAR_IMAGE_CACHE.delete(figureKey);
|
||||
setUpdateId(prev => prev + 1);
|
||||
},
|
||||
dispose: null,
|
||||
disposed: false
|
||||
});
|
||||
|
||||
let setType = AvatarSetType.FULL;
|
||||
|
||||
if(headOnly) setType = AvatarSetType.HEAD;
|
||||
|
||||
avatarImage.setDirection(setType, direction);
|
||||
|
||||
const imageUrl = avatarImage.processAsImageUrl(setType);
|
||||
|
||||
if(imageUrl && !isDisposed.current)
|
||||
const resetFigure = (_figure: string) =>
|
||||
{
|
||||
if(!avatarImage.isPlaceholder()) AVATAR_IMAGE_CACHE.set(figureKey, imageUrl);
|
||||
if(isDisposed.current) return;
|
||||
|
||||
setAvatarUrl(imageUrl);
|
||||
}
|
||||
const avatarImage = GetAvatarRenderManager().createAvatarImage(_figure, AvatarScaleType.LARGE, gender, { resetFigure: (figure: string) => resetFigure(figure), dispose: null, disposed: false });
|
||||
|
||||
avatarImage.dispose();
|
||||
let setType = AvatarSetType.FULL;
|
||||
|
||||
if(headOnly) setType = AvatarSetType.HEAD;
|
||||
|
||||
avatarImage.setDirection(setType, direction);
|
||||
|
||||
const imageUrl = avatarImage.processAsImageUrl(setType);
|
||||
|
||||
if(imageUrl && !isDisposed.current)
|
||||
{
|
||||
if(!avatarImage.isPlaceholder())
|
||||
{
|
||||
if(AVATAR_IMAGE_CACHE.size >= AVATAR_CACHE_MAX_SIZE)
|
||||
{
|
||||
const firstKey = AVATAR_IMAGE_CACHE.keys().next().value;
|
||||
AVATAR_IMAGE_CACHE.delete(firstKey);
|
||||
}
|
||||
|
||||
AVATAR_IMAGE_CACHE.set(figureKey, imageUrl);
|
||||
}
|
||||
|
||||
setAvatarUrl(imageUrl);
|
||||
}
|
||||
|
||||
avatarImage.dispose();
|
||||
};
|
||||
|
||||
resetFigure(figure);
|
||||
}
|
||||
}, [ figure, gender, direction, headOnly, isReady, updateId ]);
|
||||
}, [ figure, gender, direction, headOnly, isReady ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GetRoomEngine, IGetImageListener, ImageResult, TextureUtils, Vector3d } from '@nitrots/nitro-renderer';
|
||||
import { CSSProperties, FC, useEffect, useMemo, useState } from 'react';
|
||||
import { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ProductTypeEnum } from '../../api';
|
||||
import { Base, BaseProps } from '../Base';
|
||||
|
||||
@@ -16,6 +16,23 @@ export const LayoutFurniImageView: FC<LayoutFurniImageViewProps> = props =>
|
||||
{
|
||||
const { productType = 's', productClassId = -1, direction = 2, extraData = '', scale = 1, style = {}, ...rest } = props;
|
||||
const [ imageElement, setImageElement ] = useState<HTMLImageElement>(null);
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
isMounted.current = true;
|
||||
|
||||
return () => { isMounted.current = false; };
|
||||
}, []);
|
||||
|
||||
const updateImage = useCallback(async (texture: any) =>
|
||||
{
|
||||
if(!texture) return;
|
||||
|
||||
const image = await TextureUtils.generateImage(texture);
|
||||
|
||||
if(image && isMounted.current) setImageElement(image);
|
||||
}, []);
|
||||
|
||||
const getStyle = useMemo(() =>
|
||||
{
|
||||
@@ -42,10 +59,12 @@ export const LayoutFurniImageView: FC<LayoutFurniImageViewProps> = props =>
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setImageElement(null);
|
||||
|
||||
let imageResult: ImageResult = null;
|
||||
|
||||
const listener: IGetImageListener = {
|
||||
imageReady: async (id, texture, image) => setImageElement(await TextureUtils.generateImage(texture)),
|
||||
imageReady: (result) => updateImage(result?.data),
|
||||
imageFailed: null
|
||||
};
|
||||
|
||||
@@ -59,12 +78,8 @@ export const LayoutFurniImageView: FC<LayoutFurniImageViewProps> = props =>
|
||||
break;
|
||||
}
|
||||
|
||||
if(!imageResult) return;
|
||||
|
||||
(async () => setImageElement(await TextureUtils.generateImage(imageResult.data)))();
|
||||
}, [ productType, productClassId, direction, extraData ]);
|
||||
|
||||
if(!imageElement) return null;
|
||||
if(imageResult?.data) updateImage(imageResult.data);
|
||||
}, [ productType, productClassId, direction, extraData, updateImage ]);
|
||||
|
||||
return <Base classNames={ [ 'furni-image' ] } style={ getStyle } { ...rest } />;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GetRoomEngine, TextureUtils, Vector3d } from '@nitrots/nitro-renderer';
|
||||
import { CSSProperties, FC, useEffect, useMemo, useState } from 'react';
|
||||
import { CSSProperties, FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Base, BaseProps } from '../Base';
|
||||
|
||||
interface LayoutRoomObjectImageViewProps extends BaseProps<HTMLDivElement>
|
||||
@@ -15,6 +15,14 @@ export const LayoutRoomObjectImageView: FC<LayoutRoomObjectImageViewProps> = pro
|
||||
{
|
||||
const { roomId = -1, objectId = 1, category = -1, direction = 2, scale = 1, style = {}, ...rest } = props;
|
||||
const [ imageElement, setImageElement ] = useState<HTMLImageElement>(null);
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
isMounted.current = true;
|
||||
|
||||
return () => { isMounted.current = false; };
|
||||
}, []);
|
||||
|
||||
const getStyle = useMemo(() =>
|
||||
{
|
||||
@@ -42,15 +50,23 @@ export const LayoutRoomObjectImageView: FC<LayoutRoomObjectImageViewProps> = pro
|
||||
useEffect(() =>
|
||||
{
|
||||
const imageResult = GetRoomEngine().getRoomObjectImage(roomId, objectId, category, new Vector3d(direction * 45), 64, {
|
||||
imageReady: async (id, texture, image) => setImageElement(await TextureUtils.generateImage(texture)),
|
||||
imageReady: async (id, texture, image) =>
|
||||
{
|
||||
const img = await TextureUtils.generateImage(texture);
|
||||
|
||||
if(img && isMounted.current) setImageElement(img);
|
||||
},
|
||||
imageFailed: null
|
||||
});
|
||||
|
||||
// needs (roomObjectImage.data.width > 140) || (roomObjectImage.data.height > 200) scale 1
|
||||
|
||||
if(!imageResult) return;
|
||||
|
||||
(async () => setImageElement(await TextureUtils.generateImage(imageResult.data)))();
|
||||
(async () =>
|
||||
{
|
||||
const img = await TextureUtils.generateImage(imageResult.data);
|
||||
|
||||
if(img && isMounted.current) setImageElement(img);
|
||||
})();
|
||||
}, [ roomId, objectId, category, direction, scale ]);
|
||||
|
||||
if(!imageElement) return null;
|
||||
|
||||
@@ -24,15 +24,13 @@ export const LayoutRoomPreviewerView: FC<{
|
||||
const width = elementRef.current.parentElement.clientWidth;
|
||||
const texture = TextureUtils.createRenderTexture(width, height);
|
||||
|
||||
const update = async (ticker: NitroTicker) =>
|
||||
const paintToDOM = () =>
|
||||
{
|
||||
if(!roomPreviewer || !elementRef.current) return;
|
||||
|
||||
roomPreviewer.updatePreviewRoomView();
|
||||
|
||||
const renderingCanvas = roomPreviewer.getRenderingCanvas();
|
||||
|
||||
if(!renderingCanvas.canvasUpdated) return;
|
||||
if(!renderingCanvas) return;
|
||||
|
||||
GetRenderer().render({
|
||||
target: texture,
|
||||
@@ -40,14 +38,29 @@ export const LayoutRoomPreviewerView: FC<{
|
||||
clear: true
|
||||
});
|
||||
|
||||
let canvas = GetRenderer().texture.generateCanvas(texture);
|
||||
const canvas = GetRenderer().texture.generateCanvas(texture);
|
||||
const base64 = canvas.toDataURL('image/png');
|
||||
|
||||
canvas = null;
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
elementRef.current.style.backgroundImage = `url(${ base64 })`;
|
||||
};
|
||||
|
||||
const update = (ticker: NitroTicker) =>
|
||||
{
|
||||
if(!roomPreviewer || !elementRef.current) return;
|
||||
|
||||
roomPreviewer.updatePreviewRoomView();
|
||||
|
||||
const renderingCanvas = roomPreviewer.getRenderingCanvas();
|
||||
|
||||
if(renderingCanvas && renderingCanvas.canvasUpdated)
|
||||
{
|
||||
paintToDOM();
|
||||
}
|
||||
};
|
||||
|
||||
GetTicker().add(update);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() =>
|
||||
@@ -58,7 +71,7 @@ export const LayoutRoomPreviewerView: FC<{
|
||||
|
||||
roomPreviewer.modifyRoomCanvas(width, height);
|
||||
|
||||
update(GetTicker());
|
||||
paintToDOM();
|
||||
});
|
||||
|
||||
roomPreviewer.getRoomCanvas(width, height);
|
||||
|
||||
@@ -34,7 +34,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 =>
|
||||
{
|
||||
@@ -141,7 +140,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:
|
||||
|
||||
+70
@@ -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, UserIdentityView, UserProfileIconView } from '../../../../../common';
|
||||
@@ -55,15 +56,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;
|
||||
});
|
||||
});
|
||||
@@ -180,43 +189,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" />
|
||||
|
||||
@@ -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,24 @@ 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));
|
||||
@@ -80,6 +100,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
return (
|
||||
<>
|
||||
<style>{ TOOLBAR_STYLES }</style>
|
||||
{ youtubeEnabled && <YouTubePlayerView /> }
|
||||
|
||||
{ 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' }` }>
|
||||
@@ -176,6 +197,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="camera" onClick={ () => CreateLinkEvent('camera/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ (isInRoom && youtubeEnabled) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ isMod &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
||||
@@ -268,6 +293,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="camera" onClick={ () => CreateLinkEvent('camera/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ (isInRoom && youtubeEnabled) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ isMod &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export * from './YoutubeTvView';
|
||||
@@ -179,6 +179,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;
|
||||
|
||||
@@ -3,6 +3,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");
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
div {
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
color: $black;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
@@ -308,7 +308,6 @@
|
||||
|
||||
&.stickie-yellow {
|
||||
background-image: url('@/assets/images/room-widgets/stickie-widget/stickie-yellow.png');
|
||||
//background-position: -191px -184px;
|
||||
}
|
||||
|
||||
&.stickie-green {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AvatarEditorFigureCategory, AvatarFigureContainer, AvatarFigurePartType, FigureSetIdsMessageEvent, GetAvatarRenderManager, GetSessionDataManager, GetWardrobeMessageComposer, IAvatarFigureContainer, IFigurePartSet, IPalette, IPartColor, SetType, UserWardrobePageEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { AvatarEditorColorSorter, AvatarEditorPartSorter, AvatarEditorThumbnailsHelper, GetClubMemberLevel, GetConfigurationValue, IAvatarEditorCategory, IAvatarEditorCategoryPartItem, Randomizer, SendMessageComposer } from '../../api';
|
||||
import { AvatarEditorColorSorter, AvatarEditorPartSorter, GetClubMemberLevel, GetConfigurationValue, IAvatarEditorCategory, IAvatarEditorCategoryPartItem, Randomizer, SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
import { useFigureData } from './useFigureData';
|
||||
|
||||
@@ -244,11 +244,6 @@ const useAvatarEditorState = () =>
|
||||
setSavedFigures(savedFigures);
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
AvatarEditorThumbnailsHelper.clearCache();
|
||||
}, [ selectedColorParts ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible) return;
|
||||
|
||||
@@ -11,19 +11,31 @@ const useInventoryBadgesState = () =>
|
||||
const [ needsUpdate, setNeedsUpdate ] = useState(true);
|
||||
const [ badgeCodes, setBadgeCodes ] = useState<string[]>([]);
|
||||
const [ badgeIds, setBadgeIds ] = useState<Map<string, number>>(new Map<string, number>());
|
||||
const [ activeBadgeCodes, setActiveBadgeCodes ] = useState<string[]>([]);
|
||||
const [ activeBadgeCodes, setActiveBadgeCodes ] = useState<(string | null)[]>([]);
|
||||
const [ selectedBadgeCode, setSelectedBadgeCode ] = useState<string>(null);
|
||||
const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility();
|
||||
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
|
||||
|
||||
const maxBadgeCount = GetConfigurationValue<number>('user.badges.max.slots', 5);
|
||||
const localChangeRef = useRef(false);
|
||||
const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0);
|
||||
const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount);
|
||||
const pendingUpdatesRef = useRef(0);
|
||||
const isWearingBadge = (badgeCode: string) => activeBadgeCodes.some(code => code === badgeCode);
|
||||
const canWearBadges = () => (activeBadgeCodes.filter(Boolean).length < maxBadgeCount);
|
||||
|
||||
const sendActiveBadges = (badges: string[]) =>
|
||||
const toFixedSlots = (arr: (string | null)[]): (string | null)[] =>
|
||||
{
|
||||
localChangeRef.current = true;
|
||||
const seen = new Set<string>();
|
||||
return Array.from({ length: maxBadgeCount }, (_, i) =>
|
||||
{
|
||||
const code = arr[i] || null;
|
||||
if(!code || seen.has(code)) return null;
|
||||
seen.add(code);
|
||||
return code;
|
||||
});
|
||||
};
|
||||
|
||||
const sendActiveBadges = (badges: (string | null)[]) =>
|
||||
{
|
||||
pendingUpdatesRef.current++;
|
||||
const composer = new SetActivatedBadgesComposer();
|
||||
for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? '');
|
||||
SendMessageComposer(composer);
|
||||
@@ -33,24 +45,23 @@ const useInventoryBadgesState = () =>
|
||||
{
|
||||
setActiveBadgeCodes(prevValue =>
|
||||
{
|
||||
const newValue = [ ...prevValue ];
|
||||
|
||||
const index = newValue.indexOf(badgeCode);
|
||||
const slots = toFixedSlots(prevValue);
|
||||
const index = slots.indexOf(badgeCode);
|
||||
|
||||
if(index === -1)
|
||||
{
|
||||
if(newValue.length >= maxBadgeCount) return prevValue;
|
||||
const emptySlot = slots.indexOf(null);
|
||||
if(emptySlot === -1) return prevValue;
|
||||
|
||||
newValue.push(badgeCode);
|
||||
slots[emptySlot] = badgeCode;
|
||||
}
|
||||
else
|
||||
{
|
||||
newValue.splice(index, 1);
|
||||
slots[index] = null;
|
||||
}
|
||||
|
||||
sendActiveBadges(newValue);
|
||||
|
||||
return newValue;
|
||||
sendActiveBadges(slots);
|
||||
return slots;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -82,14 +93,15 @@ const useInventoryBadgesState = () =>
|
||||
return newValue;
|
||||
});
|
||||
|
||||
// Skip overwriting activeBadgeCodes if we recently made a local change
|
||||
if(localChangeRef.current)
|
||||
// Skip overwriting activeBadgeCodes if we have pending local changes
|
||||
if(pendingUpdatesRef.current > 0)
|
||||
{
|
||||
localChangeRef.current = false;
|
||||
pendingUpdatesRef.current--;
|
||||
}
|
||||
else
|
||||
{
|
||||
setActiveBadgeCodes(parser.getActiveBadgeCodes());
|
||||
const serverBadges = parser.getActiveBadgeCodes();
|
||||
setActiveBadgeCodes(toFixedSlots(serverBadges));
|
||||
}
|
||||
|
||||
setBadgeCodes(allBadgeCodes);
|
||||
@@ -159,8 +171,7 @@ const useInventoryBadgesState = () =>
|
||||
{
|
||||
setActiveBadgeCodes(prevValue =>
|
||||
{
|
||||
// Build a fixed-size array of maxBadgeCount slots
|
||||
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
|
||||
const slots = toFixedSlots(prevValue);
|
||||
|
||||
// Remove badge if already in another slot
|
||||
const existingIndex = slots.indexOf(badgeCode);
|
||||
@@ -169,11 +180,8 @@ const useInventoryBadgesState = () =>
|
||||
// Place badge at target slot
|
||||
slots[slotIndex] = badgeCode;
|
||||
|
||||
// Compact: remove nulls, keep order
|
||||
const result = slots.filter(Boolean) as string[];
|
||||
|
||||
sendActiveBadges(result);
|
||||
return result;
|
||||
sendActiveBadges(slots);
|
||||
return slots;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -181,10 +189,14 @@ const useInventoryBadgesState = () =>
|
||||
{
|
||||
setActiveBadgeCodes(prevValue =>
|
||||
{
|
||||
const result = prevValue.filter(code => code !== badgeCode);
|
||||
const slots = toFixedSlots(prevValue);
|
||||
const index = slots.indexOf(badgeCode);
|
||||
if(index === -1) return prevValue;
|
||||
|
||||
sendActiveBadges(result);
|
||||
return result;
|
||||
slots[index] = null;
|
||||
|
||||
sendActiveBadges(slots);
|
||||
return slots;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -193,14 +205,14 @@ const useInventoryBadgesState = () =>
|
||||
setActiveBadgeCodes(prevValue =>
|
||||
{
|
||||
if(fromIndex === toIndex) return prevValue;
|
||||
if(fromIndex >= prevValue.length) return prevValue;
|
||||
|
||||
const newValue = [ ...prevValue ];
|
||||
const [ moved ] = newValue.splice(fromIndex, 1);
|
||||
newValue.splice(toIndex, 0, moved);
|
||||
const slots = toFixedSlots(prevValue);
|
||||
const temp = slots[fromIndex];
|
||||
slots[fromIndex] = slots[toIndex];
|
||||
slots[toIndex] = temp;
|
||||
|
||||
sendActiveBadges(newValue);
|
||||
return newValue;
|
||||
sendActiveBadges(slots);
|
||||
return slots;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -210,19 +222,13 @@ const useInventoryBadgesState = () =>
|
||||
{
|
||||
if(fromIndex === toIndex) return prevValue;
|
||||
|
||||
// Build fixed-size array so swap works even with empty slots
|
||||
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
|
||||
|
||||
// Swap the two slots
|
||||
const slots = toFixedSlots(prevValue);
|
||||
const temp = slots[fromIndex];
|
||||
slots[fromIndex] = slots[toIndex];
|
||||
slots[toIndex] = temp;
|
||||
|
||||
// Compact: remove nulls, keep order
|
||||
const result = slots.filter(Boolean) as string[];
|
||||
|
||||
sendActiveBadges(result);
|
||||
return result;
|
||||
sendActiveBadges(slots);
|
||||
return slots;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AchievementNotificationMessageEvent, ActivityPointNotificationMessageEvent, ClubGiftNotificationEvent, ClubGiftSelectedEvent, ConnectionErrorEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, HabboBroadcastMessageEvent, HotelClosedAndOpensEvent, HotelClosesAndWillOpenAtEvent, HotelWillCloseInMinutesEvent, InfoFeedEnableMessageEvent, MaintenanceStatusMessageEvent, ModeratorCautionEvent, ModeratorMessageEvent, MOTDNotificationEvent, NotificationDialogMessageEvent, PetLevelNotificationEvent, PetReceivedMessageEvent, RespectReceivedEvent, RoomEnterEffect, RoomEnterEvent, SimpleAlertMessageEvent, UserBannedMessageEvent, Vector3d, WiredRewardResultMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { AchievementNotificationMessageEvent, ActivityPointNotificationMessageEvent, BadgeReceivedEvent, ClubGiftNotificationEvent, ClubGiftSelectedEvent, ConnectionErrorEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, HabboBroadcastMessageEvent, HotelClosedAndOpensEvent, HotelClosesAndWillOpenAtEvent, HotelWillCloseInMinutesEvent, InfoFeedEnableMessageEvent, MaintenanceStatusMessageEvent, ModeratorCautionEvent, ModeratorMessageEvent, MOTDNotificationEvent, NotificationDialogMessageEvent, PetLevelNotificationEvent, PetReceivedMessageEvent, RespectReceivedEvent, RoomEnterEffect, RoomEnterEvent, SimpleAlertMessageEvent, UserBannedMessageEvent, Vector3d, WiredRewardResultMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api';
|
||||
@@ -14,6 +14,7 @@ const getTimeZeroPadded = (time: number) =>
|
||||
};
|
||||
|
||||
let modDisclaimerTimeout: ReturnType<typeof setTimeout> = null;
|
||||
const recentBadgeNotifications = new Set<string>();
|
||||
|
||||
const useNotificationState = () =>
|
||||
{
|
||||
@@ -67,11 +68,11 @@ const useNotificationState = () =>
|
||||
|
||||
const showNitroAlert = useCallback(() => simpleAlert(null, NotificationAlertType.NITRO), [ simpleAlert ]);
|
||||
|
||||
const showSingleBubble = useCallback((message: string, type: string, imageUrl: string = null, internalLink: string = null) =>
|
||||
const showSingleBubble = useCallback((message: string, type: string, imageUrl: string = null, internalLink: string = null, senderName: string = '') =>
|
||||
{
|
||||
if(bubblesDisabled) return;
|
||||
|
||||
const notificationItem = new NotificationBubbleItem(message, type, imageUrl, internalLink);
|
||||
const notificationItem = new NotificationBubbleItem(message, type, imageUrl, internalLink, senderName);
|
||||
|
||||
setBubbleAlerts(prevValue =>
|
||||
{
|
||||
@@ -219,12 +220,36 @@ const useNotificationState = () =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
const text1 = LocalizeText('achievements.levelup.desc');
|
||||
// Skip if BadgeReceivedEvent already showed a notification for this badge
|
||||
if(recentBadgeNotifications.has(parser.data.badgeCode)) return;
|
||||
|
||||
recentBadgeNotifications.add(parser.data.badgeCode);
|
||||
setTimeout(() => recentBadgeNotifications.delete(parser.data.badgeCode), 3000);
|
||||
|
||||
const badgeName = LocalizeBadgeName(parser.data.badgeCode);
|
||||
const badgeImage = GetSessionDataManager().getBadgeUrl(parser.data.badgeCode);
|
||||
const internalLink = 'questengine/achievements/' + parser.data.category;
|
||||
|
||||
showSingleBubble((text1 + ' ' + badgeName), NotificationBubbleType.ACHIEVEMENT, badgeImage, internalLink);
|
||||
showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.data.badgeCode);
|
||||
});
|
||||
|
||||
useMessageEvent<BadgeReceivedEvent>(BadgeReceivedEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
// Skip if AchievementNotificationMessageEvent already showed a notification for this badge
|
||||
if(recentBadgeNotifications.has(parser.badgeCode)) return;
|
||||
|
||||
recentBadgeNotifications.add(parser.badgeCode);
|
||||
setTimeout(() => recentBadgeNotifications.delete(parser.badgeCode), 3000);
|
||||
|
||||
const badgeName = LocalizeBadgeName(parser.badgeCode);
|
||||
const badgeImage = GetSessionDataManager().getBadgeUrl(parser.badgeCode);
|
||||
// senderName is non-empty only when a staff member awarded the badge
|
||||
// via the `:badge` command. Empty for achievements, catalog buys,
|
||||
// wired rewards, poll rewards, etc.
|
||||
const senderName = parser.senderName || '';
|
||||
|
||||
showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.badgeCode, senderName);
|
||||
});
|
||||
|
||||
useMessageEvent<ClubGiftNotificationEvent>(ClubGiftNotificationEvent, event =>
|
||||
@@ -345,6 +370,9 @@ const useNotificationState = () =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
// Skip badge notifications — handled by BadgeReceivedEvent with "Wear" button
|
||||
if(parser.type === 'badge_received' || parser.type === 'badges' || parser.type.includes('badge')) return;
|
||||
|
||||
showNotification(parser.type, parser.parameters);
|
||||
});
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ const useFurnitureYoutubeWidgetState = () =>
|
||||
onClose();
|
||||
});
|
||||
|
||||
return { objectId, videoId, videoStart, videoEnd, currentVideoState, selectedVideo, playlists, onClose, previous, next, pause, play, selectVideo };
|
||||
return { objectId, videoId, videoStart, videoEnd, currentVideoState, selectedVideo, playlists, hasControl, onClose, previous, next, pause, play, selectVideo };
|
||||
};
|
||||
|
||||
export const useFurnitureYoutubeWidget = useFurnitureYoutubeWidgetState;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { CreateLinkEvent } from '@nitrots/nitro-renderer';
|
||||
import { useBetween } from 'use-between';
|
||||
import { LocalizeText } from '../api';
|
||||
import { useNotification } from './notification';
|
||||
|
||||
const YOUTUBE_REGEX = /(?:http:\/\/|https:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?.*v=|shorts\/)?([a-zA-Z0-9_-]{11})/;
|
||||
|
||||
const useOnClickChatState = () =>
|
||||
{
|
||||
const { showConfirm = null } = useNotification();
|
||||
@@ -17,19 +14,11 @@ const useOnClickChatState = () =>
|
||||
event.preventDefault();
|
||||
|
||||
const url = event.target.href;
|
||||
const youtubeMatch = url.match(YOUTUBE_REGEX);
|
||||
|
||||
if(youtubeMatch)
|
||||
showConfirm(LocalizeText('chat.confirm.openurl', [ 'url' ], [ url ]), () =>
|
||||
{
|
||||
CreateLinkEvent('youtube-tv/show/' + youtubeMatch[1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
showConfirm(LocalizeText('chat.confirm.openurl', [ 'url' ], [ url ]), () =>
|
||||
{
|
||||
window.open(url, '_blank');
|
||||
}, null, null, null, LocalizeText('generic.alert.title'), null, 'link');
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
}, null, null, null, LocalizeText('generic.alert.title'), null, 'link');
|
||||
};
|
||||
|
||||
return { onClickChat };
|
||||
|
||||
@@ -172,7 +172,7 @@ const InfiniteGridItem = forwardRef<HTMLDivElement, {
|
||||
: (itemColor ? 'border-card-grid-item-border' : 'border-card-grid-item-border bg-card-grid-item'),
|
||||
(itemUniqueSoldout || (itemUniqueNumber > 0)) && 'unique-item',
|
||||
itemUniqueSoldout && 'sold-out',
|
||||
itemUnseen && ' bg-green-500 bg-opacity-40',
|
||||
itemUnseen && ' animate-pulse-glow-gold border-yellow-400/60',
|
||||
className
|
||||
) }
|
||||
style={ styleNames(
|
||||
|
||||
+5
-1
@@ -144,7 +144,11 @@ module.exports = {
|
||||
'grid-rows-11',
|
||||
'grid-rows-12',
|
||||
'justify-end',
|
||||
'items-end'
|
||||
'items-end',
|
||||
'animate-pulse-glow',
|
||||
'animate-pulse-glow-red',
|
||||
'animate-drop-settle',
|
||||
'animate-pulse-glow-gold'
|
||||
],
|
||||
darkMode: 'class',
|
||||
variants: {
|
||||
|
||||
+3
-3
@@ -2,14 +2,13 @@ import react from '@vitejs/plugin-react';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
const legacyRendererRoot = resolve(__dirname, '..', 'renderer3');
|
||||
const legacyRendererRoot = resolve(__dirname, '..', 'renderer');
|
||||
const currentRendererRoot = resolve(__dirname, '..', 'Nitro_Render_V3');
|
||||
const rendererRoot = existsSync(currentRendererRoot) ? currentRendererRoot : legacyRendererRoot;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [ react(), tsconfigPaths() ],
|
||||
plugins: [ react() ],
|
||||
server: {
|
||||
fs: {
|
||||
allow: [
|
||||
@@ -25,6 +24,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'~': resolve(__dirname, 'node_modules'),
|
||||
|
||||
Reference in New Issue
Block a user