feat: add watch and voxel rooms
This commit is contained in:
Generated
+7
@@ -39,6 +39,7 @@
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.7.1",
|
||||
"hls.js": "^1.6.17",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
@@ -8949,6 +8950,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.17",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.17.tgz",
|
||||
"integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/documents/model.test.ts src/botsu/documents/document-share.test.ts src/botsu/documents/document-sync-bridge.test.ts src/botsu/documents/editor.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/background-options.test.ts src/botsu/start/cookie-controller.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/wikipedia/model.test.ts src/botsu/wikipedia/wikipedia-scraper.test.ts src/botsu/openstreetmap/geocoder.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
|
||||
"test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/documents/model.test.ts src/botsu/documents/document-share.test.ts src/botsu/documents/document-sync-bridge.test.ts src/botsu/documents/editor.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/background-options.test.ts src/botsu/start/cookie-controller.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/voxel/model.test.ts src/botsu/wikipedia/model.test.ts src/botsu/wikipedia/wikipedia-scraper.test.ts src/botsu/openstreetmap/geocoder.test.ts src/botsu/watch/model.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
|
||||
"typecheck:botsu": "../../node_modules/.bin/tsc -p tsconfig.botsu.json && ../../node_modules/.bin/tsc -p tsconfig.botsu-ui.json",
|
||||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
@@ -65,6 +65,7 @@
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.7.1",
|
||||
"hls.js": "^1.6.17",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
|
||||
@@ -120,6 +120,56 @@ export function CreateRoomTypeSelector({
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomType.VoxelRoom ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomType.VoxelRoom}
|
||||
onClick={() => onSelect(CreateRoomType.VoxelRoom)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomType.VoxelRoom)} />}
|
||||
after={value === CreateRoomType.VoxelRoom && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||
Voxel Room
|
||||
</Text>
|
||||
<Text size="T300" priority="300" truncate>
|
||||
- Collaborative voxel and Minecraft blockstate editor.
|
||||
</Text>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomType.WatchRoom ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomType.WatchRoom}
|
||||
onClick={() => onSelect(CreateRoomType.WatchRoom)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomType.WatchRoom)} />}
|
||||
after={value === CreateRoomType.WatchRoom && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||
Watch Room
|
||||
</Text>
|
||||
<Text size="T300" priority="300" truncate>
|
||||
- Synchronized theater with Matrix chat.
|
||||
</Text>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export enum CreateRoomType {
|
||||
VoiceRoom = 'voice',
|
||||
DocumentRoom = 'document',
|
||||
DrawingRoom = 'drawing',
|
||||
VoxelRoom = 'voxel',
|
||||
WatchRoom = 'watch',
|
||||
}
|
||||
|
||||
export enum CreateRoomAccess {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
import { CreateRoomAccess } from './types';
|
||||
import { createEmptyBotsuWatchSession } from '@botsu/protocol';
|
||||
|
||||
export const createRoomCreationContent = (
|
||||
type: RoomType | undefined,
|
||||
@@ -98,6 +99,18 @@ export const createVoiceRoomPowerLevelsOverride = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const createWatchRoomPowerLevelsOverride = () => ({
|
||||
events: {
|
||||
[StateEvent.BotsuWatchSession]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const createWatchRoomState = () => ({
|
||||
type: StateEvent.BotsuWatchSession,
|
||||
state_key: '',
|
||||
content: createEmptyBotsuWatchSession(),
|
||||
});
|
||||
|
||||
export type CreateRoomData = {
|
||||
version: string;
|
||||
type?: RoomType;
|
||||
@@ -126,6 +139,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
initialState.push(createRoomCallState());
|
||||
}
|
||||
|
||||
if (data.type === RoomType.Watch) {
|
||||
initialState.push(createWatchRoomState());
|
||||
}
|
||||
|
||||
initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock));
|
||||
|
||||
const options: ICreateRoomOpts = {
|
||||
@@ -139,7 +156,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
data.additionalCreators
|
||||
),
|
||||
power_level_content_override:
|
||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
||||
data.type === RoomType.Call
|
||||
? createVoiceRoomPowerLevelsOverride()
|
||||
: data.type === RoomType.Watch
|
||||
? createWatchRoomPowerLevelsOverride()
|
||||
: undefined,
|
||||
initial_state: initialState,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Text,
|
||||
TextArea,
|
||||
} from 'folds';
|
||||
import { parseBotsuDocumentObject } from '@botsu/protocol';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import {
|
||||
@@ -42,34 +43,33 @@ import {
|
||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { CreateRoomTypeSelector } from '../../components/create-room/CreateRoomTypeSelector';
|
||||
import { getRoomIconSrc } from '../../utils/room';
|
||||
import { parseBotsuDocumentObject } from '@botsu/protocol';
|
||||
|
||||
const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomType) => {
|
||||
const isVoiceRoom = type === CreateRoomType.VoiceRoom;
|
||||
const isDocumentRoom = type === CreateRoomType.DocumentRoom;
|
||||
const isDrawingRoom = type === CreateRoomType.DrawingRoom;
|
||||
const isVoxelRoom = type === CreateRoomType.VoxelRoom;
|
||||
const isWatchRoom = type === CreateRoomType.WatchRoom;
|
||||
|
||||
let joinRule: JoinRule = JoinRule.Public;
|
||||
if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted;
|
||||
if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock;
|
||||
|
||||
return getRoomIconSrc(
|
||||
Icons,
|
||||
isVoiceRoom
|
||||
? RoomType.Call
|
||||
: isDocumentRoom
|
||||
? RoomType.Document
|
||||
: isDrawingRoom
|
||||
? RoomType.Drawing
|
||||
: undefined,
|
||||
joinRule
|
||||
);
|
||||
let roomType: RoomType | undefined;
|
||||
if (isVoiceRoom) roomType = RoomType.Call;
|
||||
if (isDocumentRoom) roomType = RoomType.Document;
|
||||
if (isDrawingRoom) roomType = RoomType.Drawing;
|
||||
if (isVoxelRoom) roomType = RoomType.Voxel;
|
||||
if (isWatchRoom) roomType = RoomType.Watch;
|
||||
return getRoomIconSrc(Icons, roomType, joinRule);
|
||||
};
|
||||
|
||||
const getCreateRoomTypeToIcon = (type: CreateRoomType) => {
|
||||
if (type === CreateRoomType.VoiceRoom) return Icons.VolumeHigh;
|
||||
if (type === CreateRoomType.DocumentRoom) return Icons.File;
|
||||
if (type === CreateRoomType.DrawingRoom) return Icons.Pencil;
|
||||
if (type === CreateRoomType.VoxelRoom) return Icons.Hash;
|
||||
if (type === CreateRoomType.WatchRoom) return Icons.Play;
|
||||
return Icons.Hash;
|
||||
};
|
||||
|
||||
@@ -155,6 +155,8 @@ export function CreateRoomForm({
|
||||
if (type === CreateRoomType.VoiceRoom) roomType = RoomType.Call;
|
||||
if (type === CreateRoomType.DocumentRoom) roomType = RoomType.Document;
|
||||
if (type === CreateRoomType.DrawingRoom) roomType = RoomType.Drawing;
|
||||
if (type === CreateRoomType.VoxelRoom) roomType = RoomType.Voxel;
|
||||
if (type === CreateRoomType.WatchRoom) roomType = RoomType.Watch;
|
||||
|
||||
create({
|
||||
version: selectedRoomVersion,
|
||||
@@ -169,11 +171,11 @@ export function CreateRoomForm({
|
||||
allowFederation: federation,
|
||||
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
|
||||
}).then(async (roomId) => {
|
||||
if (type === CreateRoomType.DocumentRoom) {
|
||||
if (type === CreateRoomType.DocumentRoom || type === CreateRoomType.VoxelRoom) {
|
||||
try {
|
||||
const token = await mx.getOpenIdToken();
|
||||
const draftId = `doc_${crypto.randomUUID()}`;
|
||||
console.log('[BotsuDocumentRoom] creating shared document, draftId=', draftId);
|
||||
const voxelRoom = type === CreateRoomType.VoxelRoom;
|
||||
const draftId = `doc_${voxelRoom ? 'voxel_' : ''}${crypto.randomUUID()}`;
|
||||
const response = await fetch('/presence/documents/import-local', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -183,26 +185,28 @@ export function CreateRoomForm({
|
||||
body: JSON.stringify({
|
||||
localDraftId: draftId,
|
||||
title: roomName,
|
||||
icon: 'file-text',
|
||||
icon: voxelRoom ? 'house' : 'file-text',
|
||||
body: '',
|
||||
}),
|
||||
});
|
||||
console.log('[BotsuDocumentRoom] import-local response:', response.status);
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
console.log('[BotsuDocumentRoom] payload:', payload);
|
||||
const object = parseBotsuDocumentObject(payload.object);
|
||||
await mx.sendStateEvent(roomId, StateEvent.BotsuDocument, {
|
||||
version: 1,
|
||||
objectId: object.objectId,
|
||||
}, '');
|
||||
console.log('[BotsuDocumentRoom] state event sent, objectId=', object.objectId);
|
||||
await mx.sendStateEvent(
|
||||
roomId,
|
||||
voxelRoom ? StateEvent.BotsuVoxel : StateEvent.BotsuDocument,
|
||||
{
|
||||
version: 1,
|
||||
objectId: object.objectId,
|
||||
},
|
||||
''
|
||||
);
|
||||
} else {
|
||||
const text = await response.text().catch(() => '?');
|
||||
console.error('[BotsuDocumentRoom] import-local failed:', response.status, text);
|
||||
console.error('[BotsuRoom] shared object creation failed:', response.status, text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BotsuDocumentRoom] failed to create shared document:', err);
|
||||
console.error('[BotsuRoom] failed to create shared object:', err);
|
||||
}
|
||||
}
|
||||
if (alive()) {
|
||||
|
||||
@@ -62,10 +62,14 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
||||
{type === CreateRoomType.VoiceRoom
|
||||
? 'New Voice Room'
|
||||
: type === CreateRoomType.DocumentRoom
|
||||
? 'New Document room'
|
||||
: type === CreateRoomType.DrawingRoom
|
||||
? 'New Drawing room'
|
||||
: 'New Chat room'}
|
||||
? 'New Document room'
|
||||
: type === CreateRoomType.DrawingRoom
|
||||
? 'New Drawing room'
|
||||
: type === CreateRoomType.VoxelRoom
|
||||
? 'New Voxel room'
|
||||
: type === CreateRoomType.WatchRoom
|
||||
? 'New Watch room'
|
||||
: 'New Chat room'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -27,7 +27,9 @@ import { BotsuDrawingRoomView } from '../../../botsu/drawing/BotsuDrawingRoomVie
|
||||
import { BotsuDrawingTransport } from '../../../botsu/drawing/BotsuDrawingTransport';
|
||||
import { DrawingToolProvider } from '../../../botsu/drawing/DrawingToolContext';
|
||||
import { PixelCanvasProvider } from '../../../botsu/start/PixelCanvasContext';
|
||||
import { isDocumentRoom, isDrawingRoom } from '../../utils/room';
|
||||
import { BotsuWatchRoomView } from '../../../botsu/watch';
|
||||
import { BotsuVoxelRoomView } from '../../../botsu/voxel';
|
||||
import { isDocumentRoom, isDrawingRoom, isVoxelRoom, isWatchRoom } from '../../utils/room';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
@@ -60,6 +62,8 @@ export function Room() {
|
||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||
const documentView = isDocumentRoom(room);
|
||||
const drawingView = isDrawingRoom(room);
|
||||
const voxelView = isVoxelRoom(room);
|
||||
const watchView = isWatchRoom(room);
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
@@ -72,7 +76,7 @@ export function Room() {
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!callView && !documentView && !drawingView && (
|
||||
{!callView && !documentView && !drawingView && !voxelView && !watchView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes">
|
||||
@@ -91,6 +95,23 @@ export function Room() {
|
||||
</Box>
|
||||
</DocumentEditorProvider>
|
||||
)}
|
||||
{!callView && watchView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes">
|
||||
<BotsuWatchRoomView room={room} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!callView && voxelView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<BotsuDocumentTransport />
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes">
|
||||
<BotsuVoxelRoomView room={room} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!callView && drawingView && (
|
||||
<PixelCanvasProvider>
|
||||
@@ -114,7 +135,7 @@ export function Room() {
|
||||
<CallChatView />
|
||||
</>
|
||||
)}
|
||||
{!callView && screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
{!callView && !watchView && !voxelView && screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
|
||||
@@ -12,9 +12,13 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||
import { CreateRoomForm } from '../../../features/create-room';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CreateRoomType } from '../../../components/create-room';
|
||||
|
||||
export function HomeCreateRoom() {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const watchRoom = searchParams.get('type') === CreateRoomType.WatchRoom;
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
@@ -40,11 +44,24 @@ export function HomeCreateRoom() {
|
||||
<PageHeroSection>
|
||||
<Box direction="Column" gap="700">
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={Icons.Hash} />}
|
||||
title="Create Room"
|
||||
subTitle="Build a Room for Real-Time Conversations."
|
||||
icon={<Icon size="600" src={watchRoom ? Icons.Play : Icons.Hash} />}
|
||||
title={watchRoom ? 'Create Watch Room' : 'Create Room'}
|
||||
subTitle={
|
||||
watchRoom
|
||||
? 'Watch one stream together with synchronized playback and Matrix chat.'
|
||||
: 'Build a Room for Real-Time Conversations.'
|
||||
}
|
||||
/>
|
||||
<CreateRoomForm
|
||||
onCreate={navigateRoom}
|
||||
defaultType={
|
||||
searchParams.get('type') === CreateRoomType.WatchRoom
|
||||
? CreateRoomType.WatchRoom
|
||||
: searchParams.get('type') === CreateRoomType.VoxelRoom
|
||||
? CreateRoomType.VoxelRoom
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<CreateRoomForm onCreate={navigateRoom} />
|
||||
</Box>
|
||||
</PageHeroSection>
|
||||
</PageContentCenter>
|
||||
|
||||
@@ -95,7 +95,9 @@ export const isUnsupportedRoom = (room: Room | null): boolean => {
|
||||
type !== undefined &&
|
||||
type !== RoomType.Space &&
|
||||
type !== RoomType.Document &&
|
||||
type !== RoomType.Drawing
|
||||
type !== RoomType.Drawing &&
|
||||
type !== RoomType.Voxel &&
|
||||
type !== RoomType.Watch
|
||||
);
|
||||
};
|
||||
|
||||
@@ -113,6 +115,20 @@ export const isDrawingRoom = (room: Room | null): boolean => {
|
||||
return event.getContent().type === RoomType.Drawing;
|
||||
};
|
||||
|
||||
export const isVoxelRoom = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return false;
|
||||
return event.getContent().type === RoomType.Voxel;
|
||||
};
|
||||
|
||||
export const isWatchRoom = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return false;
|
||||
return event.getContent().type === RoomType.Watch;
|
||||
};
|
||||
|
||||
export function isValidChild(mEvent: MatrixEvent): boolean {
|
||||
return (
|
||||
mEvent.getType() === StateEvent.SpaceChild &&
|
||||
@@ -331,6 +347,10 @@ export const getRoomIconSrc = (
|
||||
return icons.Pencil;
|
||||
}
|
||||
|
||||
if (roomType === RoomType.Voxel) return icons.Hash;
|
||||
|
||||
if (roomType === RoomType.Watch) return icons.Play;
|
||||
|
||||
if (joinRule === JoinRule.Public) return icons.HashGlobe;
|
||||
if (
|
||||
joinRule === JoinRule.Invite ||
|
||||
|
||||
@@ -8,7 +8,30 @@ test('member catalogue exposes the collaborative suite without administration',
|
||||
|
||||
assert.deepEqual(
|
||||
apps.map((app) => app.id),
|
||||
['godot', 'discussions', 'documents', 'tables', 'files', 'transfers', 'services', 'generations-audiovisuel', 'generations-vision', 'generations-textuel', 'emoji-tcg', 'radio', 'shop', 'voxel', 'dessin', 'cinema', 'casino', 'bourse', 'peertube', 'wikipedia', 'pets', 'openstreetmap']
|
||||
[
|
||||
'godot',
|
||||
'discussions',
|
||||
'documents',
|
||||
'tables',
|
||||
'files',
|
||||
'transfers',
|
||||
'services',
|
||||
'generations-audiovisuel',
|
||||
'generations-vision',
|
||||
'generations-textuel',
|
||||
'emoji-tcg',
|
||||
'radio',
|
||||
'shop',
|
||||
'voxel',
|
||||
'dessin',
|
||||
'cinema',
|
||||
'casino',
|
||||
'bourse',
|
||||
'peertube',
|
||||
'wikipedia',
|
||||
'pets',
|
||||
'openstreetmap',
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
apps.some((app) => app.id === 'administration'),
|
||||
@@ -42,9 +65,17 @@ test('catalogue records live and planned applications explicitly', () => {
|
||||
assert.equal(getApp('transfers')?.launch.mode, 'external');
|
||||
assert.equal(getApp('radio')?.availability, 'planned');
|
||||
assert.equal(getApp('shop')?.availability, 'planned');
|
||||
assert.equal(getApp('voxel')?.availability, 'planned');
|
||||
assert.equal(getApp('voxel')?.availability, 'available');
|
||||
assert.deepEqual(getApp('voxel')?.launch, {
|
||||
mode: 'native',
|
||||
path: '/home/create/?type=voxel',
|
||||
});
|
||||
assert.equal(getApp('dessin')?.availability, 'planned');
|
||||
assert.equal(getApp('cinema')?.availability, 'planned');
|
||||
assert.equal(getApp('cinema')?.availability, 'available');
|
||||
assert.deepEqual(getApp('cinema')?.launch, {
|
||||
mode: 'native',
|
||||
path: '/home/create/?type=watch',
|
||||
});
|
||||
assert.equal(getApp('casino')?.availability, 'planned');
|
||||
assert.equal(getApp('bourse')?.availability, 'planned');
|
||||
assert.equal(getApp('peertube')?.availability, 'planned');
|
||||
|
||||
@@ -114,7 +114,7 @@ export const botsuApps: readonly BotsuApp[] = [
|
||||
{
|
||||
id: 'generations-vision',
|
||||
label: 'Vision',
|
||||
description: 'Génération et analyse d\'images.',
|
||||
description: "Génération et analyse d'images.",
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'planned',
|
||||
launch: { mode: 'native', path: '/botsu/generations/vision/' },
|
||||
@@ -153,11 +153,11 @@ export const botsuApps: readonly BotsuApp[] = [
|
||||
},
|
||||
{
|
||||
id: 'voxel',
|
||||
label: 'Voxel',
|
||||
description: 'Édition et création voxel.',
|
||||
label: 'Voxel Room',
|
||||
description: 'Atelier voxel et Minecraft collaboratif en temps réel.',
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'planned',
|
||||
launch: { mode: 'native', path: '/botsu/voxel/' },
|
||||
availability: 'available',
|
||||
launch: { mode: 'native', path: '/home/create/?type=voxel' },
|
||||
},
|
||||
{
|
||||
id: 'dessin',
|
||||
@@ -169,11 +169,11 @@ export const botsuApps: readonly BotsuApp[] = [
|
||||
},
|
||||
{
|
||||
id: 'cinema',
|
||||
label: 'Cinéma',
|
||||
description: 'Films et séries.',
|
||||
label: 'Watch Room',
|
||||
description: 'Théâtre partagé avec lecture synchronisée et discussion Matrix.',
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'planned',
|
||||
launch: { mode: 'native', path: '/botsu/cinema/' },
|
||||
availability: 'available',
|
||||
launch: { mode: 'native', path: '/home/create/?type=watch' },
|
||||
},
|
||||
{
|
||||
id: 'casino',
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
.botsu-voxel-room {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) 42px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
color: var(--tc-surface-normal, #e8e8e8);
|
||||
background: var(--bg-surface-low, #050505);
|
||||
}
|
||||
|
||||
.botsu-voxel-room--empty {
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
color: var(--tc-surface-low, #a3a3a3);
|
||||
}
|
||||
|
||||
.botsu-voxel-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.botsu-voxel-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.botsu-voxel-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #a3a3a3;
|
||||
font: 500 13px/1.4 Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.botsu-voxel-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 0 10px 0 14px;
|
||||
color: var(--tc-surface-low, #a3a3a3);
|
||||
background: var(--bg-surface, #111111);
|
||||
border-top: 1px solid var(--bg-surface-border, #303030);
|
||||
font: 500 11px/1 Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.botsu-voxel-statusgroup,
|
||||
.botsu-voxel-modes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.botsu-voxel-statusgroup {
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-voxel-sync {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #737373;
|
||||
}
|
||||
|
||||
.botsu-voxel-sync--online {
|
||||
background: #a3e635;
|
||||
box-shadow: 0 0 0 2px rgb(163 230 53 / 12%);
|
||||
}
|
||||
|
||||
.botsu-voxel-sync--error {
|
||||
background: #fb7185;
|
||||
}
|
||||
|
||||
.botsu-voxel-separator {
|
||||
width: 1px;
|
||||
height: 13px;
|
||||
flex: 0 0 auto;
|
||||
background: var(--bg-surface-border, #303030);
|
||||
}
|
||||
|
||||
.botsu-voxel-project-name {
|
||||
overflow: hidden;
|
||||
color: var(--tc-surface-normal, #e8e8e8);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.botsu-voxel-modes {
|
||||
flex: 0 0 auto;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--bg-surface-low, #080808);
|
||||
border: 1px solid var(--bg-surface-border, #303030);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.botsu-voxel-modes button {
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
color: var(--tc-surface-low, #a3a3a3);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-voxel-modes button:hover {
|
||||
color: var(--tc-surface-normal, #e8e8e8);
|
||||
background: var(--bg-surface-hover, #202020);
|
||||
}
|
||||
|
||||
.botsu-voxel-modes button.is-active {
|
||||
color: var(--tc-primary-normal, #050505);
|
||||
background: var(--bg-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.botsu-voxel-statusbar {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.botsu-voxel-project-name,
|
||||
.botsu-voxel-statusgroup .botsu-voxel-separator:nth-of-type(2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.botsu-voxel-modes button {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { parseBotsuDocumentReference, type BotsuDocumentObjectId } from '@botsu/protocol';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { useDocumentSyncBridge } from '../documents/DocumentSyncContext';
|
||||
import type { BotsuSharedDocumentState } from '../documents/document-sync-bridge';
|
||||
import {
|
||||
applyVoxelierProject,
|
||||
getVoxelierRoomCode,
|
||||
parseVoxelierProject,
|
||||
readVoxelierProject,
|
||||
VOXELIER_BLOCKSTATES_MAP,
|
||||
type VoxelierProject,
|
||||
} from './model';
|
||||
import './BotsuVoxelRoomView.css';
|
||||
|
||||
type VoxelierProjectMessage = {
|
||||
type: 'project';
|
||||
clientId: string;
|
||||
project: unknown;
|
||||
};
|
||||
|
||||
type VoxelierCursorMessage = {
|
||||
type: 'cursor';
|
||||
clientId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type VoxelierPresenceMessage = {
|
||||
type: 'presence';
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
type VoxelierMessage =
|
||||
| VoxelierProjectMessage
|
||||
| VoxelierCursorMessage
|
||||
| VoxelierPresenceMessage
|
||||
| { type: 'leave'; clientId: string };
|
||||
|
||||
type VoxelAwarenessState = {
|
||||
user?: { name?: string; color?: string };
|
||||
voxel?: {
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
sentAt?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const LOCAL_YJS_ORIGIN = Symbol('botsu.voxel.local');
|
||||
const BLOCKSTATE_STORAGE_KEY = 'voxelier-blockstates-v1';
|
||||
|
||||
const readRoomVoxelObjectId = (room: Room): BotsuDocumentObjectId | undefined => {
|
||||
try {
|
||||
const event = room.currentState.getStateEvents(StateEvent.BotsuVoxel, '');
|
||||
if (!event) return undefined;
|
||||
return parseBotsuDocumentReference(event.getContent()).objectId;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const generateUserColor = (seed: string): string => {
|
||||
const colors = ['#67e8f9', '#a3e635', '#fbbf24', '#f472b6', '#c084fc', '#fb7185', '#38bdf8'];
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 31 + seed.charCodeAt(index)) % 1_000_003;
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
};
|
||||
|
||||
const isVoxelierMessage = (value: unknown): value is VoxelierMessage => {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const message = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof message.type === 'string' &&
|
||||
['project', 'cursor', 'presence', 'leave'].includes(message.type) &&
|
||||
typeof message.clientId === 'string'
|
||||
);
|
||||
};
|
||||
|
||||
const mergeBlockstateStore = (frameWindow: Window, projectId: string, serialized: string): void => {
|
||||
try {
|
||||
const projectState = JSON.parse(serialized) as unknown;
|
||||
const stored = frameWindow.localStorage.getItem(BLOCKSTATE_STORAGE_KEY);
|
||||
const current = stored ? (JSON.parse(stored) as Record<string, unknown>) : {};
|
||||
const byProject =
|
||||
typeof current.byProject === 'object' && current.byProject !== null
|
||||
? (current.byProject as Record<string, unknown>)
|
||||
: {};
|
||||
const next = { version: 1, byProject: { ...byProject, [projectId]: projectState } };
|
||||
frameWindow.localStorage.setItem(BLOCKSTATE_STORAGE_KEY, JSON.stringify(next));
|
||||
const updateEvent = frameWindow.document.createEvent('CustomEvent');
|
||||
updateEvent.initCustomEvent('voxelier:blockstates-updated', false, false, next);
|
||||
frameWindow.dispatchEvent(updateEvent);
|
||||
} catch {
|
||||
// Invalid legacy blockstate data should not stop the voxel editor.
|
||||
}
|
||||
};
|
||||
|
||||
const postProject = (
|
||||
channel: BroadcastChannel,
|
||||
project: VoxelierProject,
|
||||
clientId: string,
|
||||
name: string,
|
||||
color: string
|
||||
): void => {
|
||||
channel.postMessage({
|
||||
type: 'project',
|
||||
clientId,
|
||||
name,
|
||||
color,
|
||||
project,
|
||||
activeProject: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
updatedAt: project.updated_at,
|
||||
width: project.width,
|
||||
height: project.height,
|
||||
depth: project.depth,
|
||||
cellCount: Object.keys(project.cells).length,
|
||||
},
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const forwardAwarenessState = (
|
||||
channel: BroadcastChannel,
|
||||
clientId: number,
|
||||
state: VoxelAwarenessState
|
||||
): void => {
|
||||
const name = state.user?.name ?? 'Builder';
|
||||
const color = state.user?.color ?? '#67e8f9';
|
||||
const { voxel } = state;
|
||||
const activeProject = voxel?.projectId
|
||||
? { id: voxel.projectId, name: voxel.projectName ?? 'Voxel Project' }
|
||||
: undefined;
|
||||
if (typeof voxel?.x === 'number' && typeof voxel.y === 'number') {
|
||||
channel.postMessage({
|
||||
type: 'cursor',
|
||||
clientId: `botsu-awareness-${clientId}`,
|
||||
name,
|
||||
color,
|
||||
activeProject,
|
||||
x: voxel.x,
|
||||
y: voxel.y,
|
||||
sentAt: voxel.sentAt ?? Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
channel.postMessage({
|
||||
type: 'presence',
|
||||
clientId: `botsu-awareness-${clientId}`,
|
||||
name,
|
||||
color,
|
||||
activeProject,
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
export function BotsuVoxelRoomView({ room }: { room: Room }) {
|
||||
const mx = useMatrixClient();
|
||||
const documentSync = useDocumentSyncBridge();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const channelRef = useRef<BroadcastChannel>();
|
||||
const lastIframeProjectRef = useRef<VoxelierProject>();
|
||||
const [objectId, setObjectId] = useState<BotsuDocumentObjectId | undefined>(() =>
|
||||
readRoomVoxelObjectId(room)
|
||||
);
|
||||
const [syncState, setSyncState] = useState<BotsuSharedDocumentState>({ status: 'idle' });
|
||||
const [ydocReady, setYdocReady] = useState(false);
|
||||
const [frameReady, setFrameReady] = useState(false);
|
||||
const [project, setProject] = useState<VoxelierProject>();
|
||||
const [onlineCount, setOnlineCount] = useState(1);
|
||||
const [mode, setMode] = useState<'voxel' | 'minecraft'>('voxel');
|
||||
|
||||
useEffect(() => {
|
||||
const updateObjectId = () => setObjectId(readRoomVoxelObjectId(room));
|
||||
room.on(RoomStateEvent.Events, updateObjectId);
|
||||
const poll = window.setTimeout(updateObjectId, 1_500);
|
||||
return () => {
|
||||
room.removeListener(RoomStateEvent.Events, updateObjectId);
|
||||
window.clearTimeout(poll);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!objectId) {
|
||||
setSyncState({ status: 'idle' });
|
||||
setYdocReady(false);
|
||||
return undefined;
|
||||
}
|
||||
setYdocReady(false);
|
||||
return documentSync.activate(objectId, (next) => {
|
||||
setSyncState(next);
|
||||
if (next.status === 'online' || next.status === 'syncing') setYdocReady(true);
|
||||
});
|
||||
}, [documentSync, objectId]);
|
||||
|
||||
const ydoc = useMemo(() => {
|
||||
if (!objectId || !ydocReady) return undefined;
|
||||
return documentSync.getYDoc();
|
||||
}, [documentSync, objectId, ydocReady]);
|
||||
|
||||
const awareness = useMemo(() => {
|
||||
if (!objectId || !ydocReady) return undefined;
|
||||
return documentSync.getAwareness();
|
||||
}, [documentSync, objectId, ydocReady]);
|
||||
|
||||
const roomCode = useMemo(
|
||||
() => (objectId ? getVoxelierRoomCode(objectId) : undefined),
|
||||
[objectId]
|
||||
);
|
||||
const user = useMemo(() => {
|
||||
const userId = mx.getUserId() ?? 'unknown';
|
||||
const displayName = mx.getUser(userId)?.displayName ?? userId;
|
||||
return { name: displayName.split(':')[0], color: generateUserColor(userId) };
|
||||
}, [mx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!awareness) return;
|
||||
awareness.setLocalState({ user });
|
||||
}, [awareness, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ydoc || !awareness || !roomCode || typeof BroadcastChannel === 'undefined')
|
||||
return undefined;
|
||||
const channel = new BroadcastChannel(`voxelier:${roomCode}`);
|
||||
channelRef.current = channel;
|
||||
const parentClientId = `botsu-parent-${awareness.clientID}`;
|
||||
|
||||
const publishSharedProject = () => {
|
||||
const shared = readVoxelierProject(ydoc);
|
||||
if (!shared) return false;
|
||||
lastIframeProjectRef.current = shared;
|
||||
setProject(shared);
|
||||
postProject(channel, shared, parentClientId, user.name, user.color);
|
||||
return true;
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
if (!isVoxelierMessage(event.data) || event.data.clientId.startsWith('botsu-')) return;
|
||||
if (event.data.type === 'presence') {
|
||||
publishSharedProject();
|
||||
return;
|
||||
}
|
||||
if (event.data.type === 'cursor') {
|
||||
awareness.setLocalStateField('voxel', {
|
||||
...awareness.getLocalState()?.voxel,
|
||||
x: Math.max(0, Math.min(1, event.data.x)),
|
||||
y: Math.max(0, Math.min(1, event.data.y)),
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.data.type !== 'project') return;
|
||||
try {
|
||||
const next = parseVoxelierProject(event.data.project);
|
||||
const base = lastIframeProjectRef.current;
|
||||
const shared = readVoxelierProject(ydoc);
|
||||
if (!base && shared) {
|
||||
publishSharedProject();
|
||||
return;
|
||||
}
|
||||
applyVoxelierProject(ydoc, next, base, LOCAL_YJS_ORIGIN);
|
||||
lastIframeProjectRef.current = next;
|
||||
setProject(next);
|
||||
awareness.setLocalStateField('voxel', {
|
||||
projectId: next.id,
|
||||
projectName: next.name,
|
||||
});
|
||||
} catch {
|
||||
// Ignore malformed cross-context messages.
|
||||
}
|
||||
};
|
||||
|
||||
const handleYjsTransaction = (transaction: Y.Transaction) => {
|
||||
if (transaction.origin === LOCAL_YJS_ORIGIN) return;
|
||||
publishSharedProject();
|
||||
};
|
||||
ydoc.on('afterTransaction', handleYjsTransaction);
|
||||
|
||||
const handleAwarenessChange = (changes: {
|
||||
added: number[];
|
||||
updated: number[];
|
||||
removed: number[];
|
||||
}) => {
|
||||
const states = awareness.getStates() as Map<number, VoxelAwarenessState>;
|
||||
setOnlineCount(Math.max(1, states.size));
|
||||
[...changes.added, ...changes.updated].forEach((clientId) => {
|
||||
if (clientId === awareness.clientID) return;
|
||||
const state = states.get(clientId);
|
||||
if (state) forwardAwarenessState(channel, clientId, state);
|
||||
});
|
||||
changes.removed.forEach((clientId) => {
|
||||
channel.postMessage({ type: 'leave', clientId: `botsu-awareness-${clientId}` });
|
||||
});
|
||||
};
|
||||
awareness.on('change', handleAwarenessChange);
|
||||
|
||||
publishSharedProject();
|
||||
return () => {
|
||||
ydoc.off('afterTransaction', handleYjsTransaction);
|
||||
awareness.off('change', handleAwarenessChange);
|
||||
channel.close();
|
||||
if (channelRef.current === channel) channelRef.current = undefined;
|
||||
};
|
||||
}, [awareness, roomCode, user.color, user.name, ydoc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ydoc || !frameReady) return undefined;
|
||||
const blockstates = ydoc.getMap<string>(VOXELIER_BLOCKSTATES_MAP);
|
||||
const frameWindow = iframeRef.current?.contentWindow;
|
||||
if (!frameWindow) return undefined;
|
||||
|
||||
const projectId = project?.id;
|
||||
if (projectId) {
|
||||
const initial = blockstates.get(projectId);
|
||||
if (initial) mergeBlockstateStore(frameWindow, projectId, initial);
|
||||
}
|
||||
|
||||
const handleBlockstateUpdate = (event: Event) => {
|
||||
const currentProjectId = lastIframeProjectRef.current?.id;
|
||||
if (!currentProjectId) return;
|
||||
const { detail } = event as CustomEvent<unknown>;
|
||||
if (typeof detail !== 'object' || detail === null) return;
|
||||
const { byProject } = detail as { byProject?: unknown };
|
||||
if (typeof byProject !== 'object' || byProject === null) return;
|
||||
const projectState = (byProject as Record<string, unknown>)[currentProjectId];
|
||||
if (projectState === undefined) return;
|
||||
const serialized = JSON.stringify(projectState);
|
||||
if (serialized.length > 500_000) return;
|
||||
ydoc.transact(() => blockstates.set(currentProjectId, serialized), LOCAL_YJS_ORIGIN);
|
||||
};
|
||||
frameWindow.addEventListener('voxelier:blockstates-updated', handleBlockstateUpdate);
|
||||
|
||||
const handleBlockstateChange = (event: Y.YMapEvent<string>, transaction: Y.Transaction) => {
|
||||
if (transaction.origin === LOCAL_YJS_ORIGIN) return;
|
||||
event.keysChanged.forEach((changedProjectId) => {
|
||||
const serialized = blockstates.get(changedProjectId);
|
||||
if (serialized) mergeBlockstateStore(frameWindow, changedProjectId, serialized);
|
||||
});
|
||||
};
|
||||
blockstates.observe(handleBlockstateChange);
|
||||
return () => {
|
||||
frameWindow.removeEventListener('voxelier:blockstates-updated', handleBlockstateUpdate);
|
||||
blockstates.unobserve(handleBlockstateChange);
|
||||
};
|
||||
}, [frameReady, project?.id, ydoc]);
|
||||
|
||||
const handleFrameLoad = useCallback(() => {
|
||||
const frameDocument = iframeRef.current?.contentDocument;
|
||||
if (!frameDocument) return;
|
||||
setFrameReady(true);
|
||||
let attempts = 0;
|
||||
const enterStudio = window.setInterval(() => {
|
||||
attempts += 1;
|
||||
const hostButton = Array.from(frameDocument.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Host'
|
||||
);
|
||||
if (hostButton instanceof HTMLButtonElement) hostButton.click();
|
||||
if (hostButton || attempts >= 25) window.clearInterval(enterStudio);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const setEditorMode = useCallback((nextMode: 'voxel' | 'minecraft') => {
|
||||
const frameDocument = iframeRef.current?.contentDocument;
|
||||
if (!frameDocument) return;
|
||||
const panel = frameDocument.querySelector('.vox-bs-panel');
|
||||
const open = panel?.classList.contains('is-open') ?? false;
|
||||
if ((nextMode === 'minecraft') !== open) {
|
||||
const toggle = frameDocument.querySelector<HTMLElement>('.vox-bs-toggle');
|
||||
toggle?.click();
|
||||
}
|
||||
setMode(nextMode);
|
||||
}, []);
|
||||
|
||||
let syncLabel = 'Synchronisation…';
|
||||
if (syncState.status === 'online') syncLabel = 'Synchronisé';
|
||||
if (syncState.status === 'error') syncLabel = 'Erreur de synchro';
|
||||
|
||||
if (!objectId) {
|
||||
return (
|
||||
<section className="botsu-voxel-room botsu-voxel-room--empty" aria-label="Voxel Room">
|
||||
<p role="status">Ce salon n’est pas encore lié à un espace voxel partagé.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="botsu-voxel-room" aria-label="Voxel Room collaborative">
|
||||
<div className="botsu-voxel-stage">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="botsu-voxel-frame"
|
||||
src={`/voxelier/index.html?room=${roomCode}&botsu=1`}
|
||||
title="Éditeur voxel collaboratif"
|
||||
sandbox="allow-same-origin allow-scripts allow-downloads allow-forms allow-pointer-lock"
|
||||
onLoad={handleFrameLoad}
|
||||
/>
|
||||
{!frameReady && (
|
||||
<div className="botsu-voxel-loading" role="status">
|
||||
Connexion à l’atelier voxel…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="botsu-voxel-statusbar">
|
||||
<div className="botsu-voxel-statusgroup" aria-live="polite">
|
||||
<span className={`botsu-voxel-sync botsu-voxel-sync--${syncState.status}`} />
|
||||
<span>{syncLabel}</span>
|
||||
<span className="botsu-voxel-separator" aria-hidden="true" />
|
||||
<span>{onlineCount} en ligne</span>
|
||||
{project && (
|
||||
<>
|
||||
<span className="botsu-voxel-separator" aria-hidden="true" />
|
||||
<span>{`${project.width} × ${project.height} × ${project.depth}`}</span>
|
||||
<span className="botsu-voxel-project-name">{project.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="botsu-voxel-modes" aria-label="Mode d’édition">
|
||||
<button
|
||||
type="button"
|
||||
className={mode === 'voxel' ? 'is-active' : undefined}
|
||||
aria-pressed={mode === 'voxel'}
|
||||
onClick={() => setEditorMode('voxel')}
|
||||
>
|
||||
Voxel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={mode === 'minecraft' ? 'is-active' : undefined}
|
||||
aria-pressed={mode === 'minecraft'}
|
||||
onClick={() => setEditorMode('minecraft')}
|
||||
>
|
||||
Minecraft
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BotsuVoxelRoomView } from './BotsuVoxelRoomView';
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
applyVoxelierProject,
|
||||
getVoxelierRoomCode,
|
||||
parseVoxelierProject,
|
||||
readVoxelierProject,
|
||||
type VoxelierProject,
|
||||
} from './model.ts';
|
||||
|
||||
const createProject = (cells: Record<string, string>): VoxelierProject => ({
|
||||
id: 'sanctuary',
|
||||
name: 'Sanctuary',
|
||||
width: 16,
|
||||
height: 16,
|
||||
depth: 16,
|
||||
palette: ['minecraft:stone', 'minecraft:oak_log'],
|
||||
cells,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
});
|
||||
|
||||
test('parses Voxelier projects without retaining caller-owned records', () => {
|
||||
const cells = { '1,2,3': 'minecraft:stone' };
|
||||
const project = parseVoxelierProject(createProject(cells));
|
||||
cells['1,2,3'] = 'minecraft:dirt';
|
||||
|
||||
assert.equal(project.cells['1,2,3'], 'minecraft:stone');
|
||||
assert.throws(() => parseVoxelierProject({ ...createProject({}), width: 0 }), /width/);
|
||||
assert.throws(
|
||||
() => parseVoxelierProject(createProject({ '99,0,0': 'minecraft:stone' })),
|
||||
/outside project bounds/
|
||||
);
|
||||
});
|
||||
|
||||
test('room codes are stable four-digit identifiers', () => {
|
||||
assert.equal(getVoxelierRoomCode('doc_alpha'), getVoxelierRoomCode('doc_alpha'));
|
||||
assert.match(getVoxelierRoomCode('doc_alpha'), /^\d{4}$/);
|
||||
assert.notEqual(getVoxelierRoomCode('doc_alpha'), getVoxelierRoomCode('doc_beta'));
|
||||
});
|
||||
|
||||
test('cell-level Yjs updates merge concurrent edits from two builders', () => {
|
||||
const initial = createProject({ '1,1,1': 'minecraft:stone' });
|
||||
const builderA = new Y.Doc();
|
||||
const builderB = new Y.Doc();
|
||||
applyVoxelierProject(builderA, initial);
|
||||
Y.applyUpdate(builderB, Y.encodeStateAsUpdate(builderA));
|
||||
|
||||
applyVoxelierProject(
|
||||
builderA,
|
||||
createProject({ ...initial.cells, '2,2,2': 'minecraft:oak_log@r90' }),
|
||||
initial
|
||||
);
|
||||
applyVoxelierProject(
|
||||
builderB,
|
||||
createProject({ ...initial.cells, '3,3,3': 'minecraft:diamond_block' }),
|
||||
initial
|
||||
);
|
||||
|
||||
const updateA = Y.encodeStateAsUpdate(builderA, Y.encodeStateVector(builderB));
|
||||
const updateB = Y.encodeStateAsUpdate(builderB, Y.encodeStateVector(builderA));
|
||||
Y.applyUpdate(builderA, updateB);
|
||||
Y.applyUpdate(builderB, updateA);
|
||||
|
||||
assert.deepEqual(
|
||||
{ ...readVoxelierProject(builderA)?.cells },
|
||||
{
|
||||
'1,1,1': 'minecraft:stone',
|
||||
'2,2,2': 'minecraft:oak_log@r90',
|
||||
'3,3,3': 'minecraft:diamond_block',
|
||||
}
|
||||
);
|
||||
assert.deepEqual(readVoxelierProject(builderA), readVoxelierProject(builderB));
|
||||
});
|
||||
|
||||
test('a local deletion only removes the cell changed from its base snapshot', () => {
|
||||
const ydoc = new Y.Doc();
|
||||
const initial = createProject({
|
||||
'1,1,1': 'minecraft:stone',
|
||||
'2,2,2': 'minecraft:oak_log',
|
||||
});
|
||||
applyVoxelierProject(ydoc, initial);
|
||||
applyVoxelierProject(ydoc, createProject({ '2,2,2': 'minecraft:oak_log' }), initial);
|
||||
|
||||
assert.deepEqual(
|
||||
{ ...readVoxelierProject(ydoc)?.cells },
|
||||
{
|
||||
'2,2,2': 'minecraft:oak_log',
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export const VOXELIER_META_MAP = 'voxelier-meta';
|
||||
export const VOXELIER_CELLS_MAP = 'voxelier-cells';
|
||||
export const VOXELIER_BLOCKSTATES_MAP = 'voxelier-blockstates';
|
||||
|
||||
const MAXIMUM_DIMENSION = 256;
|
||||
const MAXIMUM_CELLS = 100_000;
|
||||
const CELL_KEY_PATTERN = /^(0|[1-9]\d*),(0|[1-9]\d*),(0|[1-9]\d*)$/;
|
||||
|
||||
export type VoxelierProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
palette: string[];
|
||||
cells: Record<string, string>;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export type VoxelierProjectPatch = {
|
||||
project: VoxelierProject;
|
||||
setCells: ReadonlyArray<readonly [string, string]>;
|
||||
deleteCells: readonly string[];
|
||||
};
|
||||
|
||||
const readOwnValue = (record: object, key: string): unknown => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(record, key);
|
||||
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError(`Invalid Voxelier project ${key}`);
|
||||
}
|
||||
return descriptor.value;
|
||||
};
|
||||
|
||||
const parseDimension = (value: unknown, name: string): number => {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > MAXIMUM_DIMENSION
|
||||
) {
|
||||
throw new TypeError(`Invalid Voxelier project ${name}`);
|
||||
}
|
||||
return value as number;
|
||||
};
|
||||
|
||||
const parseTimestamp = (value: unknown, name: string): number => {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new TypeError(`Invalid Voxelier project ${name}`);
|
||||
}
|
||||
return value as number;
|
||||
};
|
||||
|
||||
const parseBlockId = (value: unknown): string => {
|
||||
const hasControlCharacter =
|
||||
typeof value === 'string' &&
|
||||
Array.from(value).some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code < 32 || code === 127;
|
||||
});
|
||||
if (typeof value !== 'string' || value.length < 1 || value.length > 300 || hasControlCharacter) {
|
||||
throw new TypeError('Invalid Voxelier block ID');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const parsePalette = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value) || value.length > 4_096) {
|
||||
throw new TypeError('Invalid Voxelier project palette');
|
||||
}
|
||||
return [...new Set(value.map(parseBlockId))];
|
||||
};
|
||||
|
||||
const parseCells = (
|
||||
value: unknown,
|
||||
dimensions: Pick<VoxelierProject, 'width' | 'height' | 'depth'>
|
||||
): Record<string, string> => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new TypeError('Invalid Voxelier project cells');
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const keys = Reflect.ownKeys(descriptors);
|
||||
if (keys.length > MAXIMUM_CELLS) throw new TypeError('Voxelier project is too large');
|
||||
|
||||
const cells: Record<string, string> = Object.create(null);
|
||||
keys.forEach((key) => {
|
||||
if (typeof key !== 'string' || !CELL_KEY_PATTERN.test(key)) {
|
||||
throw new TypeError('Invalid Voxelier cell key');
|
||||
}
|
||||
const descriptor = descriptors[key];
|
||||
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('Invalid Voxelier cell');
|
||||
}
|
||||
const coordinates = key.split(',').map(Number);
|
||||
const x = coordinates[0] ?? Number.NaN;
|
||||
const y = coordinates[1] ?? Number.NaN;
|
||||
const z = coordinates[2] ?? Number.NaN;
|
||||
if (x >= dimensions.width || y >= dimensions.height || z >= dimensions.depth) {
|
||||
throw new TypeError('Voxelier cell is outside project bounds');
|
||||
}
|
||||
cells[key] = parseBlockId(descriptor.value);
|
||||
});
|
||||
return cells;
|
||||
};
|
||||
|
||||
export const parseVoxelierProject = (value: unknown): VoxelierProject => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new TypeError('Invalid Voxelier project');
|
||||
}
|
||||
const idValue = readOwnValue(value, 'id');
|
||||
const nameValue = readOwnValue(value, 'name');
|
||||
if (typeof idValue !== 'string' || idValue.trim().length < 1 || idValue.length > 120) {
|
||||
throw new TypeError('Invalid Voxelier project id');
|
||||
}
|
||||
if (typeof nameValue !== 'string' || nameValue.trim().length < 1 || nameValue.length > 160) {
|
||||
throw new TypeError('Invalid Voxelier project name');
|
||||
}
|
||||
|
||||
const dimensions = {
|
||||
width: parseDimension(readOwnValue(value, 'width'), 'width'),
|
||||
height: parseDimension(readOwnValue(value, 'height'), 'height'),
|
||||
depth: parseDimension(readOwnValue(value, 'depth'), 'depth'),
|
||||
};
|
||||
return {
|
||||
id: idValue,
|
||||
name: nameValue.trim(),
|
||||
...dimensions,
|
||||
palette: parsePalette(readOwnValue(value, 'palette')),
|
||||
cells: parseCells(readOwnValue(value, 'cells'), dimensions),
|
||||
created_at: parseTimestamp(readOwnValue(value, 'created_at'), 'created_at'),
|
||||
updated_at: parseTimestamp(readOwnValue(value, 'updated_at'), 'updated_at'),
|
||||
};
|
||||
};
|
||||
|
||||
export const diffVoxelierProjects = (
|
||||
nextValue: unknown,
|
||||
base?: VoxelierProject
|
||||
): VoxelierProjectPatch => {
|
||||
const project = parseVoxelierProject(nextValue);
|
||||
const setCells: Array<readonly [string, string]> = [];
|
||||
const deleteCells: string[] = [];
|
||||
|
||||
Object.entries(project.cells).forEach(([key, value]) => {
|
||||
if (!base || base.cells[key] !== value) setCells.push([key, value]);
|
||||
});
|
||||
if (base) {
|
||||
Object.keys(base.cells).forEach((key) => {
|
||||
if (!(key in project.cells)) deleteCells.push(key);
|
||||
});
|
||||
}
|
||||
return { project, setCells, deleteCells };
|
||||
};
|
||||
|
||||
export const applyVoxelierProject = (
|
||||
ydoc: Y.Doc,
|
||||
nextValue: unknown,
|
||||
base?: VoxelierProject,
|
||||
origin?: unknown
|
||||
): VoxelierProject => {
|
||||
const patch = diffVoxelierProjects(nextValue, base);
|
||||
ydoc.transact(() => {
|
||||
const meta = ydoc.getMap<unknown>(VOXELIER_META_MAP);
|
||||
const setMeta = (key: string, value: unknown) => {
|
||||
const current = meta.get(key);
|
||||
const equal =
|
||||
Array.isArray(current) && Array.isArray(value)
|
||||
? current.length === value.length && current.every((item, index) => item === value[index])
|
||||
: current === value;
|
||||
if (!equal) meta.set(key, value);
|
||||
};
|
||||
setMeta('id', patch.project.id);
|
||||
setMeta('name', patch.project.name);
|
||||
setMeta('width', patch.project.width);
|
||||
setMeta('height', patch.project.height);
|
||||
setMeta('depth', patch.project.depth);
|
||||
setMeta('palette', patch.project.palette);
|
||||
setMeta('created_at', patch.project.created_at);
|
||||
setMeta('updated_at', patch.project.updated_at);
|
||||
|
||||
const cells = ydoc.getMap<string>(VOXELIER_CELLS_MAP);
|
||||
patch.setCells.forEach(([key, value]) => cells.set(key, value));
|
||||
patch.deleteCells.forEach((key) => cells.delete(key));
|
||||
}, origin);
|
||||
return patch.project;
|
||||
};
|
||||
|
||||
export const readVoxelierProject = (ydoc: Y.Doc): VoxelierProject | undefined => {
|
||||
const meta = ydoc.getMap<unknown>(VOXELIER_META_MAP);
|
||||
if (!meta.has('id')) return undefined;
|
||||
const cells = Object.fromEntries(ydoc.getMap<string>(VOXELIER_CELLS_MAP).entries());
|
||||
try {
|
||||
return parseVoxelierProject({
|
||||
id: meta.get('id'),
|
||||
name: meta.get('name'),
|
||||
width: meta.get('width'),
|
||||
height: meta.get('height'),
|
||||
depth: meta.get('depth'),
|
||||
palette: meta.get('palette'),
|
||||
cells,
|
||||
created_at: meta.get('created_at'),
|
||||
updated_at: meta.get('updated_at'),
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getVoxelierRoomCode = (seed: string): string => {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 131 + seed.charCodeAt(index)) % 9_000;
|
||||
}
|
||||
return String(1_000 + hash);
|
||||
};
|
||||
@@ -0,0 +1,785 @@
|
||||
import React, {
|
||||
CSSProperties,
|
||||
FormEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { color } from 'folds';
|
||||
import {
|
||||
ArrowsClockwiseIcon,
|
||||
CheckCircleIcon,
|
||||
ClosedCaptioningIcon,
|
||||
CornersOutIcon,
|
||||
FilmSlateIcon,
|
||||
LinkIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
SpeakerHighIcon,
|
||||
SpeakerSlashIcon,
|
||||
UsersThreeIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {
|
||||
createEmptyBotsuWatchSession,
|
||||
parseBotsuWatchSession,
|
||||
type BotsuWatchSession,
|
||||
} from '@botsu/protocol';
|
||||
import { RoomView } from '../../app/features/room/RoomView';
|
||||
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import {
|
||||
createBotsuWatchSnapshot,
|
||||
formatBotsuWatchTime,
|
||||
isHlsWatchSource,
|
||||
projectBotsuWatchPositionMs,
|
||||
shouldCorrectBotsuWatchDrift,
|
||||
type BotsuWatchSnapshot,
|
||||
} from './model';
|
||||
import './watch-room.css';
|
||||
|
||||
type WatchRoomState = {
|
||||
snapshot: BotsuWatchSnapshot;
|
||||
senderId?: string;
|
||||
};
|
||||
|
||||
type WatchQuality = {
|
||||
index: number;
|
||||
height: number;
|
||||
bitrate: number;
|
||||
};
|
||||
|
||||
type WatchPreferences = {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
};
|
||||
|
||||
type WatchThemeStyle = CSSProperties & Record<`--botsu-watch-${string}`, string>;
|
||||
|
||||
const WATCH_PREFERENCES_KEY = 'botsu.watch.preferences.v1';
|
||||
const EMPTY_SESSION = createEmptyBotsuWatchSession();
|
||||
|
||||
const watchThemeStyle = {
|
||||
'--botsu-watch-canvas': color.Background.Container,
|
||||
'--botsu-watch-surface': color.Surface.Container,
|
||||
'--botsu-watch-surface-raised': color.SurfaceVariant.Container,
|
||||
'--botsu-watch-text': color.Background.OnContainer,
|
||||
'--botsu-watch-muted': color.SurfaceVariant.OnContainer,
|
||||
'--botsu-watch-border': color.Surface.ContainerLine,
|
||||
'--botsu-watch-accent': color.Primary.Main,
|
||||
'--botsu-watch-on-accent': color.Primary.OnMain,
|
||||
} satisfies WatchThemeStyle;
|
||||
|
||||
const loadWatchPreferences = (): WatchPreferences => {
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
window.localStorage.getItem(WATCH_PREFERENCES_KEY) ?? 'null'
|
||||
) as unknown;
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error();
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.volume !== 'number' ||
|
||||
!Number.isFinite(record.volume) ||
|
||||
record.volume < 0 ||
|
||||
record.volume > 1 ||
|
||||
typeof record.muted !== 'boolean'
|
||||
) {
|
||||
throw new Error();
|
||||
}
|
||||
return { volume: record.volume, muted: record.muted };
|
||||
} catch {
|
||||
return { volume: 0.72, muted: false };
|
||||
}
|
||||
};
|
||||
|
||||
const readWatchRoomState = (room: Room, now = Date.now()): WatchRoomState => {
|
||||
const event = room.currentState.getStateEvents(StateEvent.BotsuWatchSession, '');
|
||||
if (!event || Array.isArray(event)) {
|
||||
return { snapshot: createBotsuWatchSnapshot(EMPTY_SESSION, now) };
|
||||
}
|
||||
const unsignedAge = (event.getUnsigned() as { age?: unknown }).age;
|
||||
const fallbackAge = Math.max(now - event.getTs(), 0);
|
||||
const eventAge = typeof unsignedAge === 'number' ? unsignedAge : fallbackAge;
|
||||
return {
|
||||
snapshot: createBotsuWatchSnapshot(event.getContent(), now, eventAge),
|
||||
senderId: event.getSender() ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const memberInitials = (name: string): string =>
|
||||
name
|
||||
.replace(/^@/, '')
|
||||
.split(/[\s._-]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('') || '?';
|
||||
|
||||
const qualityLabel = (quality: WatchQuality): string => {
|
||||
if (quality.height > 0) return `${quality.height}p`;
|
||||
if (quality.bitrate > 0) return `${Math.round(quality.bitrate / 1_000)} kb/s`;
|
||||
return `Piste ${quality.index + 1}`;
|
||||
};
|
||||
|
||||
function SourceDialog({
|
||||
session,
|
||||
busy,
|
||||
error,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onRemove,
|
||||
}: {
|
||||
session: BotsuWatchSession;
|
||||
busy: boolean;
|
||||
error?: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (url: string, title: string) => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
}) {
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
void onSubmit(String(form.get('url') ?? ''), String(form.get('title') ?? ''));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !busy) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [busy, onClose]);
|
||||
|
||||
return (
|
||||
<div className="botsu-watch-source-overlay" role="presentation" onMouseDown={onClose}>
|
||||
<form
|
||||
aria-labelledby="botsu-watch-source-title"
|
||||
aria-modal="true"
|
||||
className="botsu-watch-source-dialog"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onSubmit={handleSubmit}
|
||||
role="dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="botsu-watch-eyebrow">SOURCE PARTAGÉE</span>
|
||||
<h2 id="botsu-watch-source-title">Choisir le flux</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Fermer"
|
||||
className="botsu-watch-icon-button"
|
||||
disabled={busy}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<XIcon aria-hidden size={20} />
|
||||
</button>
|
||||
</header>
|
||||
<label>
|
||||
<span>Titre</span>
|
||||
<input
|
||||
autoFocus
|
||||
defaultValue={session.source?.title ?? ''}
|
||||
maxLength={120}
|
||||
name="title"
|
||||
placeholder="Film du samedi"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>URL HTTPS du média</span>
|
||||
<div className="botsu-watch-input-with-icon">
|
||||
<LinkIcon aria-hidden size={18} />
|
||||
<input
|
||||
defaultValue={session.source?.url ?? ''}
|
||||
inputMode="url"
|
||||
maxLength={2048}
|
||||
name="url"
|
||||
placeholder="https://media.example/film/master.m3u8"
|
||||
required
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<p className="botsu-watch-source-note">
|
||||
MP4, WebM et HLS (.m3u8). L’adresse est enregistrée dans l’état Matrix du salon :
|
||||
n’utilisez pas d’URL contenant un secret.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="botsu-watch-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<footer>
|
||||
{session.source && (
|
||||
<button
|
||||
className="botsu-watch-button botsu-watch-button--danger"
|
||||
disabled={busy}
|
||||
onClick={() => void onRemove()}
|
||||
type="button"
|
||||
>
|
||||
Retirer le flux
|
||||
</button>
|
||||
)}
|
||||
<span />
|
||||
<button className="botsu-watch-button" disabled={busy} onClick={onClose} type="button">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
className="botsu-watch-button botsu-watch-button--primary"
|
||||
disabled={busy}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? 'Synchronisation…' : 'Diffuser'}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
const mx = useMatrixClient();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const hlsRef = useRef<Hls>();
|
||||
const [watchState, setWatchState] = useState<WatchRoomState>(() => {
|
||||
try {
|
||||
return readWatchRoomState(room);
|
||||
} catch {
|
||||
return { snapshot: createBotsuWatchSnapshot(EMPTY_SESSION, Date.now()) };
|
||||
}
|
||||
});
|
||||
const [preferences, setPreferences] = useState<WatchPreferences>(loadWatchPreferences);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [seekDraft, setSeekDraft] = useState<number>();
|
||||
const [qualities, setQualities] = useState<WatchQuality[]>([]);
|
||||
const [qualityIndex, setQualityIndex] = useState(-1);
|
||||
const [captions, setCaptions] = useState(false);
|
||||
const [captionsAvailable, setCaptionsAvailable] = useState(false);
|
||||
const [sourceDialog, setSourceDialog] = useState(false);
|
||||
const [sourceError, setSourceError] = useState<string>();
|
||||
const [mediaError, setMediaError] = useState<string>();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [autoplayBlocked, setAutoplayBlocked] = useState(false);
|
||||
|
||||
const session = watchState.snapshot.session;
|
||||
const source = session.source;
|
||||
const joinedMembers = useMemo(() => room.getJoinedMembers(), [room, watchState]);
|
||||
const visibleMembers = joinedMembers.slice(0, 4);
|
||||
const canSeek = Number.isFinite(duration) && duration > 0;
|
||||
|
||||
const applyPlaybackState = useCallback(async (snapshot: BotsuWatchSnapshot) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !snapshot.session.source) return;
|
||||
const knownDuration = Number.isFinite(video.duration) ? video.duration * 1_000 : undefined;
|
||||
const expectedMs = projectBotsuWatchPositionMs(snapshot, Date.now(), knownDuration);
|
||||
if (
|
||||
video.readyState >= HTMLMediaElement.HAVE_METADATA &&
|
||||
Number.isFinite(video.duration) &&
|
||||
shouldCorrectBotsuWatchDrift(video.currentTime * 1_000, expectedMs)
|
||||
) {
|
||||
video.currentTime = expectedMs / 1_000;
|
||||
}
|
||||
setPosition(expectedMs / 1_000);
|
||||
if (snapshot.session.paused) {
|
||||
video.pause();
|
||||
setAutoplayBlocked(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await video.play();
|
||||
setAutoplayBlocked(false);
|
||||
} catch {
|
||||
setAutoplayBlocked(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStateEvent = (event: MatrixEvent) => {
|
||||
if (
|
||||
event.getRoomId() !== room.roomId ||
|
||||
event.getType() !== StateEvent.BotsuWatchSession ||
|
||||
event.getStateKey() !== ''
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = readWatchRoomState(room);
|
||||
setWatchState(next);
|
||||
setSourceError(undefined);
|
||||
} catch {
|
||||
setSourceError('Le dernier état Watch reçu est invalide.');
|
||||
}
|
||||
};
|
||||
room.on(RoomStateEvent.Events, handleStateEvent);
|
||||
return () => {
|
||||
room.removeListener(RoomStateEvent.Events, handleStateEvent);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = undefined;
|
||||
setQualities([]);
|
||||
setQualityIndex(-1);
|
||||
setMediaError(undefined);
|
||||
setDuration(0);
|
||||
setPosition(0);
|
||||
setAutoplayBlocked(false);
|
||||
if (!video) return undefined;
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
if (!source) return undefined;
|
||||
|
||||
if (isHlsWatchSource(source.url) && Hls.isSupported()) {
|
||||
const hls = new Hls({ enableWorker: true });
|
||||
hlsRef.current = hls;
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(source.url));
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setQualities(
|
||||
hls.levels.map((level, index) => ({
|
||||
index,
|
||||
height: level.height,
|
||||
bitrate: level.bitrate,
|
||||
}))
|
||||
);
|
||||
setCaptionsAvailable(video.textTracks.length > 0);
|
||||
void applyPlaybackState(watchState.snapshot);
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal)
|
||||
setMediaError('Le flux HLS ne peut pas être chargé. Vérifiez son accès CORS.');
|
||||
});
|
||||
hls.attachMedia(video);
|
||||
return () => {
|
||||
hls.destroy();
|
||||
if (hlsRef.current === hls) hlsRef.current = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
if (isHlsWatchSource(source.url) && !video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
setMediaError('Ce navigateur ne prend pas en charge ce flux HLS.');
|
||||
return undefined;
|
||||
}
|
||||
video.src = source.url;
|
||||
video.load();
|
||||
return () => {
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
};
|
||||
}, [applyPlaybackState, source?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
void applyPlaybackState(watchState.snapshot);
|
||||
}, [applyPlaybackState, watchState]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !session.source) return;
|
||||
const knownDuration = Number.isFinite(video.duration) ? video.duration * 1_000 : undefined;
|
||||
const expectedMs = projectBotsuWatchPositionMs(
|
||||
watchState.snapshot,
|
||||
Date.now(),
|
||||
knownDuration
|
||||
);
|
||||
setPosition(expectedMs / 1_000);
|
||||
if (
|
||||
!session.paused &&
|
||||
video.readyState >= HTMLMediaElement.HAVE_METADATA &&
|
||||
Number.isFinite(video.duration) &&
|
||||
shouldCorrectBotsuWatchDrift(video.currentTime * 1_000, expectedMs)
|
||||
) {
|
||||
video.currentTime = expectedMs / 1_000;
|
||||
}
|
||||
}, 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [session.paused, session.source, watchState.snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.volume = preferences.volume;
|
||||
video.muted = preferences.muted;
|
||||
try {
|
||||
window.localStorage.setItem(WATCH_PREFERENCES_KEY, JSON.stringify(preferences));
|
||||
} catch {
|
||||
// Personal settings remain in memory when storage is unavailable.
|
||||
}
|
||||
}, [preferences]);
|
||||
|
||||
const sendSession = useCallback(
|
||||
async (nextValue: BotsuWatchSession) => {
|
||||
const next = parseBotsuWatchSession(nextValue);
|
||||
const previous = watchState;
|
||||
setSending(true);
|
||||
setSourceError(undefined);
|
||||
setWatchState({
|
||||
snapshot: createBotsuWatchSnapshot(next, Date.now()),
|
||||
senderId: mx.getSafeUserId(),
|
||||
});
|
||||
try {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.BotsuWatchSession as any, next, '');
|
||||
} catch {
|
||||
setWatchState(previous);
|
||||
setSourceError('Matrix a refusé la synchronisation. Vérifiez vos droits dans le salon.');
|
||||
throw new Error('watch_sync_failed');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[mx, room.roomId, watchState]
|
||||
);
|
||||
|
||||
const handleTogglePlayback = async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !source || sending) return;
|
||||
const nextPaused = !session.paused;
|
||||
const positionMs = Number.isFinite(video.duration)
|
||||
? Math.round(video.currentTime * 1_000)
|
||||
: session.positionMs;
|
||||
if (nextPaused) video.pause();
|
||||
else {
|
||||
try {
|
||||
await video.play();
|
||||
setAutoplayBlocked(false);
|
||||
} catch {
|
||||
setAutoplayBlocked(true);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await sendSession({ ...session, paused: nextPaused, positionMs });
|
||||
} catch {
|
||||
void applyPlaybackState(watchState.snapshot);
|
||||
}
|
||||
};
|
||||
|
||||
const commitSeek = async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video || seekDraft === undefined || !canSeek || sending) return;
|
||||
video.currentTime = seekDraft;
|
||||
setPosition(seekDraft);
|
||||
setSeekDraft(undefined);
|
||||
try {
|
||||
await sendSession({ ...session, positionMs: Math.round(seekDraft * 1_000) });
|
||||
} catch {
|
||||
void applyPlaybackState(watchState.snapshot);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSourceSubmit = async (url: string, title: string) => {
|
||||
try {
|
||||
await sendSession({
|
||||
version: 1,
|
||||
source: { url: url.trim(), title: title.trim() },
|
||||
paused: true,
|
||||
positionMs: 0,
|
||||
});
|
||||
setSourceDialog(false);
|
||||
} catch (error) {
|
||||
if ((error as Error).message !== 'watch_sync_failed') {
|
||||
setSourceError((error as Error).message.replace(/^BotsuWatchSession\.source\./, ''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSource = async () => {
|
||||
try {
|
||||
await sendSession(EMPTY_SESSION);
|
||||
setSourceDialog(false);
|
||||
} catch {
|
||||
// sendSession exposes the actionable Matrix error.
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.volume = preferences.volume;
|
||||
video.muted = preferences.muted;
|
||||
setDuration(video.duration);
|
||||
setCaptionsAvailable(video.textTracks.length > 0);
|
||||
void applyPlaybackState(watchState.snapshot);
|
||||
};
|
||||
|
||||
const handleQualityChange = (value: number) => {
|
||||
setQualityIndex(value);
|
||||
if (hlsRef.current) hlsRef.current.currentLevel = value;
|
||||
};
|
||||
|
||||
const handleCaptionsToggle = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const next = !captions;
|
||||
Array.from(video.textTracks).forEach((track, index) => {
|
||||
track.mode = next && index === 0 ? 'showing' : 'disabled';
|
||||
});
|
||||
setCaptions(next);
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
if (session.paused || !Number.isFinite(duration)) return;
|
||||
void sendSession({ ...session, paused: true, positionMs: Math.round(duration * 1_000) });
|
||||
};
|
||||
|
||||
const displayPosition = seekDraft ?? position;
|
||||
const senderName = watchState.senderId
|
||||
? room.getMember(watchState.senderId)?.name ?? watchState.senderId
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="botsu-watch-room" style={watchThemeStyle as CSSProperties}>
|
||||
<main className="botsu-watch-stage" ref={stageRef}>
|
||||
<div className="botsu-watch-statusbar">
|
||||
<div className="botsu-watch-sync-status">
|
||||
{sending ? (
|
||||
<ArrowsClockwiseIcon aria-hidden className="botsu-watch-spin" size={19} />
|
||||
) : (
|
||||
<CheckCircleIcon aria-hidden size={19} />
|
||||
)}
|
||||
<span>{sending ? 'Synchronisation…' : 'Synchronisé · à l’instant'}</span>
|
||||
{senderName && <small>par {senderName}</small>}
|
||||
</div>
|
||||
<div className="botsu-watch-viewers">
|
||||
<UsersThreeIcon aria-hidden size={20} />
|
||||
<span>
|
||||
{joinedMembers.length} spectateur{joinedMembers.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="botsu-watch-button"
|
||||
onClick={() => setSourceDialog(true)}
|
||||
type="button"
|
||||
>
|
||||
<LinkIcon aria-hidden size={17} />
|
||||
{source ? 'Changer le flux' : 'Ajouter un flux'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section aria-label="Écran partagé" className="botsu-watch-screen">
|
||||
{source ? (
|
||||
<>
|
||||
<video
|
||||
aria-label={source.title}
|
||||
onDurationChange={handleLoadedMetadata}
|
||||
onEnded={handleEnded}
|
||||
onError={() => setMediaError('Le média ne peut pas être lu par ce navigateur.')}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onTimeUpdate={(event) => {
|
||||
if (seekDraft === undefined) setPosition(event.currentTarget.currentTime);
|
||||
}}
|
||||
playsInline
|
||||
ref={videoRef}
|
||||
/>
|
||||
<div className="botsu-watch-screen-title">
|
||||
<FilmSlateIcon aria-hidden size={18} />
|
||||
<span>{source.title}</span>
|
||||
</div>
|
||||
{autoplayBlocked && !session.paused && (
|
||||
<button
|
||||
className="botsu-watch-join-playback"
|
||||
onClick={() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
void video
|
||||
.play()
|
||||
.then(() => setAutoplayBlocked(false))
|
||||
.catch(() => setAutoplayBlocked(true));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PlayIcon aria-hidden size={24} weight="fill" />
|
||||
Rejoindre la lecture
|
||||
</button>
|
||||
)}
|
||||
{mediaError && (
|
||||
<div className="botsu-watch-media-error" role="alert">
|
||||
{mediaError}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="botsu-watch-empty">
|
||||
<FilmSlateIcon aria-hidden size={42} />
|
||||
<h2>La salle est prête</h2>
|
||||
<p>Ajoutez un flux HTTPS pour lancer la séance à plusieurs.</p>
|
||||
<button
|
||||
className="botsu-watch-button botsu-watch-button--primary"
|
||||
onClick={() => setSourceDialog(true)}
|
||||
type="button"
|
||||
>
|
||||
<LinkIcon aria-hidden size={17} />
|
||||
Ajouter un flux
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="botsu-watch-shared-controls"
|
||||
className="botsu-watch-controls botsu-watch-controls--shared"
|
||||
>
|
||||
<span className="botsu-watch-eyebrow" id="botsu-watch-shared-controls">
|
||||
POUR TOUT LE MONDE
|
||||
</span>
|
||||
<div className="botsu-watch-shared-row">
|
||||
<button
|
||||
aria-label={
|
||||
session.paused ? 'Lire pour tout le monde' : 'Mettre en pause pour tout le monde'
|
||||
}
|
||||
className="botsu-watch-play-button"
|
||||
disabled={!source || sending}
|
||||
onClick={() => void handleTogglePlayback()}
|
||||
type="button"
|
||||
>
|
||||
{session.paused ? (
|
||||
<PlayIcon aria-hidden size={24} weight="fill" />
|
||||
) : (
|
||||
<PauseIcon aria-hidden size={24} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
<span className="botsu-watch-time">{formatBotsuWatchTime(displayPosition)}</span>
|
||||
<input
|
||||
aria-label="Position commune"
|
||||
disabled={!source || !canSeek || sending}
|
||||
max={canSeek ? duration : 1}
|
||||
min={0}
|
||||
onBlur={() => void commitSeek()}
|
||||
onChange={(event) => setSeekDraft(Number(event.currentTarget.value))}
|
||||
onKeyUp={() => void commitSeek()}
|
||||
onPointerUp={() => void commitSeek()}
|
||||
step={0.1}
|
||||
type="range"
|
||||
value={canSeek ? Math.min(displayPosition, duration) : 0}
|
||||
/>
|
||||
<span className="botsu-watch-time">
|
||||
{canSeek ? formatBotsuWatchTime(duration) : source ? 'EN DIRECT' : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="botsu-watch-personal-controls"
|
||||
className="botsu-watch-controls botsu-watch-controls--personal"
|
||||
>
|
||||
<span className="botsu-watch-eyebrow" id="botsu-watch-personal-controls">
|
||||
POUR MOI
|
||||
</span>
|
||||
<div className="botsu-watch-personal-row">
|
||||
<div className="botsu-watch-volume">
|
||||
<button
|
||||
aria-label={preferences.muted ? 'Réactiver le son' : 'Couper le son'}
|
||||
className="botsu-watch-icon-button"
|
||||
onClick={() => setPreferences((current) => ({ ...current, muted: !current.muted }))}
|
||||
type="button"
|
||||
>
|
||||
{preferences.muted || preferences.volume === 0 ? (
|
||||
<SpeakerSlashIcon aria-hidden size={22} />
|
||||
) : (
|
||||
<SpeakerHighIcon aria-hidden size={22} />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
aria-label="Volume personnel"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(event) => {
|
||||
const volume = Number(event.currentTarget.value);
|
||||
setPreferences({ volume, muted: volume === 0 ? true : false });
|
||||
}}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={preferences.volume}
|
||||
/>
|
||||
<output>{Math.round(preferences.volume * 100)}</output>
|
||||
</div>
|
||||
<label className="botsu-watch-quality">
|
||||
<span className="botsu-watch-visually-hidden">Qualité personnelle</span>
|
||||
<select
|
||||
aria-label="Qualité personnelle"
|
||||
disabled={qualities.length === 0}
|
||||
onChange={(event) => handleQualityChange(Number(event.currentTarget.value))}
|
||||
value={qualityIndex}
|
||||
>
|
||||
<option value={-1}>
|
||||
Auto{qualities[0] ? ` · ${qualityLabel(qualities[qualities.length - 1]!)}` : ''}
|
||||
</option>
|
||||
{qualities.map((quality) => (
|
||||
<option
|
||||
key={`${quality.index}-${quality.height}-${quality.bitrate}`}
|
||||
value={quality.index}
|
||||
>
|
||||
{qualityLabel(quality)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
aria-label={captions ? 'Masquer les sous-titres' : 'Afficher les sous-titres'}
|
||||
aria-pressed={captions}
|
||||
className="botsu-watch-icon-button"
|
||||
disabled={!captionsAvailable}
|
||||
onClick={handleCaptionsToggle}
|
||||
type="button"
|
||||
>
|
||||
<ClosedCaptioningIcon aria-hidden size={22} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Plein écran"
|
||||
className="botsu-watch-icon-button"
|
||||
disabled={!source}
|
||||
onClick={() => void stageRef.current?.requestFullscreen()}
|
||||
type="button"
|
||||
>
|
||||
<CornersOutIcon aria-hidden size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside aria-label="Discussion Matrix" className="botsu-watch-chat">
|
||||
<header className="botsu-watch-chat-header">
|
||||
<div>
|
||||
<span className="botsu-watch-eyebrow">MATRIX</span>
|
||||
<h2>Discussion</h2>
|
||||
</div>
|
||||
<div aria-label={`${joinedMembers.length} membres`} className="botsu-watch-avatars">
|
||||
{visibleMembers.map((member) => (
|
||||
<span className="botsu-watch-avatar" key={member.userId} title={member.name}>
|
||||
{memberInitials(member.name)}
|
||||
</span>
|
||||
))}
|
||||
{joinedMembers.length > visibleMembers.length && (
|
||||
<span className="botsu-watch-avatar">
|
||||
+{joinedMembers.length - visibleMembers.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="botsu-watch-chat-feed">
|
||||
<RoomView />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{sourceDialog && (
|
||||
<SourceDialog
|
||||
busy={sending}
|
||||
error={sourceError}
|
||||
onClose={() => !sending && setSourceDialog(false)}
|
||||
onRemove={handleRemoveSource}
|
||||
onSubmit={handleSourceSubmit}
|
||||
session={session}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './BotsuWatchRoomView';
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createBotsuWatchSnapshot,
|
||||
formatBotsuWatchTime,
|
||||
isHlsWatchSource,
|
||||
projectBotsuWatchPositionMs,
|
||||
shouldCorrectBotsuWatchDrift,
|
||||
} from './model.ts';
|
||||
|
||||
const session = {
|
||||
version: 1,
|
||||
source: { url: 'https://media.botsu.net/movie.mp4', title: 'Film' },
|
||||
paused: false,
|
||||
positionMs: 40_000,
|
||||
} as const;
|
||||
|
||||
test('projects shared playback from Matrix event age and local observation time', () => {
|
||||
const snapshot = createBotsuWatchSnapshot(session, 10_000, 2_000);
|
||||
assert.equal(projectBotsuWatchPositionMs(snapshot, 13_000), 45_000);
|
||||
assert.equal(
|
||||
projectBotsuWatchPositionMs(
|
||||
createBotsuWatchSnapshot({ ...session, paused: true }, 10_000, 9_000),
|
||||
13_000
|
||||
),
|
||||
40_000
|
||||
);
|
||||
});
|
||||
|
||||
test('clamps projection to a known media duration and detects meaningful drift', () => {
|
||||
const snapshot = createBotsuWatchSnapshot(session, 10_000, 2_000);
|
||||
assert.equal(projectBotsuWatchPositionMs(snapshot, 30_000, 50_000), 50_000);
|
||||
assert.equal(shouldCorrectBotsuWatchDrift(10_000, 11_000), false);
|
||||
assert.equal(shouldCorrectBotsuWatchDrift(10_000, 11_251), true);
|
||||
});
|
||||
|
||||
test('recognizes HLS manifests without trusting query strings and formats media time', () => {
|
||||
assert.equal(isHlsWatchSource('https://media.botsu.net/live/master.M3U8?quality=auto'), true);
|
||||
assert.equal(isHlsWatchSource('https://media.botsu.net/movie.mp4?next=.m3u8'), false);
|
||||
assert.equal(formatBotsuWatchTime(65.9), '1:05');
|
||||
assert.equal(formatBotsuWatchTime(3_665), '1:01:05');
|
||||
assert.equal(formatBotsuWatchTime(Number.POSITIVE_INFINITY), '--:--');
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
MAXIMUM_WATCH_POSITION_MS,
|
||||
parseBotsuWatchSession,
|
||||
type BotsuWatchSession,
|
||||
} from '@botsu/protocol';
|
||||
|
||||
export const WATCH_SYNC_TOLERANCE_MS = 1_250;
|
||||
|
||||
export type BotsuWatchSnapshot = {
|
||||
session: BotsuWatchSession;
|
||||
observedAtMs: number;
|
||||
eventAgeMs: number;
|
||||
};
|
||||
|
||||
const clampPosition = (positionMs: number, durationMs?: number): number => {
|
||||
const maximum =
|
||||
typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0
|
||||
? Math.min(durationMs, MAXIMUM_WATCH_POSITION_MS)
|
||||
: MAXIMUM_WATCH_POSITION_MS;
|
||||
return Math.min(Math.max(Math.round(positionMs), 0), maximum);
|
||||
};
|
||||
|
||||
export const createBotsuWatchSnapshot = (
|
||||
value: unknown,
|
||||
observedAtMs: number,
|
||||
eventAgeMs = 0
|
||||
): BotsuWatchSnapshot => {
|
||||
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
||||
throw new TypeError('Watch snapshot observation time is invalid');
|
||||
}
|
||||
const boundedAge =
|
||||
Number.isSafeInteger(eventAgeMs) && eventAgeMs >= 0
|
||||
? Math.min(eventAgeMs, MAXIMUM_WATCH_POSITION_MS)
|
||||
: 0;
|
||||
return {
|
||||
session: parseBotsuWatchSession(value),
|
||||
observedAtMs,
|
||||
eventAgeMs: boundedAge,
|
||||
};
|
||||
};
|
||||
|
||||
export const projectBotsuWatchPositionMs = (
|
||||
snapshot: BotsuWatchSnapshot,
|
||||
nowMs: number,
|
||||
durationMs?: number
|
||||
): number => {
|
||||
const elapsedSinceObservation = Math.max(nowMs - snapshot.observedAtMs, 0);
|
||||
const elapsedWhilePlaying = snapshot.session.paused
|
||||
? 0
|
||||
: snapshot.eventAgeMs + elapsedSinceObservation;
|
||||
return clampPosition(snapshot.session.positionMs + elapsedWhilePlaying, durationMs);
|
||||
};
|
||||
|
||||
export const shouldCorrectBotsuWatchDrift = (
|
||||
actualPositionMs: number,
|
||||
expectedPositionMs: number
|
||||
): boolean => Math.abs(actualPositionMs - expectedPositionMs) > WATCH_SYNC_TOLERANCE_MS;
|
||||
|
||||
export const isHlsWatchSource = (url: string): boolean => {
|
||||
try {
|
||||
return new URL(url).pathname.toLowerCase().endsWith('.m3u8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatBotsuWatchTime = (positionSeconds: number): string => {
|
||||
if (!Number.isFinite(positionSeconds) || positionSeconds < 0) return '--:--';
|
||||
const totalSeconds = Math.floor(positionSeconds);
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
};
|
||||
@@ -0,0 +1,585 @@
|
||||
.botsu-watch-room {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
color: var(--botsu-watch-text);
|
||||
background: var(--botsu-watch-canvas);
|
||||
font-family: var(--font-secondary, Inter, sans-serif);
|
||||
}
|
||||
|
||||
.botsu-watch-stage,
|
||||
.botsu-watch-chat {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.botsu-watch-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
background: #050505;
|
||||
color: #f5f5f5;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.botsu-watch-statusbar {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
padding: 8px 16px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid #313131;
|
||||
}
|
||||
|
||||
.botsu-watch-sync-status,
|
||||
.botsu-watch-viewers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.botsu-watch-sync-status {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.botsu-watch-sync-status small {
|
||||
max-width: 11rem;
|
||||
overflow: hidden;
|
||||
color: #9d9d9d;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-watch-viewers {
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.botsu-watch-screen {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
min-height: 260px;
|
||||
max-height: calc(100vh - 350px);
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border-bottom: 1px solid #313131;
|
||||
}
|
||||
|
||||
.botsu-watch-screen video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.botsu-watch-screen-title {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
max-width: min(70%, 34rem);
|
||||
padding: 8px 10px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 28%);
|
||||
background: rgb(0 0 0 / 68%);
|
||||
backdrop-filter: blur(10px);
|
||||
color: #f5f5f5;
|
||||
font-size: 0.78rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-watch-empty {
|
||||
display: flex;
|
||||
width: min(30rem, calc(100% - 40px));
|
||||
padding: 42px 24px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid #313131;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.botsu-watch-empty h2,
|
||||
.botsu-watch-empty p,
|
||||
.botsu-watch-chat-header h2,
|
||||
.botsu-watch-source-dialog h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.botsu-watch-empty h2 {
|
||||
font-size: clamp(1.3rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
.botsu-watch-empty p {
|
||||
max-width: 23rem;
|
||||
margin: 0 0 8px;
|
||||
color: #a3a3a3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.botsu-watch-join-playback {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
padding: 0 18px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid #f5f5f5;
|
||||
border-radius: 2px;
|
||||
background: rgb(5 5 5 / 82%);
|
||||
color: #f5f5f5;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.botsu-watch-media-error {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
max-width: 30rem;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e96a6a;
|
||||
background: rgb(5 5 5 / 90%);
|
||||
color: #ffd0d0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.botsu-watch-controls {
|
||||
padding: 18px 20px 20px;
|
||||
border-bottom: 1px solid #313131;
|
||||
}
|
||||
|
||||
.botsu-watch-eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: currentColor;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row,
|
||||
.botsu-watch-personal-row,
|
||||
.botsu-watch-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row {
|
||||
gap: 12px;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.botsu-watch-personal-row {
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.botsu-watch-volume {
|
||||
min-width: 190px;
|
||||
flex: 1 1 260px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.botsu-watch-volume output {
|
||||
width: 2.2rem;
|
||||
color: #bdbdbd;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row input[type='range'],
|
||||
.botsu-watch-volume input[type='range'] {
|
||||
min-width: 0;
|
||||
height: 3px;
|
||||
flex: 1 1 auto;
|
||||
accent-color: #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row input[type='range']:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.botsu-watch-time {
|
||||
min-width: 3.7rem;
|
||||
color: #d0d0d0;
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.botsu-watch-play-button,
|
||||
.botsu-watch-icon-button,
|
||||
.botsu-watch-button,
|
||||
.botsu-watch-quality select {
|
||||
min-height: 42px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.botsu-watch-play-button,
|
||||
.botsu-watch-icon-button {
|
||||
display: inline-grid;
|
||||
width: 44px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-watch-play-button {
|
||||
width: 54px;
|
||||
min-height: 48px;
|
||||
border-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.botsu-watch-button {
|
||||
display: inline-flex;
|
||||
padding: 0 13px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-watch-button--primary {
|
||||
border-color: #f5f5f5;
|
||||
background: #f5f5f5;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.botsu-watch-button--danger {
|
||||
border-color: #9b4747;
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.botsu-watch-play-button:hover:not(:disabled),
|
||||
.botsu-watch-icon-button:hover:not(:disabled),
|
||||
.botsu-watch-button:hover:not(:disabled),
|
||||
.botsu-watch-quality select:hover:not(:disabled) {
|
||||
border-color: #f5f5f5;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.botsu-watch-button--primary:hover:not(:disabled) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.botsu-watch-play-button:disabled,
|
||||
.botsu-watch-icon-button:disabled,
|
||||
.botsu-watch-button:disabled,
|
||||
.botsu-watch-quality select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.botsu-watch-play-button:focus-visible,
|
||||
.botsu-watch-icon-button:focus-visible,
|
||||
.botsu-watch-button:focus-visible,
|
||||
.botsu-watch-quality select:focus-visible,
|
||||
.botsu-watch-source-dialog input:focus-visible,
|
||||
.botsu-watch-room input[type='range']:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.botsu-watch-quality {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.botsu-watch-quality select {
|
||||
min-width: 150px;
|
||||
padding: 0 34px 0 12px;
|
||||
appearance: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-watch-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--botsu-watch-border);
|
||||
background: var(--botsu-watch-canvas);
|
||||
}
|
||||
|
||||
.botsu-watch-chat-header {
|
||||
display: flex;
|
||||
min-height: 100px;
|
||||
padding: 14px 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--botsu-watch-border);
|
||||
}
|
||||
|
||||
.botsu-watch-chat-header .botsu-watch-eyebrow {
|
||||
margin-bottom: 4px;
|
||||
color: var(--botsu-watch-muted);
|
||||
}
|
||||
|
||||
.botsu-watch-chat-header h2 {
|
||||
font-size: 1.12rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.botsu-watch-avatars {
|
||||
display: flex;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.botsu-watch-avatar {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: -8px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--botsu-watch-border);
|
||||
border-radius: 50%;
|
||||
background: var(--botsu-watch-surface-raised);
|
||||
color: var(--botsu-watch-text);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.botsu-watch-chat-feed {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.botsu-watch-chat-feed > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.botsu-watch-source-overlay {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
padding: 24px;
|
||||
place-items: center;
|
||||
background: rgb(0 0 0 / 72%);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog {
|
||||
display: flex;
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
padding: 20px;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--botsu-watch-border);
|
||||
border-radius: 2px;
|
||||
background: var(--botsu-watch-canvas);
|
||||
color: var(--botsu-watch-text);
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog header,
|
||||
.botsu-watch-source-dialog footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog header .botsu-watch-eyebrow {
|
||||
margin-bottom: 5px;
|
||||
color: var(--botsu-watch-muted);
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog h2 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--botsu-watch-border);
|
||||
border-radius: 2px;
|
||||
background: var(--botsu-watch-surface);
|
||||
color: var(--botsu-watch-text);
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botsu-watch-input-with-icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.botsu-watch-input-with-icon svg {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
left: 12px;
|
||||
color: var(--botsu-watch-muted);
|
||||
}
|
||||
|
||||
.botsu-watch-input-with-icon input {
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.botsu-watch-source-note,
|
||||
.botsu-watch-error {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.botsu-watch-source-note {
|
||||
color: var(--botsu-watch-muted);
|
||||
}
|
||||
|
||||
.botsu-watch-error {
|
||||
padding: 10px;
|
||||
border: 1px solid #9b4747;
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog footer span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.botsu-watch-spin {
|
||||
animation: botsu-watch-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.botsu-watch-visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
@keyframes botsu-watch-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.botsu-watch-room {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.botsu-watch-stage {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.botsu-watch-screen {
|
||||
min-height: 240px;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.botsu-watch-chat {
|
||||
min-height: 540px;
|
||||
border-top: 1px solid var(--botsu-watch-border);
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.botsu-watch-statusbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.botsu-watch-sync-status {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.botsu-watch-viewers {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.botsu-watch-screen {
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 54px minmax(90px, 1fr) 58px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.botsu-watch-play-button {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.botsu-watch-personal-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.botsu-watch-volume {
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.botsu-watch-quality {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.botsu-watch-source-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog {
|
||||
max-height: calc(100vh - 20px);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog footer span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.botsu-watch-spin {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ export enum StateEvent {
|
||||
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
||||
BotsuWorkspace = 'net.botsu.workspace',
|
||||
BotsuDocument = 'net.botsu.document',
|
||||
BotsuVoxel = 'net.botsu.voxel',
|
||||
BotsuWatchSession = 'net.botsu.watch.session',
|
||||
}
|
||||
|
||||
export enum MessageEvent {
|
||||
@@ -57,6 +59,8 @@ export enum RoomType {
|
||||
Call = 'org.matrix.msc3417.call',
|
||||
Document = 'net.botsu.document',
|
||||
Drawing = 'net.botsu.drawing',
|
||||
Voxel = 'net.botsu.voxel',
|
||||
Watch = 'net.botsu.watch',
|
||||
}
|
||||
|
||||
export type MSpaceChildContent = {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"src/botsu/workspace/*.css.ts",
|
||||
"src/botsu/workspace/*.d.ts",
|
||||
"src/botsu/shell/BotsuFrame.tsx",
|
||||
"src/botsu/start/*.tsx"
|
||||
"src/botsu/start/*.tsx",
|
||||
"src/botsu/voxel/*.tsx",
|
||||
"src/botsu/watch/*.tsx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"src/botsu/start/cookie-controller.ts",
|
||||
"src/botsu/start/pixel-canvas-bridge.ts",
|
||||
"src/botsu/start/pixel-canvas-publisher.ts",
|
||||
"src/botsu/voxel/model.ts",
|
||||
"src/botsu/watch/model.ts",
|
||||
"src/botsu/workspace/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+3856
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+296
@@ -0,0 +1,296 @@
|
||||
.vox-bs-toggle,
|
||||
.vox-bs-panel {
|
||||
--vox-bs-bg: rgba(8, 8, 8, .86);
|
||||
--vox-bs-panel: rgba(22, 22, 22, .9);
|
||||
--vox-bs-field: rgba(255, 255, 255, .06);
|
||||
--vox-bs-line: rgba(244, 244, 244, .18);
|
||||
--vox-bs-line-strong: rgba(244, 244, 244, .48);
|
||||
--vox-bs-text: rgb(246, 246, 246);
|
||||
--vox-bs-muted: rgb(150, 150, 150);
|
||||
--vox-bs-radius: 12px;
|
||||
color: var(--vox-bs-text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.vox-bs-toggle {
|
||||
position: fixed;
|
||||
z-index: 88;
|
||||
right: 14px;
|
||||
top: 94px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--vox-bs-line-strong);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, .14), rgba(255, 255, 255, .03)), var(--vox-bs-bg);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, .48);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: .02em;
|
||||
backdrop-filter: blur(18px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.3);
|
||||
}
|
||||
|
||||
.vox-bs-toggle::before {
|
||||
content: "";
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 4px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, .72), transparent 42%),
|
||||
repeating-linear-gradient(90deg, rgba(255, 255, 255, .24) 0 2px, transparent 2px 4px);
|
||||
transform: rotate(45deg) skew(-8deg, -8deg);
|
||||
}
|
||||
|
||||
.vox-bs-panel {
|
||||
position: fixed;
|
||||
z-index: 89;
|
||||
right: 14px;
|
||||
top: 88px;
|
||||
bottom: 96px;
|
||||
display: none;
|
||||
width: min(420px, calc(100vw - 28px));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vox-bs-line);
|
||||
border-radius: var(--vox-bs-radius);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, .12), transparent 38%),
|
||||
linear-gradient(180deg, rgba(28, 28, 28, .94), var(--vox-bs-bg));
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, .6);
|
||||
backdrop-filter: blur(22px) saturate(1.25);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.25);
|
||||
}
|
||||
|
||||
.vox-bs-panel.is-open {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.vox-bs-head,
|
||||
.vox-bs-actions,
|
||||
.vox-bs-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vox-bs-head {
|
||||
justify-content: space-between;
|
||||
padding: 12px 12px 10px;
|
||||
border-bottom: 1px solid var(--vox-bs-line);
|
||||
}
|
||||
|
||||
.vox-bs-title span,
|
||||
.vox-bs-field span,
|
||||
.vox-bs-section-title span,
|
||||
.vox-bs-status {
|
||||
display: block;
|
||||
color: var(--vox-bs-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vox-bs-title strong {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 18px;
|
||||
letter-spacing: -.04em;
|
||||
}
|
||||
|
||||
.vox-bs-close,
|
||||
.vox-bs-panel button,
|
||||
.vox-bs-panel select,
|
||||
.vox-bs-panel input,
|
||||
.vox-bs-panel textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.vox-bs-panel button {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--vox-bs-line);
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, .1), rgba(255, 255, 255, .035)), var(--vox-bs-field);
|
||||
color: var(--vox-bs-text);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vox-bs-panel button:hover {
|
||||
border-color: var(--vox-bs-line-strong);
|
||||
}
|
||||
|
||||
.vox-bs-panel button.is-active,
|
||||
.vox-bs-primary {
|
||||
border-color: var(--vox-bs-line-strong) !important;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, .9), rgba(255, 255, 255, .7)) !important;
|
||||
color: #080808 !important;
|
||||
}
|
||||
|
||||
.vox-bs-close {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.vox-bs-tabs {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--vox-bs-line);
|
||||
}
|
||||
|
||||
.vox-bs-tabs button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vox-bs-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.vox-bs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vox-bs-field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vox-bs-field.is-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.vox-bs-panel input,
|
||||
.vox-bs-panel select,
|
||||
.vox-bs-panel textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--vox-bs-line);
|
||||
border-radius: 9px;
|
||||
background: var(--vox-bs-field);
|
||||
color: var(--vox-bs-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vox-bs-panel input,
|
||||
.vox-bs-panel select {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vox-bs-panel textarea {
|
||||
min-height: 94px;
|
||||
resize: vertical;
|
||||
padding: 9px 10px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.vox-bs-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--vox-bs-line);
|
||||
border-radius: var(--vox-bs-radius);
|
||||
background: rgba(0, 0, 0, .22);
|
||||
}
|
||||
|
||||
.vox-bs-section-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vox-bs-section-title strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vox-bs-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vox-bs-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vox-bs-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--vox-bs-line);
|
||||
border-radius: 10px;
|
||||
background: var(--vox-bs-field);
|
||||
}
|
||||
|
||||
.vox-bs-row code,
|
||||
.vox-bs-code {
|
||||
color: var(--vox-bs-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vox-bs-row small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
color: var(--vox-bs-muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vox-bs-footer {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--vox-bs-line);
|
||||
background: rgba(0, 0, 0, .18);
|
||||
}
|
||||
|
||||
.vox-bs-status {
|
||||
overflow: hidden;
|
||||
min-height: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vox-bs-empty {
|
||||
padding: 14px;
|
||||
border: 1px dashed var(--vox-bs-line);
|
||||
border-radius: var(--vox-bs-radius);
|
||||
color: var(--vox-bs-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vox-bs-toggle {
|
||||
top: auto;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
}
|
||||
|
||||
.vox-bs-panel {
|
||||
inset: 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
+808
@@ -0,0 +1,808 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var GALLERY_KEY = "voxelier-gallery-v2";
|
||||
var BLOCKSTATE_KEY = "voxelier-blockstates-v1";
|
||||
|
||||
var runtime = {
|
||||
open: false,
|
||||
tab: "definition",
|
||||
projectId: "",
|
||||
blockId: "",
|
||||
status: "Pret.",
|
||||
lastGalleryRaw: ""
|
||||
};
|
||||
|
||||
var stateDirections = {
|
||||
north: { y: 180 },
|
||||
south: { y: 0 },
|
||||
east: { y: 90 },
|
||||
west: { y: 270 }
|
||||
};
|
||||
|
||||
var axisRotations = {
|
||||
y: {},
|
||||
x: { x: 90, y: 90 },
|
||||
z: { x: 90 }
|
||||
};
|
||||
|
||||
function html(value) {
|
||||
return String(value == null ? "" : value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function attr(value) {
|
||||
return html(value).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function readJsonKey(key, fallback) {
|
||||
try {
|
||||
var raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonKey(key, value) {
|
||||
localStorage.setItem(key, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
function getGalleryRaw() {
|
||||
return localStorage.getItem(GALLERY_KEY) || "";
|
||||
}
|
||||
|
||||
function loadProjects() {
|
||||
var raw = getGalleryRaw();
|
||||
runtime.lastGalleryRaw = raw;
|
||||
if (!raw) return [];
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.projects)) return parsed.projects.filter(Boolean);
|
||||
if (parsed && parsed.cells) return [parsed];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
return { version: 1, byProject: {} };
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
var store = readJsonKey(BLOCKSTATE_KEY, makeStore());
|
||||
if (!store || typeof store !== "object") store = makeStore();
|
||||
if (!store.byProject || typeof store.byProject !== "object") store.byProject = {};
|
||||
store.version = 1;
|
||||
return store;
|
||||
}
|
||||
|
||||
function saveStore(store) {
|
||||
writeJsonKey(BLOCKSTATE_KEY, store);
|
||||
window.dispatchEvent(new CustomEvent("voxelier:blockstates-updated", { detail: store }));
|
||||
}
|
||||
|
||||
function projectRecord(store, projectId) {
|
||||
if (!store.byProject[projectId]) {
|
||||
store.byProject[projectId] = { definitions: {}, cellStates: {} };
|
||||
}
|
||||
var record = store.byProject[projectId];
|
||||
if (!record.definitions || typeof record.definitions !== "object") record.definitions = {};
|
||||
if (!record.cellStates || typeof record.cellStates !== "object") record.cellStates = {};
|
||||
return record;
|
||||
}
|
||||
|
||||
function isBlockId(value) {
|
||||
return /^[a-z0-9_.-]+:[a-z0-9_./-]+$/i.test(String(value || ""));
|
||||
}
|
||||
|
||||
function splitBlockId(blockId) {
|
||||
var parts = String(blockId || "").split(":");
|
||||
return {
|
||||
namespace: parts[0] || "minecraft",
|
||||
path: parts.slice(1).join(":") || "stone"
|
||||
};
|
||||
}
|
||||
|
||||
function cleanBlockPath(blockId) {
|
||||
return splitBlockId(blockId).path.replace(/^block\//, "").replace(/^item\//, "");
|
||||
}
|
||||
|
||||
function blockModelId(blockId) {
|
||||
var parts = splitBlockId(blockId);
|
||||
return parts.namespace + ":block/" + cleanBlockPath(blockId);
|
||||
}
|
||||
|
||||
function parseStateString(value) {
|
||||
var states = {};
|
||||
String(value || "")
|
||||
.split(",")
|
||||
.map(function (entry) { return entry.trim(); })
|
||||
.filter(Boolean)
|
||||
.forEach(function (entry) {
|
||||
var pair = entry.split("=");
|
||||
var key = pair.shift();
|
||||
var val = pair.join("=");
|
||||
if (key && val) states[key.trim()] = val.trim();
|
||||
});
|
||||
return states;
|
||||
}
|
||||
|
||||
function stateKey(states) {
|
||||
return Object.keys(states || {})
|
||||
.sort()
|
||||
.filter(function (key) { return states[key] !== ""; })
|
||||
.map(function (key) { return key + "=" + states[key]; })
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function parseCellValue(value) {
|
||||
var raw = String(value || "").trim();
|
||||
var rotation = 0;
|
||||
var rotationMatch = raw.match(/@r(0|90|180|270)$/);
|
||||
if (rotationMatch) {
|
||||
rotation = Number(rotationMatch[1]);
|
||||
raw = raw.slice(0, -rotationMatch[0].length);
|
||||
}
|
||||
var states = {};
|
||||
var stateMatch = raw.match(/^([a-z0-9_.-]+:[a-z0-9_./-]+)\[([^\]]*)\]$/i);
|
||||
if (stateMatch) {
|
||||
raw = stateMatch[1];
|
||||
states = parseStateString(stateMatch[2]);
|
||||
}
|
||||
return { blockId: raw, rotation: rotation, states: states };
|
||||
}
|
||||
|
||||
function serializeBlockRef(blockId, states, rotation) {
|
||||
var key = stateKey(states);
|
||||
var value = blockId + (key ? "[" + key + "]" : "");
|
||||
return rotation ? value + "@r" + rotation : value;
|
||||
}
|
||||
|
||||
function coordFromString(coord) {
|
||||
var parts = String(coord || "").split(",").map(function (part) {
|
||||
return Number.parseInt(part, 10);
|
||||
});
|
||||
if (parts.length !== 3 || parts.some(function (part) { return !Number.isInteger(part); })) return null;
|
||||
return { x: parts[0], y: parts[1], z: parts[2] };
|
||||
}
|
||||
|
||||
function projectBlocks(project) {
|
||||
var ids = [];
|
||||
(project.palette || []).forEach(function (entry) {
|
||||
var block = parseCellValue(entry).blockId;
|
||||
if (isBlockId(block) && ids.indexOf(block) === -1) ids.push(block);
|
||||
});
|
||||
Object.keys(project.cells || {}).forEach(function (coord) {
|
||||
var cell = parseCellValue(project.cells[coord]);
|
||||
if (isBlockId(cell.blockId) && ids.indexOf(cell.blockId) === -1) ids.push(cell.blockId);
|
||||
});
|
||||
return ids.sort(function (left, right) { return left.localeCompare(right); });
|
||||
}
|
||||
|
||||
function normalizeDefinition(blockId, definition) {
|
||||
var base = defaultDefinition(blockId);
|
||||
var next = definition && typeof definition === "object" ? definition : base;
|
||||
return {
|
||||
properties: normalizeProperties(next.properties || base.properties),
|
||||
defaultState: normalizeState(next.defaultState || base.defaultState),
|
||||
variants: normalizeVariants(next.variants || base.variants),
|
||||
multipart: Array.isArray(next.multipart) ? next.multipart : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProperties(properties) {
|
||||
var normalized = {};
|
||||
Object.keys(properties || {}).forEach(function (key) {
|
||||
var values = Array.isArray(properties[key]) ? properties[key] : String(properties[key]).split(",");
|
||||
values = values.map(function (value) { return String(value).trim(); }).filter(Boolean);
|
||||
if (key.trim() && values.length) normalized[key.trim()] = Array.from(new Set(values));
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeState(states) {
|
||||
var normalized = {};
|
||||
Object.keys(states || {}).forEach(function (key) {
|
||||
if (String(states[key]).trim()) normalized[key.trim()] = String(states[key]).trim();
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeVariants(variants) {
|
||||
var normalized = {};
|
||||
Object.keys(variants || {}).forEach(function (key) {
|
||||
var value = variants[key] || {};
|
||||
normalized[key] = normalizeVariant(value);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeVariant(value) {
|
||||
var variant = {};
|
||||
if (value.model) variant.model = String(value.model);
|
||||
if (Number.isFinite(Number(value.x))) variant.x = Number(value.x);
|
||||
if (Number.isFinite(Number(value.y))) variant.y = Number(value.y);
|
||||
if (Number.isFinite(Number(value.uvlock))) variant.uvlock = Boolean(value.uvlock);
|
||||
if (!variant.model) variant.model = "minecraft:block/stone";
|
||||
return variant;
|
||||
}
|
||||
|
||||
function defaultDefinition(blockId) {
|
||||
return {
|
||||
properties: {},
|
||||
defaultState: {},
|
||||
variants: {
|
||||
"": { model: blockModelId(blockId) }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function facingDefinition(blockId) {
|
||||
var variants = {};
|
||||
["north", "south", "east", "west"].forEach(function (facing) {
|
||||
variants["facing=" + facing] = Object.assign({ model: blockModelId(blockId), uvlock: true }, stateDirections[facing]);
|
||||
});
|
||||
return {
|
||||
properties: { facing: ["north", "south", "east", "west"] },
|
||||
defaultState: { facing: "north" },
|
||||
variants: variants
|
||||
};
|
||||
}
|
||||
|
||||
function axisDefinition(blockId) {
|
||||
var variants = {};
|
||||
["x", "y", "z"].forEach(function (axis) {
|
||||
variants["axis=" + axis] = Object.assign({ model: blockModelId(blockId) }, axisRotations[axis]);
|
||||
});
|
||||
return {
|
||||
properties: { axis: ["x", "y", "z"] },
|
||||
defaultState: { axis: "y" },
|
||||
variants: variants
|
||||
};
|
||||
}
|
||||
|
||||
function allCombinations(properties) {
|
||||
var names = Object.keys(properties || {});
|
||||
if (!names.length) return [{}];
|
||||
return names.reduce(function (rows, name) {
|
||||
var values = properties[name] || [];
|
||||
var next = [];
|
||||
rows.forEach(function (row) {
|
||||
values.forEach(function (value) {
|
||||
next.push(Object.assign({}, row, Object.fromEntries([[name, value]])));
|
||||
});
|
||||
});
|
||||
return next;
|
||||
}, [{}]);
|
||||
}
|
||||
|
||||
function activeProject(projects) {
|
||||
if (!projects.length) return null;
|
||||
var project = projects.find(function (item) { return item.id === runtime.projectId; }) || projects[0];
|
||||
runtime.projectId = project.id;
|
||||
return project;
|
||||
}
|
||||
|
||||
function activeBlock(project) {
|
||||
var blocks = project ? projectBlocks(project) : [];
|
||||
if (!blocks.length) return "";
|
||||
if (blocks.indexOf(runtime.blockId) === -1) runtime.blockId = blocks[0];
|
||||
return runtime.blockId;
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
runtime.status = message;
|
||||
var status = document.querySelector(".vox-bs-status");
|
||||
if (status) status.textContent = message;
|
||||
}
|
||||
|
||||
function downloadJson(filename, data) {
|
||||
var blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function safeFilename(value) {
|
||||
return String(value || "voxelier")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "voxelier";
|
||||
}
|
||||
|
||||
function blockstateJson(definition) {
|
||||
var def = normalizeDefinition("minecraft:stone", definition);
|
||||
if (def.multipart && def.multipart.length) return { multipart: def.multipart };
|
||||
return { variants: def.variants };
|
||||
}
|
||||
|
||||
function exportSchema(project, record) {
|
||||
var definitions = {};
|
||||
Object.keys(record.definitions || {}).forEach(function (blockId) {
|
||||
definitions[blockId] = normalizeDefinition(blockId, record.definitions[blockId]);
|
||||
});
|
||||
var cells = Object.keys(project.cells || {}).sort().map(function (coord) {
|
||||
var parsed = parseCellValue(project.cells[coord]);
|
||||
var cellState = record.cellStates[coord];
|
||||
var def = definitions[parsed.blockId];
|
||||
var states = normalizeState(parsed.states);
|
||||
if (cellState && cellState.blockId === parsed.blockId) {
|
||||
states = normalizeState(cellState.states);
|
||||
} else if (!Object.keys(states).length && def) {
|
||||
states = normalizeState(def.defaultState);
|
||||
}
|
||||
var pos = coordFromString(coord);
|
||||
return {
|
||||
pos: pos ? [pos.x, pos.y, pos.z] : coord,
|
||||
block: parsed.blockId,
|
||||
states: states,
|
||||
rotation: parsed.rotation,
|
||||
ref: serializeBlockRef(parsed.blockId, states, parsed.rotation)
|
||||
};
|
||||
});
|
||||
return {
|
||||
kind: "voxelier-minecraft-schema",
|
||||
version: 1,
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
size: { width: project.width, height: project.height, depth: project.depth },
|
||||
palette: projectBlocks(project)
|
||||
},
|
||||
blockstates: definitions,
|
||||
cells: cells
|
||||
};
|
||||
}
|
||||
|
||||
function currentContext() {
|
||||
var projects = loadProjects();
|
||||
var project = activeProject(projects);
|
||||
var store = loadStore();
|
||||
var record = project ? projectRecord(store, project.id) : null;
|
||||
var blockId = project ? activeBlock(project) : "";
|
||||
if (record && blockId && !record.definitions[blockId]) {
|
||||
record.definitions[blockId] = defaultDefinition(blockId);
|
||||
saveStore(store);
|
||||
}
|
||||
return { projects: projects, project: project, store: store, record: record, blockId: blockId };
|
||||
}
|
||||
|
||||
function render() {
|
||||
var root = document.getElementById("vox-bs-root");
|
||||
if (!root) {
|
||||
root = document.createElement("div");
|
||||
root.id = "vox-bs-root";
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
|
||||
var ctx = currentContext();
|
||||
var projects = ctx.projects;
|
||||
var project = ctx.project;
|
||||
var record = ctx.record;
|
||||
var blockId = ctx.blockId;
|
||||
var blocks = project ? projectBlocks(project) : [];
|
||||
var def = record && blockId ? normalizeDefinition(blockId, record.definitions[blockId]) : null;
|
||||
var cellCount = record ? Object.keys(record.cellStates || {}).length : 0;
|
||||
|
||||
root.innerHTML =
|
||||
'<button class="vox-bs-toggle" type="button" data-vox-action="toggle" aria-expanded="' + (runtime.open ? "true" : "false") + '">Blockstates</button>' +
|
||||
'<aside class="vox-bs-panel ' + (runtime.open ? "is-open" : "") + '" aria-label="Blockstate manager">' +
|
||||
'<header class="vox-bs-head">' +
|
||||
'<div class="vox-bs-title"><span>Minecraft</span><strong>Blockstates</strong></div>' +
|
||||
'<button class="vox-bs-close" type="button" data-vox-action="toggle" aria-label="Fermer">x</button>' +
|
||||
'</header>' +
|
||||
'<nav class="vox-bs-tabs" aria-label="Blockstate sections">' +
|
||||
tabButton("definition", "Bloc") +
|
||||
tabButton("cells", "Cellules") +
|
||||
tabButton("export", "Export") +
|
||||
'</nav>' +
|
||||
'<div class="vox-bs-body">' +
|
||||
renderProjectPicker(projects, project, blocks, blockId) +
|
||||
(!project ? '<div class="vox-bs-empty">Aucun projet Voxelier trouve dans localStorage.</div>' : '') +
|
||||
(project && runtime.tab === "definition" ? renderDefinition(blockId, def) : '') +
|
||||
(project && runtime.tab === "cells" ? renderCells(blockId, def, record, cellCount) : '') +
|
||||
(project && runtime.tab === "export" ? renderExport(project, blockId, def, record, cellCount) : '') +
|
||||
'</div>' +
|
||||
'<footer class="vox-bs-footer">' +
|
||||
'<div class="vox-bs-actions">' +
|
||||
'<button type="button" data-vox-action="refresh">Refresh</button>' +
|
||||
'<button type="button" data-vox-action="copy-ref">Copier ref</button>' +
|
||||
'<button type="button" class="vox-bs-primary" data-vox-action="export-schema">Export schema</button>' +
|
||||
'</div>' +
|
||||
'<span class="vox-bs-status">' + html(runtime.status) + '</span>' +
|
||||
'<input id="vox-bs-import-input" class="hidden-input" type="file" accept="application/json,.json" />' +
|
||||
'</footer>' +
|
||||
'</aside>';
|
||||
}
|
||||
|
||||
function tabButton(id, label) {
|
||||
return '<button type="button" data-vox-tab="' + attr(id) + '" class="' + (runtime.tab === id ? "is-active" : "") + '">' + html(label) + '</button>';
|
||||
}
|
||||
|
||||
function renderProjectPicker(projects, project, blocks, blockId) {
|
||||
var projectOptions = projects.map(function (item) {
|
||||
return '<option value="' + attr(item.id) + '"' + (project && item.id === project.id ? " selected" : "") + '>' + html(item.name || item.id) + '</option>';
|
||||
}).join("");
|
||||
var blockOptions = blocks.map(function (id) {
|
||||
return '<option value="' + attr(id) + '"' + (id === blockId ? " selected" : "") + '>' + html(id) + '</option>';
|
||||
}).join("");
|
||||
return '<section class="vox-bs-section">' +
|
||||
'<div class="vox-bs-grid">' +
|
||||
'<label class="vox-bs-field is-wide"><span>Projet</span><select id="vox-bs-project">' + projectOptions + '</select></label>' +
|
||||
'<label class="vox-bs-field is-wide"><span>Bloc</span><select id="vox-bs-block">' + blockOptions + '</select></label>' +
|
||||
'</div>' +
|
||||
'</section>';
|
||||
}
|
||||
|
||||
function renderDefinition(blockId, def) {
|
||||
var props = Object.keys(def.properties || {});
|
||||
var propRows = props.length ? props.map(function (name) {
|
||||
return '<div class="vox-bs-row">' +
|
||||
'<div><code>' + html(name) + '</code><small>' + html(def.properties[name].join(", ")) + '</small></div>' +
|
||||
'<button type="button" data-vox-action="remove-prop" data-prop="' + attr(name) + '">Retirer</button>' +
|
||||
'</div>';
|
||||
}).join("") : '<div class="vox-bs-empty">Aucune propriete pour ce bloc.</div>';
|
||||
var defaults = props.length ? props.map(function (name) {
|
||||
return '<label class="vox-bs-field"><span>' + html(name) + '</span><select class="vox-bs-default" data-prop="' + attr(name) + '">' +
|
||||
def.properties[name].map(function (value) {
|
||||
return '<option value="' + attr(value) + '"' + (def.defaultState[name] === value ? " selected" : "") + '>' + html(value) + '</option>';
|
||||
}).join("") +
|
||||
'</select></label>';
|
||||
}).join("") : "";
|
||||
var variants = Object.keys(def.variants || {}).sort();
|
||||
var variantRows = variants.length ? variants.map(function (key) {
|
||||
var variant = def.variants[key];
|
||||
return '<div class="vox-bs-row">' +
|
||||
'<div><code>' + html(key || "default") + '</code><small>' + html([variant.model, variant.x != null ? "x=" + variant.x : "", variant.y != null ? "y=" + variant.y : ""].filter(Boolean).join(" | ")) + '</small></div>' +
|
||||
'<button type="button" data-vox-action="remove-variant" data-key="' + attr(key) + '">Retirer</button>' +
|
||||
'</div>';
|
||||
}).join("") : '<div class="vox-bs-empty">Aucune variante.</div>';
|
||||
|
||||
return '<section class="vox-bs-section">' +
|
||||
'<div class="vox-bs-section-title"><div><span>Definition</span><strong>' + html(blockId) + '</strong></div></div>' +
|
||||
'<div class="vox-bs-actions">' +
|
||||
'<button type="button" data-vox-action="preset-facing">Preset facing</button>' +
|
||||
'<button type="button" data-vox-action="preset-axis">Preset axis</button>' +
|
||||
'<button type="button" data-vox-action="generate-variants">Generer variantes</button>' +
|
||||
'</div>' +
|
||||
'<div class="vox-bs-grid">' +
|
||||
'<label class="vox-bs-field"><span>Propriete</span><input id="vox-bs-prop-name" placeholder="facing" /></label>' +
|
||||
'<label class="vox-bs-field"><span>Valeurs</span><input id="vox-bs-prop-values" placeholder="north,south,east,west" /></label>' +
|
||||
'</div>' +
|
||||
'<div class="vox-bs-actions"><button type="button" data-vox-action="add-prop">Ajouter propriete</button></div>' +
|
||||
'<div class="vox-bs-list">' + propRows + '</div>' +
|
||||
(defaults ? '<div class="vox-bs-grid">' + defaults + '</div>' : '') +
|
||||
'<div class="vox-bs-grid">' +
|
||||
'<label class="vox-bs-field is-wide"><span>Variant key</span><input id="vox-bs-variant-key" placeholder="facing=north" /></label>' +
|
||||
'<label class="vox-bs-field is-wide"><span>Model</span><input id="vox-bs-variant-model" value="' + attr(blockModelId(blockId)) + '" /></label>' +
|
||||
'<label class="vox-bs-field"><span>X</span><input id="vox-bs-variant-x" type="number" step="90" placeholder="0" /></label>' +
|
||||
'<label class="vox-bs-field"><span>Y</span><input id="vox-bs-variant-y" type="number" step="90" placeholder="0" /></label>' +
|
||||
'</div>' +
|
||||
'<div class="vox-bs-actions"><button type="button" data-vox-action="add-variant">Ajouter variante</button></div>' +
|
||||
'<div class="vox-bs-list">' + variantRows + '</div>' +
|
||||
'</section>';
|
||||
}
|
||||
|
||||
function renderCells(blockId, def, record, cellCount) {
|
||||
var props = Object.keys(def.properties || {});
|
||||
var selectors = props.map(function (name) {
|
||||
return '<label class="vox-bs-field"><span>' + html(name) + '</span><select class="vox-bs-cell-state" data-prop="' + attr(name) + '">' +
|
||||
def.properties[name].map(function (value) {
|
||||
var selected = (def.defaultState || {})[name] === value ? " selected" : "";
|
||||
return '<option value="' + attr(value) + '"' + selected + '>' + html(value) + '</option>';
|
||||
}).join("") +
|
||||
'</select></label>';
|
||||
}).join("");
|
||||
var rows = Object.keys(record.cellStates || {}).sort().slice(0, 80).map(function (coord) {
|
||||
var cell = record.cellStates[coord];
|
||||
return '<div class="vox-bs-row">' +
|
||||
'<div><code>' + html(coord) + '</code><small>' + html(cell.blockId + (stateKey(cell.states) ? "[" + stateKey(cell.states) + "]" : "")) + '</small></div>' +
|
||||
'<button type="button" data-vox-action="remove-cell" data-coord="' + attr(coord) + '">Retirer</button>' +
|
||||
'</div>';
|
||||
}).join("");
|
||||
return '<section class="vox-bs-section">' +
|
||||
'<div class="vox-bs-section-title"><div><span>Cell states</span><strong>' + cellCount + ' cellule(s)</strong></div></div>' +
|
||||
'<div class="vox-bs-grid">' +
|
||||
'<label class="vox-bs-field"><span>X</span><input id="vox-bs-x" type="number" min="0" value="0" /></label>' +
|
||||
'<label class="vox-bs-field"><span>Y</span><input id="vox-bs-y" type="number" min="0" value="0" /></label>' +
|
||||
'<label class="vox-bs-field"><span>Z</span><input id="vox-bs-z" type="number" min="0" value="0" /></label>' +
|
||||
'<label class="vox-bs-field"><span>Bloc</span><input id="vox-bs-cell-block" value="' + attr(blockId) + '" /></label>' +
|
||||
'</div>' +
|
||||
(selectors ? '<div class="vox-bs-grid">' + selectors + '</div>' : '<div class="vox-bs-empty">Ajoute une propriete dans Bloc avant d attacher un etat a une cellule.</div>') +
|
||||
'<div class="vox-bs-actions"><button type="button" class="vox-bs-primary" data-vox-action="apply-cell">Appliquer cellule</button></div>' +
|
||||
'<div class="vox-bs-list">' + (rows || '<div class="vox-bs-empty">Aucune cellule avec etat explicite.</div>') + '</div>' +
|
||||
'</section>';
|
||||
}
|
||||
|
||||
function renderExport(project, blockId, def, record, cellCount) {
|
||||
var schema = exportSchema(project, record);
|
||||
var blockJson = def ? blockstateJson(def) : {};
|
||||
return '<section class="vox-bs-section">' +
|
||||
'<div class="vox-bs-section-title"><div><span>Schema</span><strong>' + html(project.name) + '</strong></div></div>' +
|
||||
'<div class="vox-bs-grid">' +
|
||||
'<div class="vox-bs-row"><div><code>' + Object.keys(record.definitions || {}).length + '</code><small>definitions blockstate</small></div></div>' +
|
||||
'<div class="vox-bs-row"><div><code>' + cellCount + '</code><small>cell states</small></div></div>' +
|
||||
'<div class="vox-bs-row"><div><code>' + schema.cells.length + '</code><small>voxels exportes</small></div></div>' +
|
||||
'<div class="vox-bs-row"><div><code>' + html(blockId || "-") + '</code><small>bloc actif</small></div></div>' +
|
||||
'</div>' +
|
||||
'<div class="vox-bs-actions">' +
|
||||
'<button type="button" class="vox-bs-primary" data-vox-action="export-schema">Export schema</button>' +
|
||||
'<button type="button" data-vox-action="export-blockstate">Export blockstate JSON</button>' +
|
||||
'<button type="button" data-vox-action="import-json">Import JSON</button>' +
|
||||
'</div>' +
|
||||
'<label class="vox-bs-field is-wide"><span>Preview schema</span><textarea readonly>' + html(JSON.stringify(schema, null, 2).slice(0, 3000)) + '</textarea></label>' +
|
||||
'<label class="vox-bs-field is-wide"><span>Preview blockstate</span><textarea readonly>' + html(JSON.stringify(blockJson, null, 2)) + '</textarea></label>' +
|
||||
'</section>';
|
||||
}
|
||||
|
||||
function mutateDefinition(mutator) {
|
||||
var ctx = currentContext();
|
||||
if (!ctx.project || !ctx.record || !ctx.blockId) return;
|
||||
var def = normalizeDefinition(ctx.blockId, ctx.record.definitions[ctx.blockId]);
|
||||
mutator(def, ctx);
|
||||
ctx.record.definitions[ctx.blockId] = normalizeDefinition(ctx.blockId, def);
|
||||
saveStore(ctx.store);
|
||||
render();
|
||||
}
|
||||
|
||||
function addProperty() {
|
||||
var nameInput = document.getElementById("vox-bs-prop-name");
|
||||
var valuesInput = document.getElementById("vox-bs-prop-values");
|
||||
var name = nameInput ? nameInput.value.trim() : "";
|
||||
var values = valuesInput ? valuesInput.value.split(",").map(function (value) { return value.trim(); }).filter(Boolean) : [];
|
||||
if (!name || !values.length) return setStatus("Propriete incomplete.");
|
||||
mutateDefinition(function (def) {
|
||||
def.properties[name] = Array.from(new Set(values));
|
||||
if (!def.defaultState[name]) def.defaultState[name] = values[0];
|
||||
});
|
||||
setStatus("Propriete ajoutee: " + name + ".");
|
||||
}
|
||||
|
||||
function addVariant() {
|
||||
var keyInput = document.getElementById("vox-bs-variant-key");
|
||||
var modelInput = document.getElementById("vox-bs-variant-model");
|
||||
var xInput = document.getElementById("vox-bs-variant-x");
|
||||
var yInput = document.getElementById("vox-bs-variant-y");
|
||||
var key = keyInput ? stateKey(parseStateString(keyInput.value)) : "";
|
||||
var model = modelInput ? modelInput.value.trim() : "";
|
||||
if (!model) return setStatus("Model manquant.");
|
||||
mutateDefinition(function (def) {
|
||||
var variant = { model: model };
|
||||
if (xInput && xInput.value !== "") variant.x = Number(xInput.value);
|
||||
if (yInput && yInput.value !== "") variant.y = Number(yInput.value);
|
||||
def.variants[key] = normalizeVariant(variant);
|
||||
});
|
||||
setStatus("Variante ajoutee.");
|
||||
}
|
||||
|
||||
function generateVariants() {
|
||||
mutateDefinition(function (def, ctx) {
|
||||
var combos = allCombinations(def.properties);
|
||||
combos.forEach(function (states) {
|
||||
var key = stateKey(states);
|
||||
if (!def.variants[key]) {
|
||||
var variant = { model: blockModelId(ctx.blockId) };
|
||||
if (states.facing && stateDirections[states.facing]) {
|
||||
Object.assign(variant, stateDirections[states.facing], { uvlock: true });
|
||||
}
|
||||
if (states.axis && axisRotations[states.axis]) {
|
||||
Object.assign(variant, axisRotations[states.axis]);
|
||||
}
|
||||
def.variants[key] = variant;
|
||||
}
|
||||
});
|
||||
});
|
||||
setStatus("Variantes generees.");
|
||||
}
|
||||
|
||||
function applyCellState() {
|
||||
var ctx = currentContext();
|
||||
if (!ctx.project || !ctx.record) return;
|
||||
var x = Number.parseInt((document.getElementById("vox-bs-x") || {}).value, 10);
|
||||
var y = Number.parseInt((document.getElementById("vox-bs-y") || {}).value, 10);
|
||||
var z = Number.parseInt((document.getElementById("vox-bs-z") || {}).value, 10);
|
||||
var blockInput = document.getElementById("vox-bs-cell-block");
|
||||
var blockId = blockInput && blockInput.value.trim() ? blockInput.value.trim() : ctx.blockId;
|
||||
if (![x, y, z].every(Number.isInteger) || !isBlockId(blockId)) return setStatus("Coordonnees ou bloc invalides.");
|
||||
if (x < 0 || y < 0 || z < 0 || x >= ctx.project.width || y >= ctx.project.height || z >= ctx.project.depth) {
|
||||
return setStatus("Cellule hors schema.");
|
||||
}
|
||||
var states = {};
|
||||
document.querySelectorAll(".vox-bs-cell-state").forEach(function (select) {
|
||||
if (select.dataset.prop) states[select.dataset.prop] = select.value;
|
||||
});
|
||||
ctx.record.cellStates[[x, y, z].join(",")] = { blockId: blockId, states: normalizeState(states) };
|
||||
saveStore(ctx.store);
|
||||
render();
|
||||
setStatus("Cellule " + [x, y, z].join(",") + " mise a jour.");
|
||||
}
|
||||
|
||||
function removeCell(coord) {
|
||||
var ctx = currentContext();
|
||||
if (!ctx.record) return;
|
||||
delete ctx.record.cellStates[coord];
|
||||
saveStore(ctx.store);
|
||||
render();
|
||||
setStatus("Cellule retiree.");
|
||||
}
|
||||
|
||||
function exportCurrentSchema() {
|
||||
var ctx = currentContext();
|
||||
if (!ctx.project || !ctx.record) return setStatus("Aucun projet a exporter.");
|
||||
downloadJson(safeFilename(ctx.project.name) + ".voxelier-schema.json", exportSchema(ctx.project, ctx.record));
|
||||
setStatus("Schema exporte.");
|
||||
}
|
||||
|
||||
function exportCurrentBlockstate() {
|
||||
var ctx = currentContext();
|
||||
if (!ctx.blockId || !ctx.record) return setStatus("Aucun bloc actif.");
|
||||
var def = normalizeDefinition(ctx.blockId, ctx.record.definitions[ctx.blockId]);
|
||||
downloadJson(cleanBlockPath(ctx.blockId) + ".blockstate.json", blockstateJson(def));
|
||||
setStatus("Blockstate exporte.");
|
||||
}
|
||||
|
||||
function copyCurrentRef() {
|
||||
var ctx = currentContext();
|
||||
if (!ctx.blockId || !ctx.record) return;
|
||||
var def = normalizeDefinition(ctx.blockId, ctx.record.definitions[ctx.blockId]);
|
||||
var ref = serializeBlockRef(ctx.blockId, def.defaultState, 0);
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(ref).then(function () {
|
||||
setStatus("Reference copiee: " + ref);
|
||||
}, function () {
|
||||
setStatus(ref);
|
||||
});
|
||||
} else {
|
||||
setStatus(ref);
|
||||
}
|
||||
}
|
||||
|
||||
function importJsonFile(file) {
|
||||
if (!file) return;
|
||||
file.text().then(function (text) {
|
||||
var data = JSON.parse(text);
|
||||
var ctx = currentContext();
|
||||
if (!ctx.project || !ctx.record || !ctx.blockId) return;
|
||||
if (data.kind === "voxelier-minecraft-schema" && data.blockstates) {
|
||||
Object.keys(data.blockstates).forEach(function (blockId) {
|
||||
ctx.record.definitions[blockId] = normalizeDefinition(blockId, data.blockstates[blockId]);
|
||||
});
|
||||
} else if (data.variants || data.multipart) {
|
||||
ctx.record.definitions[ctx.blockId] = normalizeDefinition(ctx.blockId, data);
|
||||
} else if (data.byProject) {
|
||||
Object.assign(ctx.store.byProject, data.byProject);
|
||||
} else {
|
||||
throw new Error("Format blockstate non reconnu.");
|
||||
}
|
||||
saveStore(ctx.store);
|
||||
render();
|
||||
setStatus("JSON importe.");
|
||||
}).catch(function (error) {
|
||||
setStatus(error instanceof Error ? error.message : "Import impossible.");
|
||||
});
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
var button = event.target.closest("[data-vox-action], [data-vox-tab]");
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
var action = button.dataset.voxAction;
|
||||
var tab = button.dataset.voxTab;
|
||||
if (tab) {
|
||||
runtime.tab = tab;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (action === "toggle") {
|
||||
runtime.open = !runtime.open;
|
||||
render();
|
||||
} else if (action === "refresh") {
|
||||
render();
|
||||
setStatus("Galerie relue.");
|
||||
} else if (action === "add-prop") {
|
||||
addProperty();
|
||||
} else if (action === "remove-prop") {
|
||||
mutateDefinition(function (def) {
|
||||
delete def.properties[button.dataset.prop];
|
||||
delete def.defaultState[button.dataset.prop];
|
||||
});
|
||||
setStatus("Propriete retiree.");
|
||||
} else if (action === "preset-facing") {
|
||||
mutateDefinition(function (def, ctx) {
|
||||
Object.assign(def, facingDefinition(ctx.blockId));
|
||||
});
|
||||
setStatus("Preset facing applique.");
|
||||
} else if (action === "preset-axis") {
|
||||
mutateDefinition(function (def, ctx) {
|
||||
Object.assign(def, axisDefinition(ctx.blockId));
|
||||
});
|
||||
setStatus("Preset axis applique.");
|
||||
} else if (action === "generate-variants") {
|
||||
generateVariants();
|
||||
} else if (action === "add-variant") {
|
||||
addVariant();
|
||||
} else if (action === "remove-variant") {
|
||||
mutateDefinition(function (def) {
|
||||
delete def.variants[button.dataset.key || ""];
|
||||
});
|
||||
setStatus("Variante retiree.");
|
||||
} else if (action === "apply-cell") {
|
||||
applyCellState();
|
||||
} else if (action === "remove-cell") {
|
||||
removeCell(button.dataset.coord);
|
||||
} else if (action === "export-schema") {
|
||||
exportCurrentSchema();
|
||||
} else if (action === "export-blockstate") {
|
||||
exportCurrentBlockstate();
|
||||
} else if (action === "copy-ref") {
|
||||
copyCurrentRef();
|
||||
} else if (action === "import-json") {
|
||||
var input = document.getElementById("vox-bs-import-input");
|
||||
if (input) input.click();
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(event) {
|
||||
if (event.target.id === "vox-bs-project") {
|
||||
runtime.projectId = event.target.value;
|
||||
runtime.blockId = "";
|
||||
render();
|
||||
} else if (event.target.id === "vox-bs-block") {
|
||||
runtime.blockId = event.target.value;
|
||||
render();
|
||||
} else if (event.target.classList.contains("vox-bs-default")) {
|
||||
var prop = event.target.dataset.prop;
|
||||
mutateDefinition(function (def) {
|
||||
def.defaultState[prop] = event.target.value;
|
||||
});
|
||||
setStatus("Etat par defaut mis a jour.");
|
||||
} else if (event.target.id === "vox-bs-import-input") {
|
||||
importJsonFile(event.target.files && event.target.files[0]);
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function stopPanelEvent(event) {
|
||||
if (event.target.closest(".vox-bs-panel") || event.target.closest(".vox-bs-toggle")) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
render();
|
||||
document.addEventListener("click", onClick);
|
||||
document.addEventListener("change", onChange);
|
||||
["pointerdown", "pointerup", "wheel", "contextmenu"].forEach(function (name) {
|
||||
document.addEventListener(name, stopPanelEvent, true);
|
||||
});
|
||||
window.setInterval(function () {
|
||||
if (!runtime.open) return;
|
||||
var raw = getGalleryRaw();
|
||||
if (raw !== runtime.lastGalleryRaw) render();
|
||||
}, 3000);
|
||||
window.VoxelierBlockstates = {
|
||||
loadStore: loadStore,
|
||||
saveStore: saveStore,
|
||||
exportSchema: function () {
|
||||
var ctx = currentContext();
|
||||
return ctx.project && ctx.record ? exportSchema(ctx.project, ctx.record) : null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
:root {
|
||||
--botsu-voxel-sidebar-width: clamp(17rem, 23vw, 22.5rem);
|
||||
--radius-lg: 4px;
|
||||
--radius: 3px;
|
||||
--radius-sm: 2px;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
body {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.app-shell,
|
||||
.app-shell.focus-mode,
|
||||
.app-shell.top-collapsed,
|
||||
.app-shell.bottom-collapsed,
|
||||
.app-shell.left-collapsed,
|
||||
.app-shell.right-collapsed {
|
||||
--top-panel: 0px;
|
||||
--bottom-panel: 0px;
|
||||
--left-panel: 0px;
|
||||
--right-panel: var(--botsu-voxel-sidebar-width);
|
||||
display: block;
|
||||
height: 100%;
|
||||
padding: 3.25rem 0 0;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.statusbar,
|
||||
.gallery-panel,
|
||||
.online-overlay,
|
||||
.edge-openers,
|
||||
.orientation-card,
|
||||
.studio-header,
|
||||
.viewport-actions,
|
||||
.viewport-hud,
|
||||
.fps-prompt {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.focus-mode .workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--botsu-voxel-sidebar-width);
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.studio {
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
inset: auto;
|
||||
grid-column: 1;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.focus-mode .studio {
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
inset: auto;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.viewport-shell,
|
||||
.focus-mode .viewport-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.palette-panel,
|
||||
.right-collapsed .palette-panel {
|
||||
grid-column: 2;
|
||||
display: flex !important;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0.65rem;
|
||||
visibility: visible !important;
|
||||
pointer-events: auto !important;
|
||||
opacity: 1 !important;
|
||||
color: #f5f5f5;
|
||||
background: #111111;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.compact-toolbar,
|
||||
.app-shell:not(.top-collapsed) .compact-toolbar,
|
||||
.focus-mode .compact-toolbar,
|
||||
.focus-mode.preview-top .compact-toolbar {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
inset: 0 0 auto;
|
||||
left: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 3.25rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
transform: none;
|
||||
color: #f5f5f5;
|
||||
background: #111111;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.compact-toolbar button,
|
||||
.palette-panel button,
|
||||
.palette-panel input,
|
||||
.palette-panel select {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.compact-toolbar .tool-button .tool-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.remote-ghost-name,
|
||||
.remote-ghost-core,
|
||||
.remote-ghost-aura {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.vox-bs-toggle {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vox-bs-panel,
|
||||
.vox-bs-panel.is-open {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
inset: 3.25rem 0 0 auto;
|
||||
width: var(--botsu-voxel-sidebar-width);
|
||||
color: #f5f5f5;
|
||||
background: #111111;
|
||||
border: 0;
|
||||
border-left: 1px solid #3a3a3a;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.vox-bs-panel button,
|
||||
.vox-bs-panel input,
|
||||
.vox-bs-panel select,
|
||||
.vox-bs-panel textarea,
|
||||
.vox-bs-section,
|
||||
.vox-bs-row {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.vox-bs-panel .vox-bs-head,
|
||||
.vox-bs-panel .vox-bs-tabs,
|
||||
.vox-bs-panel .vox-bs-footer {
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
:root {
|
||||
--botsu-voxel-sidebar-width: 17rem;
|
||||
}
|
||||
|
||||
.compact-toolbar .tool-label {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.compact-toolbar .tool-button.is-active .tool-label,
|
||||
.compact-toolbar .tool-button.is-armed .tool-label {
|
||||
display: inline !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.workspace,
|
||||
.focus-mode .workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.viewport-shell,
|
||||
.focus-mode .viewport-shell {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.palette-panel,
|
||||
.right-collapsed .palette-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vox-bs-panel,
|
||||
.vox-bs-panel.is-open {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>BOTSU Voxel Room</title>
|
||||
<script>
|
||||
// BOTSU owns authenticated network sync. Voxelier keeps its same-origin
|
||||
// BroadcastChannel bridge, while its standalone websocket transport is
|
||||
// replaced by an inert API-compatible transport inside the Matrix room.
|
||||
try {
|
||||
window.WebSocket = class BotsuEmbeddedWebSocket extends EventTarget {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
|
||||
readyState = BotsuEmbeddedWebSocket.CLOSED;
|
||||
|
||||
send() {}
|
||||
|
||||
close() {
|
||||
this.readyState = BotsuEmbeddedWebSocket.CLOSED;
|
||||
}
|
||||
};
|
||||
localStorage.setItem(
|
||||
'voxelier-theme-v1',
|
||||
JSON.stringify({ mode: 'night', background: '#050505', accent: '#f5f5f5' })
|
||||
);
|
||||
localStorage.setItem(
|
||||
'voxelier-scene-background-v1',
|
||||
JSON.stringify({ mode: 'custom', custom: '#050505' })
|
||||
);
|
||||
} catch {
|
||||
// Storage can be unavailable in a hardened browser; the editor still
|
||||
// starts with Voxelier's own safe defaults.
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="/voxelier/assets/index-B558jG0T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/voxelier/assets/index-B72YrQJ1.css" />
|
||||
<link rel="stylesheet" crossorigin href="/voxelier/blockstate-manager.css" />
|
||||
<link rel="stylesheet" crossorigin href="/voxelier/botsu-embedded.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" crossorigin src="/voxelier/blockstate-manager.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 269 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 179 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 104 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 414 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 156 B |
@@ -42,6 +42,34 @@ const copyFiles = {
|
||||
src: 'public/locales',
|
||||
dest: 'public/',
|
||||
},
|
||||
{
|
||||
src: 'vendor/voxelier/index.html',
|
||||
dest: 'voxelier',
|
||||
},
|
||||
{
|
||||
src: 'vendor/voxelier/botsu-embedded.css',
|
||||
dest: 'voxelier',
|
||||
},
|
||||
{
|
||||
src: 'vendor/voxelier/blockstate-manager.js',
|
||||
dest: 'voxelier',
|
||||
},
|
||||
{
|
||||
src: 'vendor/voxelier/blockstate-manager.css',
|
||||
dest: 'voxelier',
|
||||
},
|
||||
{
|
||||
src: 'vendor/voxelier/assets/*',
|
||||
dest: 'voxelier/assets',
|
||||
},
|
||||
{
|
||||
src: 'vendor/voxelier/packs/*',
|
||||
dest: 'packs',
|
||||
},
|
||||
{
|
||||
src: 'vendor/voxelier/sanctuary-assets',
|
||||
dest: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Generated
+7
@@ -57,6 +57,7 @@
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.7.1",
|
||||
"hls.js": "^1.6.17",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
@@ -11992,6 +11993,12 @@
|
||||
"resolved": "apps/client",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.17",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.17.tgz",
|
||||
"integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --experimental-strip-types --test src/profile.test.ts src/presence.test.ts src/matrix-workspace.test.ts src/document-object.test.ts src/cookies.test.ts",
|
||||
"test": "node --experimental-strip-types --test src/profile.test.ts src/presence.test.ts src/matrix-workspace.test.ts src/document-object.test.ts src/cookies.test.ts src/watch-room.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -125,3 +125,16 @@ export {
|
||||
type TcgGraveyardPage,
|
||||
type TcgRarity,
|
||||
} from "./tcg.ts";
|
||||
|
||||
export {
|
||||
BOTSU_WATCH_EVENT_TYPE,
|
||||
BOTSU_WATCH_ROOM_TYPE,
|
||||
BOTSU_WATCH_SCHEMA_VERSION,
|
||||
MAXIMUM_WATCH_POSITION_MS,
|
||||
MAXIMUM_WATCH_SOURCE_TITLE_LENGTH,
|
||||
MAXIMUM_WATCH_SOURCE_URL_LENGTH,
|
||||
createEmptyBotsuWatchSession,
|
||||
parseBotsuWatchSession,
|
||||
type BotsuWatchSession,
|
||||
type BotsuWatchSource,
|
||||
} from "./watch-room.ts";
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createEmptyBotsuWatchSession,
|
||||
parseBotsuWatchSession,
|
||||
} from "./watch-room.ts";
|
||||
|
||||
test("watch sessions validate a bounded HTTPS media source", () => {
|
||||
assert.deepEqual(
|
||||
parseBotsuWatchSession({
|
||||
version: 1,
|
||||
source: {
|
||||
url: "https://media.botsu.net/films/demo/master.m3u8",
|
||||
title: "Film du samedi",
|
||||
},
|
||||
paused: false,
|
||||
positionMs: 42_000,
|
||||
}),
|
||||
{
|
||||
version: 1,
|
||||
source: {
|
||||
url: "https://media.botsu.net/films/demo/master.m3u8",
|
||||
title: "Film du samedi",
|
||||
},
|
||||
paused: false,
|
||||
positionMs: 42_000,
|
||||
}
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseBotsuWatchSession({
|
||||
version: 1,
|
||||
source: { url: "javascript:alert(1)", title: "Non" },
|
||||
paused: true,
|
||||
positionMs: 0,
|
||||
}),
|
||||
/HTTPS/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseBotsuWatchSession({
|
||||
version: 1,
|
||||
source: {
|
||||
url: "https://user:secret@media.botsu.net/movie.mp4",
|
||||
title: "Non",
|
||||
},
|
||||
paused: true,
|
||||
positionMs: 0,
|
||||
}),
|
||||
/credential-free/
|
||||
);
|
||||
});
|
||||
|
||||
test("empty watch sessions are paused and contain no source", () => {
|
||||
assert.deepEqual(createEmptyBotsuWatchSession(), {
|
||||
version: 1,
|
||||
source: null,
|
||||
paused: true,
|
||||
positionMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("watch sessions reject extra keys, accessors, and invalid positions", () => {
|
||||
const session = {
|
||||
version: 1,
|
||||
source: null,
|
||||
paused: true,
|
||||
positionMs: 0,
|
||||
};
|
||||
assert.throws(
|
||||
() => parseBotsuWatchSession({ ...session, accessToken: "secret" }),
|
||||
/keys/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseBotsuWatchSession({ ...session, positionMs: -1 }),
|
||||
/positionMs/
|
||||
);
|
||||
|
||||
let invoked = false;
|
||||
const accessor = { ...session } as Record<string, unknown>;
|
||||
Object.defineProperty(accessor, "paused", {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
invoked = true;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.throws(() => parseBotsuWatchSession(accessor), /paused/);
|
||||
assert.equal(invoked, false);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
export const BOTSU_WATCH_EVENT_TYPE = "net.botsu.watch.session";
|
||||
export const BOTSU_WATCH_ROOM_TYPE = "net.botsu.watch";
|
||||
export const BOTSU_WATCH_SCHEMA_VERSION = 1 as const;
|
||||
export const MAXIMUM_WATCH_SOURCE_URL_LENGTH = 2_048;
|
||||
export const MAXIMUM_WATCH_SOURCE_TITLE_LENGTH = 120;
|
||||
export const MAXIMUM_WATCH_POSITION_MS = 7 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
export type BotsuWatchSource = {
|
||||
url: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type BotsuWatchSession = {
|
||||
version: 1;
|
||||
source: BotsuWatchSource | null;
|
||||
paused: boolean;
|
||||
positionMs: number;
|
||||
};
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
const snapshotRecord = (
|
||||
value: unknown,
|
||||
allowedKeys: readonly string[],
|
||||
field: string
|
||||
): UnknownRecord => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TypeError(`${field} must be an object`);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value) as unknown;
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError(`${field} must be a plain object`);
|
||||
}
|
||||
|
||||
const unexpected = Reflect.ownKeys(value).find(
|
||||
(key) => typeof key !== "string" || !allowedKeys.includes(key)
|
||||
);
|
||||
if (unexpected !== undefined) {
|
||||
throw new TypeError(`${field} keys are invalid: ${String(unexpected)}`);
|
||||
}
|
||||
|
||||
const snapshot: UnknownRecord = {};
|
||||
allowedKeys.forEach((key) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor === undefined) return;
|
||||
if (!descriptor.enumerable || !("value" in descriptor)) {
|
||||
throw new TypeError(
|
||||
`${field}.${key} must be an enumerable data property`
|
||||
);
|
||||
}
|
||||
snapshot[key] = descriptor.value;
|
||||
});
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const parseSource = (value: unknown): BotsuWatchSource | null => {
|
||||
if (value === null) return null;
|
||||
const source = snapshotRecord(
|
||||
value,
|
||||
["url", "title"],
|
||||
"BotsuWatchSession.source"
|
||||
);
|
||||
if (
|
||||
typeof source.url !== "string" ||
|
||||
source.url.length < 1 ||
|
||||
source.url.length > MAXIMUM_WATCH_SOURCE_URL_LENGTH ||
|
||||
/[\u0000-\u001f\u007f]/.test(source.url)
|
||||
) {
|
||||
throw new TypeError("BotsuWatchSession.source.url is invalid");
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(source.url);
|
||||
} catch {
|
||||
throw new TypeError("BotsuWatchSession.source.url is invalid");
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password) {
|
||||
throw new TypeError(
|
||||
"BotsuWatchSession.source.url must be a credential-free HTTPS URL"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof source.title !== "string" ||
|
||||
source.title.length < 1 ||
|
||||
source.title.length > MAXIMUM_WATCH_SOURCE_TITLE_LENGTH ||
|
||||
source.title !== source.title.trim() ||
|
||||
/[\u0000-\u001f\u007f]/.test(source.title)
|
||||
) {
|
||||
throw new TypeError("BotsuWatchSession.source.title is invalid");
|
||||
}
|
||||
|
||||
return { url: url.href, title: source.title };
|
||||
};
|
||||
|
||||
export const parseBotsuWatchSession = (value: unknown): BotsuWatchSession => {
|
||||
const session = snapshotRecord(
|
||||
value,
|
||||
["version", "source", "paused", "positionMs"],
|
||||
"BotsuWatchSession"
|
||||
);
|
||||
if (session.version !== BOTSU_WATCH_SCHEMA_VERSION) {
|
||||
throw new TypeError("BotsuWatchSession.version must be 1");
|
||||
}
|
||||
if (typeof session.paused !== "boolean") {
|
||||
throw new TypeError("BotsuWatchSession.paused must be a boolean");
|
||||
}
|
||||
if (
|
||||
typeof session.positionMs !== "number" ||
|
||||
!Number.isSafeInteger(session.positionMs) ||
|
||||
session.positionMs < 0 ||
|
||||
session.positionMs > MAXIMUM_WATCH_POSITION_MS
|
||||
) {
|
||||
throw new TypeError("BotsuWatchSession.positionMs is invalid");
|
||||
}
|
||||
|
||||
return {
|
||||
version: BOTSU_WATCH_SCHEMA_VERSION,
|
||||
source: parseSource(session.source),
|
||||
paused: session.paused,
|
||||
positionMs: session.positionMs,
|
||||
};
|
||||
};
|
||||
|
||||
export const createEmptyBotsuWatchSession = (): BotsuWatchSession => ({
|
||||
version: BOTSU_WATCH_SCHEMA_VERSION,
|
||||
source: null,
|
||||
paused: true,
|
||||
positionMs: 0,
|
||||
});
|
||||
Reference in New Issue
Block a user