feat: add watch and voxel rooms

This commit is contained in:
koka
2026-08-08 16:00:42 +02:00
parent 4c136b0f09
commit eae9333307
44 changed files with 8131 additions and 53 deletions
+7
View File
@@ -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",
+2 -1
View File
@@ -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">
+24 -3
View File
@@ -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>
+21 -1
View File
@@ -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 ||
+34 -3
View File
@@ -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');
+9 -9
View File
@@ -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 nest 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 à latelier 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>
);
}
+1
View File
@@ -0,0 +1 @@
export { BotsuVoxelRoomView } from './BotsuVoxelRoomView';
+93
View File
@@ -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',
}
);
});
+215
View File
@@ -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). Ladresse est enregistrée dans létat Matrix du salon :
nutilisez pas dURL 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é · à linstant'}</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>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './BotsuWatchRoomView';
+44
View File
@@ -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), '--:--');
});
+77
View File
@@ -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')}`;
};
+585
View File
@@ -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;
}
}
+4
View File
@@ -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 = {
+3 -1
View File
@@ -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"
]
}
+2
View File
@@ -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"
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+296
View File
@@ -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
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function attr(value) {
return html(value).replace(/'/g, "&#39;");
}
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
View File
@@ -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
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

+28
View File
@@ -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

+7
View File
@@ -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",
+1 -1
View File
@@ -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": {
+13
View File
@@ -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";
+91
View File
@@ -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);
});
+131
View File
@@ -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,
});