45 changed files with 4795 additions and 107 deletions
+3
View File
@@ -15,6 +15,9 @@ BOTSU_DOCUMENT_STORE_ROOT=.data/documents
BOTSU_COOKIE_STORE_ROOT=.data/cookies
BOTSU_TCG_STORE_ROOT=.data/tcg
BOTSU_MOODBOARD_STORE_ROOT=.data/moodboard
BOTSU_RADIO_STORE_ROOT=.data/radio
# Matrix IDs separated by commas. Empty means read-only Radio management.
BOTSU_RADIO_CURATOR_IDS=
TCG_CATALOG_INDEX_PATH=.data/tcg/catalog-index.json
EMOJI_KITCHEN_ROOT=.data/emoji-kitchen/stickers
+4
View File
@@ -70,6 +70,10 @@ catalogue Discussions/Documents/Tableaux/Fichiers/Transferts/Services, un
lanceur à origines strictes et les réglages visuels persistants. Voir
`docs/architecture/application-shell.md`.
La Radio globale synchronisée est décrite dans
`docs/architecture/radio.md`, avec ses sources V1, son horloge locale persistante
et la configuration de la liste des curateurs Matrix.
## Dépôts Git
- `origin` : dépôt privé BOTSU sur `git.botsu.net`
+1 -1
View File
@@ -17,7 +17,7 @@
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "npm run test:botsu",
"test:botsu": "node --experimental-strip-types --test src/botsu/storyboard/model.test.ts src/botsu/storyboard/storyboard-ui.test.ts src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/moodboard/model.test.ts src/botsu/moodboard/controller.test.ts src/botsu/moodboard/moodboard-ui.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/kanban/model.test.ts src/botsu/kanban/integration.test.ts src/botsu/drawing/drawing-bridge.test.ts src/botsu/drawing/drawing-export.test.ts src/botsu/drawing/drawing-model.test.ts src/botsu/drawing/drawing-ui.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/voxel/voxel-color.test.ts src/botsu/voxel/voxel-ui.test.ts src/botsu/wikipedia/model.test.ts src/botsu/wikipedia/wikipedia-scraper.test.ts src/botsu/openstreetmap/geocoder.test.ts src/botsu/watch/jellyfin.test.ts src/botsu/watch/model.test.ts src/botsu/watch/providers.test.ts src/botsu/watch/watch-ui.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/storyboard/model.test.ts src/botsu/storyboard/storyboard-ui.test.ts src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/call/call-gallery.test.ts src/botsu/moodboard/model.test.ts src/botsu/moodboard/controller.test.ts src/botsu/moodboard/moodboard-ui.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/kanban/model.test.ts src/botsu/kanban/integration.test.ts src/botsu/drawing/drawing-bridge.test.ts src/botsu/drawing/drawing-export.test.ts src/botsu/drawing/drawing-model.test.ts src/botsu/drawing/drawing-ui.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/voxel/voxel-color.test.ts src/botsu/voxel/voxel-ui.test.ts src/botsu/wikipedia/model.test.ts src/botsu/wikipedia/wikipedia-scraper.test.ts src/botsu/openstreetmap/geocoder.test.ts src/botsu/radio/model.test.ts src/botsu/radio/controller.test.ts src/botsu/radio/radio-ui.test.ts src/botsu/watch/jellyfin.test.ts src/botsu/watch/model.test.ts src/botsu/watch/providers.test.ts src/botsu/watch/watch-ui.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",
+22 -2
View File
@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { useMatch } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
import { BOTSU_PATH, DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
type MobileFriendlyClientNavProps = {
children: ReactNode;
@@ -9,6 +9,7 @@ type MobileFriendlyClientNavProps = {
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
const screenSize = useScreenSizeContext();
const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
const botsuMatch = useMatch({ path: BOTSU_PATH, caseSensitive: true, end: true });
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
@@ -16,7 +17,7 @@ export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavPro
if (
screenSize === ScreenSize.Mobile &&
!(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)
!(homeMatch || botsuMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)
) {
return null;
}
@@ -42,3 +43,22 @@ export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavP
return children;
}
type MobileFriendlyPageContentProps = {
path: string;
children: ReactNode;
};
export function MobileFriendlyPageContent({ path, children }: MobileFriendlyPageContentProps) {
const screenSize = useScreenSizeContext();
const exactPath = useMatch({
path,
caseSensitive: true,
end: true,
});
if (screenSize === ScreenSize.Mobile && exactPath) {
return null;
}
return children;
}
+37 -16
View File
@@ -41,6 +41,7 @@ import {
_BOTSU_WIKIPEDIA_ARTICLE_PATH,
_BOTSU_OPENSTREETMAP_PATH,
_BOTSU_MOODBOARD_PATH,
_BOTSU_RADIO_PATH,
} from './paths';
import {
getAppPathFromHref,
@@ -64,7 +65,11 @@ import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
import { PageRoot } from '../components/page';
import { ScreenSize } from '../hooks/useScreenSize';
import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly';
import {
MobileFriendlyPageNav,
MobileFriendlyClientNav,
MobileFriendlyPageContent,
} from './MobileFriendly';
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
@@ -90,6 +95,7 @@ import { BotsuTcgPage } from '../../botsu/tcg/BotsuTcgPage';
import { BotsuWikipediaHome, BotsuWikipediaArticle } from '../../botsu/wikipedia';
import { BotsuOpenStreetMap } from '../../botsu/openstreetmap/BotsuOpenStreetMap';
import { BotsuMoodboard } from '../../botsu/moodboard';
import { BotsuRadioPage, RadioProvider } from '../../botsu/radio';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
@@ -148,15 +154,17 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<ClientNonUIFeatures>
<CallEmbedProvider>
<DocumentSyncProvider>
<ClientLayout
nav={
<MobileFriendlyClientNav>
<SidebarNav />
</MobileFriendlyClientNav>
}
>
<Outlet />
</ClientLayout>
<RadioProvider>
<ClientLayout
nav={
<MobileFriendlyClientNav>
<SidebarNav />
</MobileFriendlyClientNav>
}
>
<Outlet />
</ClientLayout>
</RadioProvider>
</DocumentSyncProvider>
<CallStatusRenderer />
</CallEmbedProvider>
@@ -186,7 +194,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav>
}
>
<BotsuFrame />
<MobileFriendlyPageContent path={BOTSU_PATH}>
<BotsuFrame />
</MobileFriendlyPageContent>
</PageRoot>
}
>
@@ -199,6 +209,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_BOTSU_WIKIPEDIA_ARTICLE_PATH} element={<BotsuWikipediaArticle />} />
<Route path={_BOTSU_OPENSTREETMAP_PATH} element={<BotsuOpenStreetMap />} />
<Route path={_BOTSU_MOODBOARD_PATH} element={<BotsuMoodboard />} />
<Route path={_BOTSU_RADIO_PATH} element={<BotsuRadioPage />} />
<Route path={_BOTSU_SERVICES_PATH} element={<BotsuServices />} />
<Route path={_BOTSU_EMBED_PATH} element={<BotsuEmbed />} />
<Route path={_BOTSU_GODOT_PATH} element={<BotsuGodot />} />
@@ -213,7 +224,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav>
}
>
<Outlet />
<MobileFriendlyPageContent path={HOME_PATH}>
<Outlet />
</MobileFriendlyPageContent>
</PageRoot>
}
>
@@ -240,7 +253,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav>
}
>
<Outlet />
<MobileFriendlyPageContent path={DIRECT_PATH}>
<Outlet />
</MobileFriendlyPageContent>
</PageRoot>
}
>
@@ -266,7 +281,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav>
}
>
<Outlet />
<MobileFriendlyPageContent path={SPACE_PATH}>
<Outlet />
</MobileFriendlyPageContent>
</PageRoot>
</RouteSpaceProvider>
}
@@ -305,7 +322,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav>
}
>
<Outlet />
<MobileFriendlyPageContent path={EXPLORE_PATH}>
<Outlet />
</MobileFriendlyPageContent>
</PageRoot>
}
>
@@ -330,7 +349,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav>
}
>
<Outlet />
<MobileFriendlyPageContent path={INBOX_PATH}>
<Outlet />
</MobileFriendlyPageContent>
</PageRoot>
}
>
@@ -19,6 +19,7 @@ import {
} from './sidebar';
import { CreateTab } from './sidebar/CreateTab';
import { BotsuTab } from '../../../botsu/shell';
import { RadioTab } from '../../../botsu/radio';
export function SidebarNav() {
const scrollRef = useRef<HTMLDivElement>(null);
@@ -46,6 +47,7 @@ export function SidebarNav() {
<SidebarStackSeparator />
<SidebarStack>
<SearchTab />
<RadioTab />
<UnverifiedTab />
<InboxTab />
<SettingsTab />
+2
View File
@@ -27,6 +27,7 @@ import {
BOTSU_APPS_PATH,
BOTSU_SERVICES_PATH,
BOTSU_EMBED_PATH,
BOTSU_RADIO_PATH,
} from './paths';
import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
import { HashRouterConfig } from '../hooks/useClientConfig';
@@ -162,6 +163,7 @@ export const getCreatePath = (): string => CREATE_PATH;
export const getBotsuPath = (): string => BOTSU_PATH;
export const getBotsuAppsPath = (): string => BOTSU_APPS_PATH;
export const getBotsuServicesPath = (): string => BOTSU_SERVICES_PATH;
export const getBotsuRadioPath = (): string => BOTSU_RADIO_PATH;
export const getBotsuEmbedPath = (appId: string): string =>
generatePath(BOTSU_EMBED_PATH, { appId: encodeURIComponent(appId) });
+2
View File
@@ -87,6 +87,7 @@ export const _BOTSU_WIKIPEDIA_PATH = 'wikipedia/';
export const _BOTSU_WIKIPEDIA_ARTICLE_PATH = 'wikipedia/:slug/';
export const _BOTSU_OPENSTREETMAP_PATH = 'openstreetmap/';
export const _BOTSU_MOODBOARD_PATH = 'moodboard/';
export const _BOTSU_RADIO_PATH = 'radio/';
export const BOTSU_PATH = '/botsu/';
export const BOTSU_GODOT_PATH = `/botsu/${_BOTSU_GODOT_PATH}`;
export const BOTSU_APPS_PATH = `/botsu/${_BOTSU_APPS_PATH}`;
@@ -94,6 +95,7 @@ export const BOTSU_SERVICES_PATH = `/botsu/${_BOTSU_SERVICES_PATH}`;
export const BOTSU_EMBED_PATH = `/botsu/${_BOTSU_EMBED_PATH}`;
export const BOTSU_DOCUMENTS_PATH = `/botsu/${_BOTSU_DOCUMENTS_PATH}`;
export const BOTSU_DOCUMENT_PATH = `/botsu/${_BOTSU_DOCUMENT_PATH}`;
export const BOTSU_RADIO_PATH = `/botsu/${_BOTSU_RADIO_PATH}`;
export const _NOTIFICATIONS_PATH = 'notifications/';
export const _INVITES_PATH = 'invites/';
@@ -25,6 +25,7 @@ import {
} from './types';
import { CallControl } from './CallControl';
import { CallControlState } from './CallControlState';
import { installBotsuCallGallery } from '../../../botsu/call/call-gallery';
export class CallEmbed {
private mx: MatrixClient;
@@ -47,6 +48,10 @@ export class CallEmbed {
private readonly disposables: Array<() => void> = [];
private galleryTheme: ElementCallThemeKind;
private disposeGallery?: () => void;
static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent {
if (dm && ongoing) {
return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice;
@@ -140,6 +145,8 @@ export class CallEmbed {
iframe.sandbox =
'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads';
iframe.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;';
iframe.allowFullscreen = true;
iframe.allow = `${iframe.allow} fullscreen;`;
iframe.src = url;
iframe.style.width = '100%';
@@ -169,12 +176,15 @@ export class CallEmbed {
this.room = room;
this.iframe = iframe;
this.container = container;
this.galleryTheme =
new URL(iframe.src).searchParams.get('theme') === 'light' ? 'light' : 'dark';
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
this.control.startObserving();
iframe.onload = () => {
this.control.startObserving();
this.installGallery();
};
let initialMediaEvent = true;
@@ -201,11 +211,18 @@ export class CallEmbed {
}
public setTheme(theme: ElementCallThemeKind) {
this.galleryTheme = theme;
this.installGallery();
return this.call.transport.send(WidgetApiToWidgetAction.ThemeChange, {
name: theme,
});
}
private installGallery(): void {
this.disposeGallery?.();
this.disposeGallery = installBotsuCallGallery(this.iframe, this.galleryTheme);
}
public hangup() {
return this.call.transport.send(ElementWidgetActions.HangupCall, {});
}
@@ -257,6 +274,8 @@ export class CallEmbed {
* @param opts
*/
public dispose(): void {
this.disposeGallery?.();
this.disposeGallery = undefined;
this.disposables.forEach((disposable) => {
disposable();
});
+3 -3
View File
@@ -49,7 +49,7 @@ test('administrator catalogue includes administration', () => {
);
});
test('BOTSU applications page hides room/stream experiments and keeps Pinterest', () => {
test('BOTSU applications page hides room experiments and exposes Radio and Pinterest', () => {
const apps = getBotsuApplicationApps(new Set(['member']));
const ids = apps.map((app) => app.id);
@@ -58,7 +58,7 @@ test('BOTSU applications page hides room/stream experiments and keeps Pinterest'
assert.equal(ids.includes('dessin'), false);
assert.equal(ids.includes('voxel'), false);
assert.equal(ids.includes('cinema'), false);
assert.equal(ids.includes('radio'), false);
assert.equal(ids.includes('radio'), true);
assert.equal(getApp('dessin')?.label, 'Dessin');
assert.equal(getApp('voxel')?.label, 'Voxel Room');
@@ -94,7 +94,7 @@ test('catalogue records live and planned applications explicitly', () => {
assert.equal(getApp('documents')?.availability, 'planned');
assert.equal(getApp('tables')?.launch.mode, 'iframe');
assert.equal(getApp('transfers')?.launch.mode, 'external');
assert.equal(getApp('radio')?.availability, 'planned');
assert.equal(getApp('radio')?.availability, 'available');
assert.equal(getApp('shop')?.availability, 'planned');
assert.equal(getApp('voxel')?.availability, 'available');
assert.deepEqual(getApp('voxel')?.launch, {
+3 -3
View File
@@ -147,9 +147,9 @@ export const botsuApps: readonly BotsuApp[] = [
{
id: 'radio',
label: 'Radio',
description: 'Écoute de la radio en direct.',
description: 'Chaînes musicales globales et synchronisées en direct.',
roles: ['member', 'admin'],
availability: 'planned',
availability: 'available',
launch: { mode: 'native', path: '/botsu/radio/' },
},
{
@@ -234,7 +234,7 @@ export const botsuApps: readonly BotsuApp[] = [
},
];
const HIDDEN_APPLICATION_IDS = new Set<string>(['dessin', 'voxel', 'cinema', 'radio']);
const HIDDEN_APPLICATION_IDS = new Set<string>(['dessin', 'voxel', 'cinema']);
export const getVisibleApps = (
roles: ReadonlySet<BotsuRole>,
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
BOTSU_CALL_GALLERY_CSS,
findElementCallMediaTile,
isInteractiveCallTarget,
} from './call-gallery.ts';
type FakeElement = {
classList: string[];
parentElement: FakeElement | null;
media: boolean;
interactive: boolean;
matches(selector: string): boolean;
querySelector(selector: string): FakeElement | null;
closest(selector: string): FakeElement | null;
};
const fakeElement = ({
classes = [],
parent = null,
media = false,
interactive = false,
}: {
classes?: string[];
parent?: FakeElement | null;
media?: boolean;
interactive?: boolean;
} = {}): FakeElement => {
const element: FakeElement = {
classList: classes,
parentElement: parent,
media,
interactive,
matches: () => false,
querySelector: () => (element.media ? element : null),
closest: () => {
let current: FakeElement | null = element;
while (current) {
if (current.interactive) return current;
current = current.parentElement;
}
return null;
},
};
return element;
};
test('finds the nearest Element Call webcam or screen-share tile', () => {
const outerTile = fakeElement({ classes: ['_tile_wrapper_8'], media: true });
const mediaTile = fakeElement({
classes: ['_tile_camera_8', '_speaking_camera_28'],
parent: outerTile,
media: true,
});
const video = fakeElement({ parent: mediaTile });
assert.equal(findElementCallMediaTile(video as unknown as Element), mediaTile);
assert.equal(findElementCallMediaTile(fakeElement() as unknown as Element), null);
});
test('keeps native call controls interactive instead of toggling fullscreen', () => {
const tile = fakeElement({ classes: ['_tile_camera_8'], media: true });
const control = fakeElement({ parent: tile, interactive: true });
assert.equal(isInteractiveCallTarget(control as unknown as Element), true);
assert.equal(isInteractiveCallTarget(tile as unknown as Element), false);
assert.equal(isInteractiveCallTarget(null), false);
});
test('BOTSU call gallery styles preserve a flex overview and a focused fullscreen tile', () => {
assert.match(BOTSU_CALL_GALLERY_CSS, /flex-wrap:\s*wrap/);
assert.match(BOTSU_CALL_GALLERY_CSS, /\[data-botsu-call-tile\]:fullscreen/);
assert.match(BOTSU_CALL_GALLERY_CSS, /var\(--botsu-radius/);
assert.match(BOTSU_CALL_GALLERY_CSS, /var\(--botsu-color-accent/);
});
test('Cinny call embed installs the BOTSU gallery and grants iframe fullscreen access', async () => {
const callEmbed = await readFile(
new URL('../../app/plugins/call/CallEmbed.ts', import.meta.url),
'utf8'
);
assert.match(callEmbed, /installBotsuCallGallery/);
assert.match(callEmbed, /iframe\.allowFullscreen = true/);
assert.match(callEmbed, /fullscreen/);
});
+277
View File
@@ -0,0 +1,277 @@
export type BotsuCallGalleryMode = 'light' | 'dark';
const ELEMENT_CALL_TILE_CLASS_PREFIX = '_tile_';
const ELEMENT_CALL_MEDIA_SELECTOR = 'video, [class*="_avatar_"], [class*="_bg_"]';
const INTERACTIVE_SELECTOR =
'button, input, select, textarea, a[href], [role="button"], [role="menuitem"], [data-radix-collection-item]';
const darkPalette = {
canvas: '#050505',
surface: '#111111',
surfaceRaised: '#181818',
text: '#f5f5f5',
textMuted: '#a3a3a3',
border: '#3a3a3a',
accent: '#ffffff',
onAccent: '#000000',
};
const lightPalette = {
canvas: '#fafafa',
surface: '#ffffff',
surfaceRaised: '#f0f0f0',
text: '#111111',
textMuted: '#626262',
border: '#c6c6c6',
accent: '#000000',
onAccent: '#ffffff',
};
const hasTileClass = (element: Element): boolean =>
Array.from(element.classList).some((className) =>
className.startsWith(ELEMENT_CALL_TILE_CLASS_PREFIX)
);
const hasMedia = (element: Element): boolean =>
element.querySelector(ELEMENT_CALL_MEDIA_SELECTOR) !== null;
const asElement = (target: EventTarget | null): Element | null => {
if (!target || typeof (target as Element).closest !== 'function') return null;
return target as Element;
};
export const findElementCallMediaTile = (target: Element | null): HTMLElement | null => {
let current: Element | null = target;
while (current) {
if (hasTileClass(current) && hasMedia(current)) return current as HTMLElement;
current = current.parentElement;
}
return null;
};
export const isInteractiveCallTarget = (target: Element | null): boolean =>
Boolean(target?.closest(INTERACTIVE_SELECTOR));
export const BOTSU_CALL_GALLERY_CSS = `
html.botsu-call-gallery,
html.botsu-call-gallery body {
color: var(--botsu-color-text, #f5f5f5);
background: var(--botsu-color-canvas, #050505);
font-family: var(--botsu-font-family, Inter, sans-serif);
}
html.botsu-call-gallery [class*="_inRoom_"] {
background: var(--botsu-color-canvas, #050505);
}
html.botsu-call-gallery [class*="_scrolling_"]:has(> [data-botsu-call-tile]) {
display: flex;
flex-wrap: wrap;
scrollbar-color: var(--botsu-color-border, #3a3a3a) transparent;
}
html.botsu-call-gallery [data-botsu-call-tile] {
box-sizing: border-box;
border-radius: var(--botsu-radius, 2px) !important;
box-shadow: inset 0 0 0 var(--botsu-border-width, 1px)
var(--botsu-color-border, #3a3a3a);
cursor: zoom-in;
outline-color: transparent !important;
transition: box-shadow 150ms ease, outline-color 150ms ease;
}
html.botsu-call-gallery [data-botsu-call-tile] [class*="_media_"],
html.botsu-call-gallery [data-botsu-call-tile] [class*="_contents_"] {
border-radius: var(--botsu-radius, 2px) !important;
}
html.botsu-call-gallery [data-botsu-call-tile]:hover {
box-shadow: inset 0 0 0 max(2px, var(--botsu-border-width, 1px))
var(--botsu-color-accent, #ffffff);
}
html.botsu-call-gallery [data-botsu-call-tile]:focus-visible {
outline: 2px solid var(--botsu-color-focus, var(--botsu-color-accent, #ffffff)) !important;
outline-offset: 3px;
}
html.botsu-call-gallery [data-botsu-call-tile][class*="_speaking_"] {
box-shadow: inset 0 0 0 max(2px, var(--botsu-border-width, 1px))
var(--botsu-color-accent, #ffffff);
}
html.botsu-call-gallery [data-botsu-call-tile][class*="_speaking_"]::before {
border-radius: var(--botsu-radius, 2px) !important;
background: var(--botsu-color-accent, #ffffff) !important;
opacity: 0.12 !important;
}
html.botsu-call-gallery [data-botsu-call-tile] button,
html.botsu-call-gallery [data-botsu-call-tile] [role="button"] {
cursor: pointer;
}
html.botsu-call-gallery [data-botsu-call-tile]:fullscreen {
position: fixed !important;
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
margin: 0 !important;
padding: clamp(8px, 1.5vw, 24px) !important;
border-radius: 0 !important;
background: var(--botsu-color-canvas, #050505) !important;
box-shadow: none !important;
cursor: zoom-out;
transform: none !important;
}
html.botsu-call-gallery [data-botsu-call-tile]:fullscreen [class*="_media_"],
html.botsu-call-gallery [data-botsu-call-tile]:fullscreen [class*="_contents_"] {
width: 100% !important;
height: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
}
@media (prefers-reduced-motion: reduce) {
html.botsu-call-gallery [data-botsu-call-tile] {
transition: none;
}
}
`;
const readThemeVariable = (sourceDocument: Document, name: string, fallback: string): string => {
const source = sourceDocument.querySelector('.botsu-theme') ?? sourceDocument.body;
const computed = source && sourceDocument.defaultView?.getComputedStyle(source);
return computed?.getPropertyValue(name).trim() || fallback;
};
const applyBotsuTheme = (
targetDocument: Document,
sourceDocument: Document,
mode: BotsuCallGalleryMode
): void => {
const palette = mode === 'light' ? lightPalette : darkPalette;
const variables: Record<string, string> = {
'--botsu-color-canvas': palette.canvas,
'--botsu-color-surface': palette.surface,
'--botsu-color-surface-raised': palette.surfaceRaised,
'--botsu-color-text': palette.text,
'--botsu-color-text-muted': palette.textMuted,
'--botsu-color-border': palette.border,
'--botsu-color-focus': palette.accent,
'--botsu-color-accent': palette.accent,
'--botsu-color-on-accent': palette.onAccent,
'--botsu-border-width': '1px',
'--botsu-radius': '2px',
'--botsu-font-family': 'Inter, sans-serif',
};
Object.entries(variables).forEach(([name, fallback]) => {
targetDocument.documentElement.style.setProperty(
name,
readThemeVariable(sourceDocument, name, fallback)
);
});
};
const ignoreFullscreenError = (result: Promise<void> | void): void => {
if (result && typeof result.catch === 'function') result.catch(() => undefined);
};
const toggleTileFullscreen = (document: Document, tile: HTMLElement): void => {
if (document.fullscreenElement) {
ignoreFullscreenError(document.exitFullscreen());
return;
}
ignoreFullscreenError(tile.requestFullscreen());
};
const tileLabel = (tile: HTMLElement, expanded: boolean): string => {
const name = tile.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80);
const action = expanded ? 'Quitter le plein écran' : 'Afficher en plein écran';
return name ? `${action}${name}` : `${action} — flux vidéo`;
};
export const installBotsuCallGallery = (
iframe: HTMLIFrameElement,
mode: BotsuCallGalleryMode
): (() => void) => {
const callDocument = iframe.contentDocument;
if (!callDocument) return () => undefined;
const sourceDocument = iframe.ownerDocument;
const style = callDocument.createElement('style');
style.dataset.botsuCallGallery = 'true';
style.textContent = BOTSU_CALL_GALLERY_CSS;
(callDocument.head ?? callDocument.documentElement).append(style);
callDocument.documentElement.classList.add('botsu-call-gallery');
applyBotsuTheme(callDocument, sourceDocument, mode);
const decoratedTiles = new Set<HTMLElement>();
const addedTabIndexTiles = new Set<HTMLElement>();
const decorateTiles = (): void => {
const mediaElements = callDocument.querySelectorAll<Element>(ELEMENT_CALL_MEDIA_SELECTOR);
mediaElements.forEach((mediaElement) => {
const tile = findElementCallMediaTile(mediaElement);
if (!tile) return;
tile.dataset.botsuCallTile = 'true';
if (!tile.hasAttribute('tabindex')) {
tile.tabIndex = 0;
addedTabIndexTiles.add(tile);
}
tile.setAttribute('aria-keyshortcuts', 'Enter Space');
tile.title = tileLabel(tile, callDocument.fullscreenElement === tile);
decoratedTiles.add(tile);
});
};
const handleClick = (event: MouseEvent): void => {
const target = asElement(event.target);
if (!target || isInteractiveCallTarget(target)) return;
const tile = findElementCallMediaTile(target);
if (tile) toggleTileFullscreen(callDocument, tile);
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const target = asElement(event.target);
const tile = findElementCallMediaTile(target);
if (!tile || target !== tile) return;
event.preventDefault();
toggleTileFullscreen(callDocument, tile);
};
const handleFullscreenChange = (): void => {
decorateTiles();
};
decorateTiles();
const refreshTimer = callDocument.defaultView?.setInterval(decorateTiles, 1_000);
callDocument.addEventListener('click', handleClick);
callDocument.addEventListener('keydown', handleKeyDown);
callDocument.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
if (refreshTimer !== undefined) callDocument.defaultView?.clearInterval(refreshTimer);
callDocument.removeEventListener('click', handleClick);
callDocument.removeEventListener('keydown', handleKeyDown);
callDocument.removeEventListener('fullscreenchange', handleFullscreenChange);
decoratedTiles.forEach((tile) => {
delete tile.dataset.botsuCallTile;
tile.removeAttribute('aria-keyshortcuts');
tile.removeAttribute('title');
if (addedTabIndexTiles.has(tile)) tile.removeAttribute('tabindex');
});
style.remove();
callDocument.documentElement.classList.remove('botsu-call-gallery');
};
};
+89
View File
@@ -0,0 +1,89 @@
export type BotsuYouTubePlayer = {
destroy: () => void;
getCurrentTime: () => number;
getDuration: () => number;
mute: () => void;
pauseVideo: () => void;
playVideo: () => void;
seekTo: (seconds: number, allowSeekAhead: boolean) => void;
setVolume: (volume: number) => void;
unMute: () => void;
};
export type BotsuYouTubeApi = {
Player: new (
element: HTMLElement,
options: {
videoId?: string;
playerVars?: Record<string, number | string>;
events: {
onReady: () => void;
onStateChange?: (event: { data: number }) => void;
onError?: () => void;
onAutoplayBlocked?: () => void;
};
}
) => BotsuYouTubePlayer;
PlayerState: { ENDED: number; PLAYING: number };
};
declare global {
interface Window {
YT?: BotsuYouTubeApi;
onYouTubeIframeAPIReady?: () => void;
}
}
let youtubeApiPromise: Promise<BotsuYouTubeApi> | undefined;
export const loadYouTubeApi = (): Promise<BotsuYouTubeApi> => {
if (window.YT?.Player) return Promise.resolve(window.YT);
if (youtubeApiPromise) return youtubeApiPromise;
youtubeApiPromise = new Promise<BotsuYouTubeApi>((resolve, reject) => {
const finish = () => {
if (window.YT?.Player) resolve(window.YT);
else reject(new Error('youtube_sdk_missing'));
};
const previous = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
try {
previous?.();
} finally {
finish();
}
};
const src = 'https://www.youtube.com/iframe_api';
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
if (existing) {
existing.addEventListener(
'error',
() => {
existing.remove();
reject(new Error('youtube_sdk_load_failed'));
},
{ once: true }
);
return;
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.referrerPolicy = 'strict-origin-when-cross-origin';
script.addEventListener(
'error',
() => {
script.remove();
reject(new Error('youtube_sdk_load_failed'));
},
{ once: true }
);
document.head.append(script);
}).catch((error) => {
youtubeApiPromise = undefined;
throw error;
});
return youtubeApiPromise;
};
@@ -0,0 +1,607 @@
import React, { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
MAXIMUM_RADIO_DESCRIPTION_LENGTH,
MAXIMUM_RADIO_NAME_LENGTH,
MAXIMUM_RADIO_TRACKS,
MAXIMUM_RADIO_TRACK_TITLE_LENGTH,
type BotsuRadioAudioTrack,
type BotsuRadioChannel,
type BotsuRadioChannelDraft,
type BotsuRadioYoutubeTrack,
} from '@botsu/protocol';
import { loadYouTubeApi } from '../media/youtube';
import { createRadioTrackId, formatRadioTime, parseYoutubeVideoId } from './model';
import { useRadio } from './RadioContext';
import './radio.css';
type SourceKind = BotsuRadioChannelDraft['source']['kind'];
type AudioFormTrack = BotsuRadioAudioTrack;
type YoutubeFormTrack = Omit<BotsuRadioYoutubeTrack, 'videoId'> & { url: string };
type EditorState = {
name: string;
description: string;
kind: SourceKind;
streamUrl: string;
audioTracks: AudioFormTrack[];
youtubeTracks: YoutubeFormTrack[];
};
const blankAudioTrack = (): AudioFormTrack => ({
id: createRadioTrackId(),
title: '',
url: '',
durationMs: 0,
});
const blankYoutubeTrack = (): YoutubeFormTrack => ({
id: createRadioTrackId(),
title: '',
url: '',
durationMs: 0,
});
const blankEditor = (): EditorState => ({
name: '',
description: '',
kind: 'live_stream',
streamUrl: '',
audioTracks: [blankAudioTrack()],
youtubeTracks: [blankYoutubeTrack()],
});
const editorFromChannel = (channel: BotsuRadioChannel): EditorState => ({
name: channel.name,
description: channel.description,
kind: channel.source.kind,
streamUrl: channel.source.kind === 'live_stream' ? channel.source.url : '',
audioTracks:
channel.source.kind === 'audio_playlist' ? channel.source.tracks : [blankAudioTrack()],
youtubeTracks:
channel.source.kind === 'youtube_playlist'
? channel.source.tracks.map((track) => ({
id: track.id,
title: track.title,
url: `https://www.youtube.com/watch?v=${track.videoId}`,
durationMs: track.durationMs,
}))
: [blankYoutubeTrack()],
});
const draftFromEditor = (editor: EditorState): BotsuRadioChannelDraft => {
if (editor.kind === 'live_stream')
return {
name: editor.name,
description: editor.description,
source: { kind: 'live_stream', url: editor.streamUrl },
};
if (editor.kind === 'audio_playlist')
return {
name: editor.name,
description: editor.description,
source: { kind: 'audio_playlist', tracks: editor.audioTracks },
};
return {
name: editor.name,
description: editor.description,
source: {
kind: 'youtube_playlist',
tracks: editor.youtubeTracks.map((track) => ({
id: track.id,
title: track.title,
videoId: parseYoutubeVideoId(track.url),
durationMs: track.durationMs,
})),
},
};
};
const detectAudioDuration = (url: string): Promise<number> =>
new Promise((resolve, reject) => {
let parsed: URL;
try {
parsed = new URL(url.trim());
} catch {
reject(new Error('Saisissez une URL audio valide.'));
return;
}
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
reject(new Error('Le fichier audio doit utiliser une URL HTTPS sans identifiants.'));
return;
}
const audio = new Audio();
const cleanup = () => {
audio.removeAttribute('src');
audio.load();
};
audio.preload = 'metadata';
audio.addEventListener(
'loadedmetadata',
() => {
const durationMs = Math.round(audio.duration * 1_000);
cleanup();
if (!Number.isSafeInteger(durationMs) || durationMs < 1_000) {
reject(new Error('La durée de ce fichier audio est invalide.'));
return;
}
resolve(durationMs);
},
{ once: true }
);
audio.addEventListener(
'error',
() => {
cleanup();
reject(new Error('Impossible de lire les métadonnées audio (URL ou CORS).'));
},
{ once: true }
);
audio.src = parsed.href;
audio.load();
});
function YoutubeDurationProbe({
videoId,
onDuration,
}: {
videoId: string;
onDuration: (durationMs: number) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const onDurationRef = useRef(onDuration);
onDurationRef.current = onDuration;
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
let disposed = false;
let playerDestroy: (() => void) | undefined;
let durationTimer: number | undefined;
host.replaceChildren();
void loadYouTubeApi()
.then((api) => {
if (disposed) return;
const iframe = document.createElement('iframe');
const params = new URLSearchParams({
autoplay: '0',
controls: '1',
enablejsapi: '1',
origin: window.location.origin,
playsinline: '1',
});
iframe.allow = 'autoplay; encrypted-media; picture-in-picture';
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.title = 'Analyse de la vidéo YouTube';
iframe.src = `https://www.youtube.com/embed/${videoId}?${params.toString()}`;
host.append(iframe);
const player = new api.Player(iframe, {
videoId,
playerVars: { autoplay: 0, controls: 1, playsinline: 1 },
events: {
onReady: () => {
player.mute();
player.playVideo();
durationTimer = window.setInterval(() => {
const durationMs = Math.round(player.getDuration() * 1_000);
if (durationMs >= 1_000) {
window.clearInterval(durationTimer);
durationTimer = undefined;
player.pauseVideo();
onDurationRef.current(durationMs);
}
}, 250);
},
onError: () => onDurationRef.current(0),
},
});
playerDestroy = () => player.destroy();
})
.catch(() => onDurationRef.current(0));
return () => {
disposed = true;
if (durationTimer !== undefined) window.clearInterval(durationTimer);
playerDestroy?.();
};
}, [videoId]);
return <div className="botsu-radio-youtube botsu-radio-youtube--probe" ref={hostRef} />;
}
export function BotsuRadioPage() {
const radio = useRadio();
const { canManage, controller } = radio;
const [channels, setChannels] = useState<BotsuRadioChannel[]>([]);
const [selected, setSelected] = useState<BotsuRadioChannel>();
const [editor, setEditor] = useState<EditorState>(blankEditor);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const [youtubeProbe, setYoutubeProbe] = useState<{ index: number; videoId: string }>();
const loadChannels = useCallback(async () => {
try {
const catalog = await controller.list(true);
setChannels(catalog.channels);
setError(undefined);
} catch (loadError) {
setError((loadError as Error).message);
}
}, [controller]);
useEffect(() => {
void loadChannels();
}, [loadChannels]);
const beginCreate = () => {
setSelected(undefined);
setEditor(blankEditor());
setYoutubeProbe(undefined);
setEditing(true);
setError(undefined);
};
const beginEdit = (channel: BotsuRadioChannel) => {
setSelected(channel);
setEditor(editorFromChannel(channel));
setYoutubeProbe(undefined);
setEditing(true);
setError(undefined);
};
const programChanged = useMemo(() => {
if (!selected) return true;
try {
return JSON.stringify(draftFromEditor(editor).source) !== JSON.stringify(selected.source);
} catch {
return false;
}
}, [editor, selected]);
const save = async (event: FormEvent) => {
event.preventDefault();
setSaving(true);
setError(undefined);
try {
const draft = draftFromEditor(editor);
if (
selected &&
programChanged &&
!window.confirm(
'Cette publication relancera immédiatement la programmation avec un nouvel ordre. Continuer ?'
)
)
return;
if (selected) await controller.update(selected.id, selected.revision, draft);
else await controller.create(draft);
setEditing(false);
setSelected(undefined);
await Promise.all([loadChannels(), radio.refreshCatalog()]);
} catch (saveError) {
setError((saveError as Error).message);
} finally {
setSaving(false);
}
};
const setAudioTrack = (index: number, change: Partial<AudioFormTrack>) => {
setEditor((current) => ({
...current,
audioTracks: current.audioTracks.map((track, trackIndex) =>
trackIndex === index ? { ...track, ...change } : track
),
}));
};
const setYoutubeTrack = (index: number, change: Partial<YoutubeFormTrack>) => {
setEditor((current) => ({
...current,
youtubeTracks: current.youtubeTracks.map((track, trackIndex) =>
trackIndex === index ? { ...track, ...change } : track
),
}));
};
const toggleArchive = async (channel: BotsuRadioChannel) => {
try {
if (channel.archived) await controller.restore(channel.id, channel.revision);
else await controller.archive(channel.id, channel.revision);
await Promise.all([loadChannels(), radio.refreshCatalog()]);
} catch (archiveError) {
setError((archiveError as Error).message);
}
};
return (
<section className="botsu-radio-page">
<header className="botsu-radio-page__header">
<span>
<p className="botsu-eyebrow">DIFFUSION COLLECTIVE</p>
<h1>Radio</h1>
<p>
La programmation avance en continu. Les auditeurs rejoignent le direct sans choisir les
pistes.
</p>
<p>
<small>
Pour écouter, ouvrez le mini-player Radio sous Recherche dans la barre globale.
</small>
</p>
</span>
{canManage && (
<button type="button" onClick={beginCreate}>
Créer une chaîne
</button>
)}
</header>
{error && (
<p className="botsu-radio-alert" role="alert">
{error}
</p>
)}
<div className="botsu-radio-page__grid">
{channels.map((channel) => (
<article key={channel.id} className={channel.archived ? 'is-archived' : undefined}>
<header>
<span>
<small>
{channel.archived
? 'ARCHIVÉE'
: channel.source.kind === 'live_stream'
? 'WEB-RADIO'
: 'PLAYLIST'}
</small>
<h2>{channel.name}</h2>
</span>
{!channel.archived && radio.listeningChannelId === channel.id && <b>EN DIRECT</b>}
</header>
<p>{channel.description || 'Aucune description.'}</p>
<footer>
{canManage && (
<button type="button" onClick={() => beginEdit(channel)}>
Modifier
</button>
)}
{canManage && (
<button type="button" onClick={() => void toggleArchive(channel)}>
{channel.archived ? 'Restaurer' : 'Archiver'}
</button>
)}
</footer>
</article>
))}
{channels.length === 0 && <p>Aucune chaîne Radio na encore é publiée.</p>}
</div>
{canManage && editing && (
<form className="botsu-radio-editor" onSubmit={save}>
<header>
<h2>{selected ? `Modifier ${selected.name}` : 'Créer une chaîne'}</h2>
<button type="button" onClick={() => setEditing(false)}>
Fermer
</button>
</header>
<label>
Nom
<input
required
maxLength={MAXIMUM_RADIO_NAME_LENGTH}
value={editor.name}
onChange={(event) =>
setEditor((current) => ({ ...current, name: event.currentTarget.value }))
}
/>
</label>
<label>
Description
<textarea
maxLength={MAXIMUM_RADIO_DESCRIPTION_LENGTH}
value={editor.description}
onChange={(event) =>
setEditor((current) => ({ ...current, description: event.currentTarget.value }))
}
/>
</label>
<label>
Type de source
<select
value={editor.kind}
onChange={(event) =>
setEditor((current) => ({
...current,
kind: event.currentTarget.value as SourceKind,
}))
}
>
<option value="live_stream">Web-radio HTTPS</option>
<option value="audio_playlist">Playlist audio directe</option>
<option value="youtube_playlist">Playlist YouTube</option>
</select>
</label>
{editor.kind === 'live_stream' && (
<label>
URL HTTPS du flux
<input
required
type="url"
value={editor.streamUrl}
onChange={(event) =>
setEditor((current) => ({ ...current, streamUrl: event.currentTarget.value }))
}
/>
</label>
)}
{editor.kind === 'audio_playlist' && (
<fieldset>
<legend>
Fichiers audio ({editor.audioTracks.length}/{MAXIMUM_RADIO_TRACKS})
</legend>
{editor.audioTracks.map((track, index) => (
<div className="botsu-radio-track" key={track.id}>
<input
required
aria-label={`Titre audio ${index + 1}`}
maxLength={MAXIMUM_RADIO_TRACK_TITLE_LENGTH}
placeholder="Titre"
value={track.title}
onChange={(event) => setAudioTrack(index, { title: event.currentTarget.value })}
/>
<input
required
aria-label={`URL audio ${index + 1}`}
type="url"
placeholder="https://…"
value={track.url}
onChange={(event) =>
setAudioTrack(index, { url: event.currentTarget.value, durationMs: 0 })
}
/>
<span>
{track.durationMs ? formatRadioTime(track.durationMs) : 'Durée inconnue'}
</span>
<button
type="button"
onClick={() =>
void detectAudioDuration(track.url)
.then((durationMs) => setAudioTrack(index, { durationMs }))
.catch((durationError) => setError(durationError.message))
}
>
Détecter la durée
</button>
{editor.audioTracks.length > 1 && (
<button
type="button"
onClick={() =>
setEditor((current) => ({
...current,
audioTracks: current.audioTracks.filter(
(_, trackIndex) => trackIndex !== index
),
}))
}
>
Retirer
</button>
)}
</div>
))}
{editor.audioTracks.length < MAXIMUM_RADIO_TRACKS && (
<button
type="button"
onClick={() =>
setEditor((current) => ({
...current,
audioTracks: [...current.audioTracks, blankAudioTrack()],
}))
}
>
Ajouter un fichier
</button>
)}
</fieldset>
)}
{editor.kind === 'youtube_playlist' && (
<fieldset>
<legend>
Vidéos YouTube ({editor.youtubeTracks.length}/{MAXIMUM_RADIO_TRACKS})
</legend>
{editor.youtubeTracks.map((track, index) => (
<div className="botsu-radio-track" key={track.id}>
<input
required
aria-label={`Titre YouTube ${index + 1}`}
maxLength={MAXIMUM_RADIO_TRACK_TITLE_LENGTH}
placeholder="Titre saisi manuellement"
value={track.title}
onChange={(event) =>
setYoutubeTrack(index, { title: event.currentTarget.value })
}
/>
<input
required
aria-label={`URL YouTube ${index + 1}`}
type="url"
placeholder="https://youtube.com/watch?v=…"
value={track.url}
onChange={(event) =>
setYoutubeTrack(index, { url: event.currentTarget.value, durationMs: 0 })
}
/>
<span>
{track.durationMs ? formatRadioTime(track.durationMs) : 'Durée inconnue'}
</span>
<button
type="button"
onClick={() => {
try {
setYoutubeProbe({ index, videoId: parseYoutubeVideoId(track.url) });
} catch (probeError) {
setError((probeError as Error).message);
}
}}
>
Analyser la vidéo
</button>
{editor.youtubeTracks.length > 1 && (
<button
type="button"
onClick={() =>
setEditor((current) => ({
...current,
youtubeTracks: current.youtubeTracks.filter(
(_, trackIndex) => trackIndex !== index
),
}))
}
>
Retirer
</button>
)}
</div>
))}
{editor.youtubeTracks.length < MAXIMUM_RADIO_TRACKS && (
<button
type="button"
onClick={() =>
setEditor((current) => ({
...current,
youtubeTracks: [...current.youtubeTracks, blankYoutubeTrack()],
}))
}
>
Ajouter une vidéo
</button>
)}
{youtubeProbe && (
<div className="botsu-radio-probe">
<p>Le lecteur reste visible pendant lanalyse de la durée.</p>
<YoutubeDurationProbe
videoId={youtubeProbe.videoId}
onDuration={(durationMs) => {
if (durationMs >= 1_000) setYoutubeTrack(youtubeProbe.index, { durationMs });
else setError('Impossible de lire cette vidéo YouTube.');
}}
/>
</div>
)}
</fieldset>
)}
{selected && programChanged && (
<p className="botsu-radio-warning">
Cette publication relancera immédiatement la programmation avec un nouvel ordre.
</p>
)}
<footer>
<button type="button" onClick={() => setEditing(false)}>
Annuler
</button>
<button type="submit" disabled={saving}>
{saving ? 'Publication…' : 'Publier'}
</button>
</footer>
</form>
)}
</section>
);
}
@@ -0,0 +1,479 @@
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import Hls from 'hls.js';
import type { BotsuRadioChannel, BotsuRadioPlaybackSnapshot } from '@botsu/protocol';
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
import { createPresenceWebSocketUrl, parsePresenceSocketMessage } from '../presence/socket-message';
import { createRadioController, type RadioController } from './controller';
import { projectRadioPositionMs, shouldCorrectRadioDrift } from './model';
const RADIO_PREFERENCES_KEY = 'botsu.radio.preferences.v1';
type RadioPreferences = { volume: number; muted: boolean };
export type RadioContextValue = {
channels: BotsuRadioChannel[];
canManage: boolean;
loading: boolean;
connected: boolean;
error?: string;
listeningChannelId?: string;
listeningChannel?: BotsuRadioChannel;
snapshot?: BotsuRadioPlaybackSnapshot;
snapshotObservedAtMs?: number;
volume: number;
muted: boolean;
autoplayBlocked: boolean;
controller: RadioController;
refreshCatalog: () => Promise<void>;
listen: (channelId: string) => Promise<void>;
leave: () => void;
setVolume: (volume: number) => void;
toggleMuted: () => void;
resume: () => Promise<void>;
setYoutubeResume: (resume?: () => Promise<void>) => void;
reportAutoplayBlocked: () => void;
reportPlaybackStarted: () => void;
};
const RadioContext = createContext<RadioContextValue | undefined>(undefined);
const loadPreferences = (): RadioPreferences => {
try {
const value = JSON.parse(window.localStorage.getItem(RADIO_PREFERENCES_KEY) ?? 'null') as {
volume?: unknown;
muted?: unknown;
} | null;
if (
value &&
typeof value.volume === 'number' &&
Number.isFinite(value.volume) &&
value.volume >= 0 &&
value.volume <= 1 &&
typeof value.muted === 'boolean'
)
return { volume: value.volume, muted: value.muted };
} catch {
// A malformed local preference must not stop the radio catalog.
}
return { volume: 0.8, muted: false };
};
const isHlsUrl = (url: string): boolean => {
try {
return new URL(url).pathname.toLowerCase().endsWith('.m3u8');
} catch {
return false;
}
};
export function RadioProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
const controller = useMemo(
() => createRadioController({ getOpenIdToken: () => mx.getOpenIdToken() }),
[mx]
);
const audioRef = useRef<HTMLAudioElement>(null);
const socketRef = useRef<WebSocket>();
const socketReadyRef = useRef(false);
const listeningChannelIdRef = useRef<string>();
const youtubeResumeRef = useRef<() => Promise<void>>();
const [channels, setChannels] = useState<BotsuRadioChannel[]>([]);
const [canManage, setCanManage] = useState(false);
const [loading, setLoading] = useState(true);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string>();
const [listeningChannelId, setListeningChannelId] = useState<string>();
const [snapshot, setSnapshot] = useState<BotsuRadioPlaybackSnapshot>();
const [snapshotObservedAtMs, setSnapshotObservedAtMs] = useState<number>();
const [preferences, setPreferences] = useState<RadioPreferences>(loadPreferences);
const [autoplayBlocked, setAutoplayBlocked] = useState(false);
const listeningChannel = channels.find((channel) => channel.id === listeningChannelId);
const refreshCatalog = useCallback(async () => {
try {
const catalog = await controller.list();
setChannels(catalog.channels);
setCanManage(catalog.canManage);
setError(undefined);
} catch (catalogError) {
setError((catalogError as Error).message || 'Le catalogue Radio est indisponible.');
} finally {
setLoading(false);
}
}, [controller]);
useEffect(() => {
void refreshCatalog();
}, [refreshCatalog]);
useEffect(() => {
try {
window.localStorage.setItem(RADIO_PREFERENCES_KEY, JSON.stringify(preferences));
} catch {
// Private browsing can disable persistence; playback still works for this session.
}
if (audioRef.current) {
audioRef.current.volume = preferences.volume;
audioRef.current.muted = preferences.muted;
}
}, [preferences]);
useEffect(() => {
let stopped = false;
let reconnectTimer: number | undefined;
let heartbeatTimer: number | undefined;
const connect = () => {
if (stopped) return;
const currentSocket = new WebSocket(createPresenceWebSocketUrl(window.location.origin));
socketRef.current = currentSocket;
currentSocket.addEventListener('open', async () => {
try {
const token = await mx.getOpenIdToken();
if (
stopped ||
socketRef.current !== currentSocket ||
currentSocket.readyState !== WebSocket.OPEN
)
return;
currentSocket.send(
JSON.stringify({
type: 'presence.hello',
protocolVersion: 1,
appId: 'radio',
openIdToken: {
accessToken: token.access_token,
matrixServerName: token.matrix_server_name,
expiresIn: token.expires_in,
},
})
);
} catch {
currentSocket.close();
}
});
currentSocket.addEventListener('message', (event) => {
if (stopped || socketRef.current !== currentSocket) return;
try {
const message = parsePresenceSocketMessage(event.data, (code, reason) =>
currentSocket.close(code, reason)
);
if (message.type === 'presence.ready') {
socketReadyRef.current = true;
setConnected(true);
const channelId = listeningChannelIdRef.current;
if (channelId)
currentSocket.send(
JSON.stringify({
type: 'radio.listen',
protocolVersion: 1,
channelId,
})
);
if (heartbeatTimer !== undefined) window.clearInterval(heartbeatTimer);
heartbeatTimer = window.setInterval(() => {
if (currentSocket.readyState === WebSocket.OPEN)
currentSocket.send(
JSON.stringify({
type: 'presence.heartbeat',
protocolVersion: 1,
})
);
}, message.heartbeatIntervalMs);
return;
}
if (message.type === 'radio.playback') {
if (message.snapshot.channelId === listeningChannelIdRef.current) {
setSnapshot(message.snapshot);
setSnapshotObservedAtMs(Date.now());
setError(undefined);
}
return;
}
if (message.type === 'radio.audience') {
setSnapshot((current) =>
current?.channelId === message.channelId
? { ...current, listenerCount: message.listenerCount }
: current
);
return;
}
if (message.type === 'radio.catalog.changed') {
void refreshCatalog();
return;
}
if (message.type === 'radio.error') {
setError(
{
not_found: "Cette chaîne n'existe plus.",
archived: 'Cette chaîne vient d’être archivée.',
unavailable: 'Cette chaîne est momentanément indisponible.',
}[message.code]
);
listeningChannelIdRef.current = undefined;
setListeningChannelId(undefined);
setSnapshot(undefined);
}
} catch {
setError('La synchronisation Radio a reçu une réponse invalide.');
}
});
currentSocket.addEventListener('close', () => {
if (socketRef.current !== currentSocket) return;
socketReadyRef.current = false;
setConnected(false);
if (heartbeatTimer !== undefined) window.clearInterval(heartbeatTimer);
heartbeatTimer = undefined;
if (!stopped) reconnectTimer = window.setTimeout(connect, 3_000);
});
};
try {
connect();
} catch {
setError('La connexion Radio exige un contexte sécurisé.');
}
return () => {
stopped = true;
if (heartbeatTimer !== undefined) window.clearInterval(heartbeatTimer);
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
socketRef.current?.close();
socketRef.current = undefined;
socketReadyRef.current = false;
};
}, [mx, refreshCatalog]);
const leave = useCallback(() => {
const socket = socketRef.current;
if (socket?.readyState === WebSocket.OPEN)
socket.send(
JSON.stringify({
type: 'radio.leave',
protocolVersion: 1,
})
);
listeningChannelIdRef.current = undefined;
youtubeResumeRef.current = undefined;
setListeningChannelId(undefined);
setSnapshot(undefined);
setSnapshotObservedAtMs(undefined);
setAutoplayBlocked(false);
const audio = audioRef.current;
audio?.pause();
audio?.removeAttribute('src');
audio?.load();
}, []);
const listen = useCallback(
async (channelId: string) => {
const channel = channels.find(
(candidate) => candidate.id === channelId && !candidate.archived
);
if (!channel) throw new Error("Cette chaîne n'est pas disponible.");
if (listeningChannelIdRef.current && listeningChannelIdRef.current !== channelId) {
const socket = socketRef.current;
if (socket?.readyState === WebSocket.OPEN)
socket.send(
JSON.stringify({
type: 'radio.leave',
protocolVersion: 1,
})
);
}
listeningChannelIdRef.current = channelId;
setListeningChannelId(channelId);
setSnapshot(undefined);
setAutoplayBlocked(false);
setError(undefined);
const socket = socketRef.current;
const sentToSocket = socket?.readyState === WebSocket.OPEN && socketReadyRef.current;
if (sentToSocket)
socket.send(
JSON.stringify({
type: 'radio.listen',
protocolVersion: 1,
channelId,
})
);
if (sentToSocket) return;
try {
const detail = await controller.get(channelId);
if (listeningChannelIdRef.current === channelId) {
setSnapshot(detail.playback);
setSnapshotObservedAtMs(Date.now());
}
} catch (listenError) {
if (listeningChannelIdRef.current === channelId) setError((listenError as Error).message);
}
},
[channels, controller]
);
const playbackUrl = useMemo(() => {
if (!listeningChannel || listeningChannel.source.kind === 'youtube_playlist') return undefined;
if (listeningChannel.source.kind === 'live_stream') return listeningChannel.source.url;
return snapshot?.sourceKind === 'audio_playlist' && snapshot.item && 'url' in snapshot.item
? snapshot.item.url
: undefined;
}, [listeningChannel, snapshot]);
useEffect(() => {
const audio = audioRef.current;
if (!audio || !playbackUrl) return undefined;
let hls: Hls | undefined;
const reportMediaError = () =>
setError('Le flux audio ne peut pas être lu. Vérifiez son accès CORS.');
const play = () => {
if (
snapshot?.sourceKind === 'audio_playlist' &&
snapshotObservedAtMs !== undefined &&
audio.readyState >= HTMLMediaElement.HAVE_METADATA
) {
const expected = projectRadioPositionMs(snapshot, snapshotObservedAtMs) / 1_000;
if (shouldCorrectRadioDrift(audio.currentTime * 1_000, expected * 1_000)) {
audio.currentTime = expected;
}
}
audio.volume = preferences.volume;
audio.muted = preferences.muted;
void audio
.play()
.then(() => setAutoplayBlocked(false))
.catch(() => setAutoplayBlocked(true));
};
audio.addEventListener('loadedmetadata', play);
audio.addEventListener('error', reportMediaError);
if (isHlsUrl(playbackUrl) && Hls.isSupported()) {
hls = new Hls({ enableWorker: true });
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls?.loadSource(playbackUrl));
hls.on(Hls.Events.MANIFEST_PARSED, play);
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) reportMediaError();
});
hls.attachMedia(audio);
} else {
audio.src = playbackUrl;
audio.load();
}
return () => {
audio.removeEventListener('loadedmetadata', play);
audio.removeEventListener('error', reportMediaError);
hls?.destroy();
audio.pause();
audio.removeAttribute('src');
audio.load();
};
}, [playbackUrl]);
useEffect(() => {
const audio = audioRef.current;
if (
!audio ||
!snapshot ||
snapshot.sourceKind !== 'audio_playlist' ||
snapshotObservedAtMs === undefined ||
audio.readyState < HTMLMediaElement.HAVE_METADATA
)
return;
const expected = projectRadioPositionMs(snapshot, snapshotObservedAtMs);
if (shouldCorrectRadioDrift(audio.currentTime * 1_000, expected))
audio.currentTime = expected / 1_000;
}, [snapshot, snapshotObservedAtMs]);
const setVolume = useCallback((volume: number) => {
setPreferences((current) => ({ ...current, volume: Math.min(Math.max(volume, 0), 1) }));
}, []);
const toggleMuted = useCallback(() => {
setPreferences((current) => ({ ...current, muted: !current.muted }));
}, []);
const setYoutubeResume = useCallback((resume?: () => Promise<void>) => {
youtubeResumeRef.current = resume;
}, []);
const reportAutoplayBlocked = useCallback(() => setAutoplayBlocked(true), []);
const reportPlaybackStarted = useCallback(() => setAutoplayBlocked(false), []);
const resume = useCallback(async () => {
try {
if (listeningChannel?.source.kind === 'youtube_playlist') {
await youtubeResumeRef.current?.();
} else {
await audioRef.current?.play();
}
setAutoplayBlocked(false);
} catch {
setAutoplayBlocked(true);
}
}, [listeningChannel]);
const value = useMemo<RadioContextValue>(
() => ({
channels,
canManage,
loading,
connected,
error,
listeningChannelId,
listeningChannel,
snapshot,
snapshotObservedAtMs,
volume: preferences.volume,
muted: preferences.muted,
autoplayBlocked,
controller,
refreshCatalog,
listen,
leave,
setVolume,
toggleMuted,
resume,
setYoutubeResume,
reportAutoplayBlocked,
reportPlaybackStarted,
}),
[
autoplayBlocked,
canManage,
channels,
connected,
controller,
error,
leave,
listen,
listeningChannel,
listeningChannelId,
loading,
preferences,
refreshCatalog,
reportAutoplayBlocked,
reportPlaybackStarted,
resume,
setVolume,
setYoutubeResume,
snapshot,
snapshotObservedAtMs,
toggleMuted,
]
);
return (
<RadioContext.Provider value={value}>
{children}
<audio ref={audioRef} aria-hidden="true" preload="auto" />
</RadioContext.Provider>
);
}
export const useRadio = (): RadioContextValue => {
const context = useContext(RadioContext);
if (!context) throw new Error('useRadio must be used inside RadioProvider');
return context;
};
@@ -0,0 +1,137 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { BOTSU_RADIO_PATH } from '../../app/pages/paths';
import { botsuCinnyThemeVars } from '../shell/theme';
import { formatRadioTime, projectRadioPositionMs } from './model';
import { useRadio } from './RadioContext';
import { RadioYoutubePlayer } from './RadioYoutubePlayer';
export function RadioPopover({ onClose }: { onClose: () => void }) {
const radio = useRadio();
const current = radio.listeningChannel;
const snapshot = radio.snapshot;
const [nowMs, setNowMs] = useState(Date.now());
useEffect(() => {
setNowMs(Date.now());
if (!snapshot?.item) return undefined;
const timer = window.setInterval(() => setNowMs(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [snapshot?.item?.id]);
const positionMs =
snapshot && radio.snapshotObservedAtMs !== undefined
? projectRadioPositionMs(snapshot, radio.snapshotObservedAtMs, nowMs)
: snapshot?.positionMs ?? 0;
const youtubeSnapshot =
snapshot?.sourceKind === 'youtube_playlist' && snapshot.item && 'videoId' in snapshot.item
? (snapshot as typeof snapshot & { item: typeof snapshot.item & { videoId: string } })
: undefined;
return (
<section
className="botsu-theme botsu-radio-popover"
style={botsuCinnyThemeVars}
data-botsu-radio-popover
aria-label="Radio BOTSU"
>
<header className="botsu-radio-popover__header">
<span>
<strong>Radio BOTSU</strong>
<small>{radio.connected ? 'Synchronisée' : 'Reconnexion…'}</small>
</span>
<span className="botsu-radio-popover__actions">
<Link to={BOTSU_RADIO_PATH}>Gérer les chaînes</Link>
<button type="button" aria-label="Fermer le mini-player Radio" onClick={onClose}>
×
</button>
</span>
</header>
{radio.error && (
<p className="botsu-radio-alert" role="alert">
{radio.error}
</p>
)}
{radio.loading ? (
<p className="botsu-radio-muted">Chargement des chaînes</p>
) : (
<ul className="botsu-radio-channel-list" aria-label="Chaînes disponibles">
{radio.channels.length === 0 && (
<li className="botsu-radio-muted">Aucune chaîne publiée.</li>
)}
{radio.channels.map((channel) => {
const listening = channel.id === radio.listeningChannelId;
return (
<li key={channel.id} className={listening ? 'is-listening' : undefined}>
<span>
<strong>{channel.name}</strong>
<small>
{channel.description ||
(channel.source.kind === 'live_stream'
? 'Web-radio en direct'
: 'Playlist synchronisée')}
</small>
</span>
{listening ? (
<button type="button" onClick={radio.leave}>
Quitter
</button>
) : (
<button type="button" onClick={() => void radio.listen(channel.id)}>
Écouter
</button>
)}
</li>
);
})}
</ul>
)}
{current && (
<div className="botsu-radio-now" aria-live="polite">
<span className="botsu-radio-live-dot" aria-hidden="true" />
<span>
<small>
EN DIRECT · {snapshot?.listenerCount ?? 0} auditeur
{snapshot?.listenerCount === 1 ? '' : 's'}
</small>
<strong>{snapshot?.item?.title ?? current.name}</strong>
{snapshot?.item && (
<small>
{formatRadioTime(positionMs)} / {formatRadioTime(snapshot.item.durationMs)}
</small>
)}
</span>
</div>
)}
{youtubeSnapshot && radio.snapshotObservedAtMs !== undefined && (
<RadioYoutubePlayer snapshot={youtubeSnapshot} observedAtMs={radio.snapshotObservedAtMs} />
)}
{current && (
<div className="botsu-radio-volume">
<button type="button" onClick={radio.toggleMuted}>
{radio.muted ? 'Réactiver le son' : 'Muet'}
</button>
<label>
<span>Volume personnel</span>
<input
aria-label="Volume personnel"
type="range"
min="0"
max="1"
step="0.01"
value={radio.volume}
onChange={(event) => radio.setVolume(event.currentTarget.valueAsNumber)}
/>
</label>
{radio.autoplayBlocked && (
<button type="button" onClick={() => void radio.resume()}>
Reprendre
</button>
)}
</div>
)}
</section>
);
}
+101
View File
@@ -0,0 +1,101 @@
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import { Icon, Icons, PopOut, RectCords } from 'folds';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../app/components/sidebar';
import { RadioPopover } from './RadioPopover';
import { useRadio } from './RadioContext';
import './radio.css';
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function RadioTab() {
const radio = useRadio();
const [anchor, setAnchor] = useState<RectCords>();
const triggerButtonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const close = useCallback((restoreFocus = false) => {
setAnchor(undefined);
if (radio.listeningChannel?.source.kind === 'youtube_playlist') radio.leave();
if (restoreFocus) window.requestAnimationFrame(() => triggerButtonRef.current?.focus());
}, [radio.leave, radio.listeningChannel?.source.kind]);
useEffect(() => {
if (!anchor) return undefined;
const focusables = () =>
Array.from(popoverRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? []);
const focusFrame = window.requestAnimationFrame(() => focusables()[0]?.focus());
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Element &&
event.target.closest('[data-botsu-radio-popover], [data-botsu-radio-trigger]')
)
return;
close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
close(true);
return;
}
if (event.key !== 'Tab') return;
const items = focusables();
if (items.length === 0) return;
const first = items[0]!;
const last = items[items.length - 1]!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown, true);
return () => {
window.cancelAnimationFrame(focusFrame);
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [anchor, close]);
const toggle: MouseEventHandler<HTMLButtonElement> = (event) => {
if (anchor) close();
else setAnchor(event.currentTarget.getBoundingClientRect());
};
return (
<SidebarItem active={Boolean(anchor || radio.listeningChannelId)}>
<SidebarItemTooltip tooltip="Radio">
{(triggerRef) => (
<PopOut
anchor={anchor}
position="Right"
align="End"
content={
<div ref={popoverRef}>
<RadioPopover onClose={() => close(true)} />
</div>
}
>
<SidebarAvatar
as="button"
ref={(node) => {
triggerRef(node);
triggerButtonRef.current = node;
}}
outlined
data-botsu-radio-trigger
aria-label="Ouvrir la Radio BOTSU"
aria-expanded={Boolean(anchor)}
onClick={toggle}
>
<Icon src={Icons.Headphone} filled={Boolean(radio.listeningChannelId)} />
</SidebarAvatar>
</PopOut>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}
@@ -0,0 +1,120 @@
import React, { useEffect, useRef } from 'react';
import type { BotsuRadioPlaybackSnapshot, BotsuRadioYoutubeTrack } from '@botsu/protocol';
import { loadYouTubeApi, type BotsuYouTubePlayer } from '../media/youtube';
import { projectRadioPositionMs, shouldCorrectRadioDrift } from './model';
import { useRadio } from './RadioContext';
type RadioYoutubePlayerProps = {
snapshot: BotsuRadioPlaybackSnapshot & { item: BotsuRadioYoutubeTrack };
observedAtMs: number;
};
export function RadioYoutubePlayer({ snapshot, observedAtMs }: RadioYoutubePlayerProps) {
const hostRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<BotsuYouTubePlayer>();
const {
volume,
muted,
setYoutubeResume,
reportAutoplayBlocked: onAutoplayBlocked,
reportPlaybackStarted,
} = useRadio();
const preferencesRef = useRef({ volume, muted });
preferencesRef.current = { volume, muted };
const timingRef = useRef({ snapshot, observedAtMs });
timingRef.current = { snapshot, observedAtMs };
useEffect(() => {
const player = playerRef.current;
if (!player) return;
player.setVolume(Math.round(volume * 100));
if (muted) player.mute();
else player.unMute();
}, [muted, volume]);
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
let disposed = false;
let destroy: (() => void) | undefined;
host.replaceChildren();
void loadYouTubeApi()
.then((api) => {
if (disposed) return;
const iframe = document.createElement('iframe');
const params = new URLSearchParams({
autoplay: '0',
controls: '1',
disablekb: '0',
enablejsapi: '1',
origin: window.location.origin,
playsinline: '1',
rel: '0',
});
iframe.allow = 'autoplay; encrypted-media; picture-in-picture';
iframe.allowFullscreen = true;
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.title = `Radio YouTube · ${snapshot.item.title}`;
iframe.src = `https://www.youtube.com/embed/${snapshot.item.videoId}?${params.toString()}`;
host.append(iframe);
let correctionTimer: number | undefined;
const player = new api.Player(iframe, {
videoId: snapshot.item.videoId,
playerVars: { autoplay: 0, controls: 1, playsinline: 1, rel: 0 },
events: {
onReady: () => {
if (disposed) return;
const applyVolume = () => {
const current = preferencesRef.current;
player.setVolume(Math.round(current.volume * 100));
if (current.muted) player.mute();
else player.unMute();
};
const resume = async () => {
const current = timingRef.current;
player.seekTo(
projectRadioPositionMs(current.snapshot, current.observedAtMs) / 1_000,
true
);
applyVolume();
player.playVideo();
};
setYoutubeResume(resume);
void resume();
correctionTimer = window.setInterval(() => {
const current = timingRef.current;
const expected = projectRadioPositionMs(current.snapshot, current.observedAtMs);
if (shouldCorrectRadioDrift(player.getCurrentTime() * 1_000, expected)) {
player.seekTo(expected / 1_000, true);
}
applyVolume();
}, 5_000);
},
onStateChange: (event) => {
if (event.data === api.PlayerState.PLAYING) reportPlaybackStarted();
},
onError: onAutoplayBlocked,
onAutoplayBlocked,
},
});
playerRef.current = player;
destroy = () => {
if (correctionTimer !== undefined) window.clearInterval(correctionTimer);
setYoutubeResume(undefined);
if (playerRef.current === player) playerRef.current = undefined;
player.destroy();
};
})
.catch(onAutoplayBlocked);
return () => {
disposed = true;
destroy?.();
host.replaceChildren();
};
}, [onAutoplayBlocked, reportPlaybackStarted, setYoutubeResume, snapshot.item.videoId]);
return <div className="botsu-radio-youtube" ref={hostRef} />;
}
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRadioController } from './controller.ts';
const token = {
access_token: 'openid-radio-token-123456',
matrix_server_name: 'botsu.net',
expires_in: 300,
token_type: 'Bearer',
};
test('radio controller authenticates catalog reads and curator writes with Matrix OpenID', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const controller = createRadioController({
getOpenIdToken: async () => token,
fetchImpl: async (input, init) => {
requests.push({ url: String(input), init });
if (init?.method === 'POST') {
return new Response(
JSON.stringify({
channel: {
version: 1,
id: 'radio_12345678',
name: 'Radio Test',
description: '',
source: { kind: 'live_stream', url: 'https://radio.botsu.net/live.mp3' },
archived: false,
revision: 1,
programRevision: 1,
createdBy: '@alice:botsu.net',
createdAt: 1_800_000_000_000,
updatedAt: 1_800_000_000_000,
},
}),
{ status: 201, headers: { 'content-type': 'application/json' } }
);
}
return new Response(JSON.stringify({ channels: [], canManage: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
assert.deepEqual(await controller.list(), { channels: [], canManage: true });
const created = await controller.create({
name: 'Radio Test',
description: '',
source: { kind: 'live_stream', url: 'https://radio.botsu.net/live.mp3' },
});
assert.equal(created.name, 'Radio Test');
assert.equal(requests.length, 2);
assert.equal(
new Headers(requests[0]?.init?.headers).get('authorization'),
`Bearer ${token.access_token}`
);
assert.equal(requests[1]?.init?.method, 'POST');
});
test('radio controller maps HTTP conflicts and forbidden writes to actionable errors', async () => {
const controller = createRadioController({
getOpenIdToken: async () => token,
fetchImpl: async () =>
new Response(JSON.stringify({ error: 'conflict' }), {
status: 409,
headers: { 'content-type': 'application/json' },
}),
});
await assert.rejects(
controller.update('radio_12345678', 1, {
name: 'Radio Test',
description: '',
source: { kind: 'live_stream', url: 'https://radio.botsu.net/live.mp3' },
}),
/modifiée ailleurs/
);
});
test('radio controller sends revisioned update and archive payloads in the server shape', async () => {
const requests: RequestInit[] = [];
const channel = {
version: 1,
id: 'radio_12345678',
name: 'Radio Test',
description: '',
source: { kind: 'live_stream', url: 'https://radio.botsu.net/live.mp3' },
archived: false,
revision: 2,
programRevision: 1,
createdBy: '@alice:botsu.net',
createdAt: 1_800_000_000_000,
updatedAt: 1_800_000_001_000,
} as const;
const controller = createRadioController({
getOpenIdToken: async () => token,
fetchImpl: async (_input, init) => {
requests.push(init ?? {});
return new Response(JSON.stringify({ channel }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
const draft = { name: channel.name, description: '', source: channel.source } as const;
await controller.update(channel.id, 1, draft);
await controller.archive(channel.id, 2);
assert.deepEqual(JSON.parse(String(requests[0]?.body)), { expectedRevision: 1, draft });
assert.deepEqual(JSON.parse(String(requests[1]?.body)), { expectedRevision: 2 });
});
+141
View File
@@ -0,0 +1,141 @@
import {
parseBotsuRadioChannel,
parseBotsuRadioChannelDraft,
parseBotsuRadioPlaybackSnapshot,
type BotsuRadioChannel,
type BotsuRadioChannelDraft,
type BotsuRadioPlaybackSnapshot,
} from '@botsu/protocol';
type OpenIdToken = { access_token: string };
type RadioControllerOptions = {
getOpenIdToken: () => Promise<OpenIdToken>;
fetchImpl?: typeof fetch;
};
export type RadioCatalog = {
channels: BotsuRadioChannel[];
canManage: boolean;
};
export type RadioChannelDetail = {
channel: BotsuRadioChannel;
playback: BotsuRadioPlaybackSnapshot;
canManage: boolean;
};
export type RadioController = {
list: (includeArchived?: boolean) => Promise<RadioCatalog>;
get: (channelId: string) => Promise<RadioChannelDetail>;
create: (draft: BotsuRadioChannelDraft) => Promise<BotsuRadioChannel>;
update: (
channelId: string,
expectedRevision: number,
draft: BotsuRadioChannelDraft
) => Promise<BotsuRadioChannel>;
archive: (channelId: string, expectedRevision: number) => Promise<BotsuRadioChannel>;
restore: (channelId: string, expectedRevision: number) => Promise<BotsuRadioChannel>;
};
const BASE = '/presence/radio/channels';
const asRecord = (value: unknown): Record<string, unknown> => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('Réponse Radio invalide.');
}
return value as Record<string, unknown>;
};
const getErrorMessage = (status: number, payload: unknown): string => {
if (status === 401) return 'La session Matrix doit être reconnectée.';
if (status === 403) return "Vous n'avez pas le droit de programmer la Radio.";
if (status === 404) return "Cette chaîne n'existe plus.";
if (status === 409) return 'Cette chaîne a été modifiée ailleurs. Rechargez-la avant de publier.';
const error =
typeof payload === 'object' && payload !== null
? (payload as { error?: unknown }).error
: undefined;
return typeof error === 'string' && error.length <= 240 ? error : `Erreur Radio (${status}).`;
};
export const createRadioController = (options: RadioControllerOptions): RadioController => {
const fetchImpl = options.fetchImpl ?? fetch;
const request = async (path = '', init: RequestInit = {}): Promise<unknown> => {
const token = await options.getOpenIdToken();
const response = await fetchImpl(`${BASE}${path}`, {
...init,
headers: {
authorization: `Bearer ${token.access_token}`,
...init.headers,
},
});
const payload = (await response.json().catch(() => ({}))) as unknown;
if (!response.ok) throw new Error(getErrorMessage(response.status, payload));
return payload;
};
const parseChannelResponse = (payload: unknown): BotsuRadioChannel =>
parseBotsuRadioChannel(asRecord(payload).channel);
return {
async list(includeArchived = false) {
const payload = asRecord(await request(includeArchived ? '?includeArchived=true' : ''));
if (!Array.isArray(payload.channels) || typeof payload.canManage !== 'boolean') {
throw new TypeError('Catalogue Radio invalide.');
}
return {
channels: payload.channels.map(parseBotsuRadioChannel),
canManage: payload.canManage,
};
},
async get(channelId) {
const payload = asRecord(await request(`/${encodeURIComponent(channelId)}`));
if (typeof payload.canManage !== 'boolean') throw new TypeError('Chaîne Radio invalide.');
return {
channel: parseBotsuRadioChannel(payload.channel),
playback: parseBotsuRadioPlaybackSnapshot(payload.playback),
canManage: payload.canManage,
};
},
async create(draft) {
return parseChannelResponse(
await request('', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(parseBotsuRadioChannelDraft(draft)),
})
);
},
async update(channelId, expectedRevision, draft) {
return parseChannelResponse(
await request(`/${encodeURIComponent(channelId)}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
expectedRevision,
draft: parseBotsuRadioChannelDraft(draft),
}),
})
);
},
async archive(channelId, expectedRevision) {
return parseChannelResponse(
await request(`/${encodeURIComponent(channelId)}/archive`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ expectedRevision }),
})
);
},
async restore(channelId, expectedRevision) {
return parseChannelResponse(
await request(`/${encodeURIComponent(channelId)}/restore`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ expectedRevision }),
})
);
},
};
};
+3
View File
@@ -0,0 +1,3 @@
export * from './BotsuRadioPage';
export * from './RadioContext';
export * from './RadioTab';
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
formatRadioTime,
parseYoutubeVideoId,
projectRadioPositionMs,
shouldCorrectRadioDrift,
} from './model.ts';
const snapshot = {
version: 1,
channelId: 'radio_12345678',
channelRevision: 1,
programRevision: 1,
sourceKind: 'audio_playlist',
item: {
id: 'track_alpha',
title: 'Alpha',
url: 'https://media.botsu.net/alpha.mp3',
durationMs: 60_000,
},
startedAtMs: 1_800_000_000_000,
positionMs: 10_000,
nextTransitionAtMs: 1_800_000_050_000,
serverTimeMs: 1_800_000_010_000,
listenerCount: 2,
} as const;
test('projects radio position from local observation and clamps to the item duration', () => {
assert.equal(projectRadioPositionMs(snapshot, 5_000, 7_500), 12_500);
assert.equal(projectRadioPositionMs(snapshot, 5_000, 70_000), 60_000);
assert.equal(shouldCorrectRadioDrift(10_000, 11_000), false);
assert.equal(shouldCorrectRadioDrift(10_000, 11_251), true);
});
test('parses canonical YouTube video URLs without accepting playlists or Spotify', () => {
assert.equal(parseYoutubeVideoId('https://youtu.be/dQw4w9WgXcQ?t=10'), 'dQw4w9WgXcQ');
assert.equal(parseYoutubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
assert.throws(
() => parseYoutubeVideoId('https://www.youtube.com/playlist?list=PL123'),
/vidéo YouTube/
);
assert.throws(() => parseYoutubeVideoId('https://open.spotify.com/track/test'), /YouTube/);
});
test('formats compact radio times', () => {
assert.equal(formatRadioTime(65_000), '1:05');
assert.equal(formatRadioTime(3_665_000), '1:01:05');
});
+61
View File
@@ -0,0 +1,61 @@
import type { BotsuRadioPlaybackSnapshot } from '@botsu/protocol';
const YOUTUBE_VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
export const projectRadioPositionMs = (
snapshot: BotsuRadioPlaybackSnapshot,
observedAtMs: number,
nowMs = Date.now()
): number => {
const elapsed = Math.max(0, nowMs - observedAtMs);
const projected = Math.max(0, snapshot.positionMs + elapsed);
const duration = snapshot.item?.durationMs;
return duration === undefined ? projected : Math.min(projected, duration);
};
export const shouldCorrectRadioDrift = (
actualPositionMs: number,
expectedPositionMs: number,
thresholdMs = 1_250
): boolean => Math.abs(actualPositionMs - expectedPositionMs) > thresholdMs;
export const parseYoutubeVideoId = (input: string): string => {
let url: URL;
try {
url = new URL(input.trim());
} catch {
throw new TypeError('Saisissez une URL de vidéo YouTube valide.');
}
if (url.protocol !== 'https:' || url.username || url.password) {
throw new TypeError('Saisissez une URL de vidéo YouTube en HTTPS.');
}
const host = url.hostname.toLowerCase();
let videoId: string | null = null;
if (host === 'youtu.be') {
videoId = url.pathname.split('/').filter(Boolean)[0] ?? null;
} else if (host === 'youtube.com' || host === 'www.youtube.com' || host === 'm.youtube.com') {
if (url.pathname === '/watch') videoId = url.searchParams.get('v');
else if (url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/')) {
videoId = url.pathname.split('/').filter(Boolean)[1] ?? null;
}
}
if (!videoId || !YOUTUBE_VIDEO_ID.test(videoId)) {
throw new TypeError('Saisissez une URL de vidéo YouTube, pas une playlist.');
}
return videoId;
};
export const formatRadioTime = (durationMs: number): string => {
const totalSeconds = Math.max(0, Math.floor(durationMs / 1_000));
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
const padded = (value: number) => String(value).padStart(2, '0');
return hours > 0
? `${hours}:${padded(minutes)}:${padded(seconds)}`
: `${minutes}:${padded(seconds)}`;
};
export const createRadioTrackId = (): string =>
`track_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const read = (name: string) => readFile(new URL(name, import.meta.url), 'utf8');
test('global radio tab lives below Search and exposes listening-only controls', async () => {
const [sidebar, tab, popover, context] = await Promise.all([
read('../../app/pages/client/SidebarNav.tsx'),
read('./RadioTab.tsx'),
read('./RadioPopover.tsx'),
read('./RadioContext.tsx'),
]);
assert.match(sidebar, /<SearchTab \/>[\s\S]*<RadioTab \/>/);
assert.match(tab, /tooltip="Radio"/);
assert.doesNotMatch(tab, /FocusTrap/);
assert.match(tab, /document\.addEventListener\('pointerdown'/);
assert.match(tab, /document\.addEventListener\('keydown'/);
assert.match(tab, /event\.key === 'Escape'/);
assert.match(tab, /querySelectorAll<HTMLElement>/);
assert.match(tab, /data-botsu-radio-trigger/);
assert.match(popover, /data-botsu-radio-popover/);
assert.match(popover, /aria-label="Fermer le mini-player Radio"/);
assert.match(tab, /<RadioPopover onClose=\{\(\) => close\(true\)\} \/>/);
assert.match(popover, /Écouter/);
assert.match(popover, /Quitter/);
assert.match(popover, /Volume personnel/);
assert.doesNotMatch(popover, /Suivante|Précédente|Rechercher/);
assert.match(context, /botsu\.radio\.preferences\.v1/);
assert.match(context, /type: 'radio\.leave'/);
});
test('YouTube radio remains visible and closes the listening session with the popover', async () => {
const [popover, youtube, css] = await Promise.all([
read('./RadioPopover.tsx'),
read('./RadioYoutubePlayer.tsx'),
read('./radio.css'),
]);
assert.match(popover, /<RadioYoutubePlayer/);
assert.match(youtube, /controls: 1/);
assert.match(youtube, /onAutoplayBlocked/);
assert.match(css, /min-height: 200px/);
assert.match(css, /min-width: 200px/);
});
test('Radio popover inherits the same Cinny BOTSU theme as the application shell', async () => {
const [popover, css, theme] = await Promise.all([
read('./RadioPopover.tsx'),
read('./radio.css'),
read('../shell/theme.ts'),
]);
assert.match(popover, /import \{ botsuCinnyThemeVars \} from '\.\.\/shell\/theme';/);
assert.match(popover, /className="botsu-theme botsu-radio-popover"/);
assert.match(popover, /style=\{botsuCinnyThemeVars\}/);
assert.match(theme, /'--botsu-color-surface': color\.Surface\.Container/);
assert.match(theme, /'--botsu-color-accent': color\.Primary\.Main/);
assert.match(css, /background:\s*var\(--botsu-color-surface,/);
assert.match(css, /border:\s*var\(--botsu-border-width,/);
assert.match(css, /border-radius:\s*var\(--botsu-radius,/);
assert.match(css, /accent-color:\s*var\(--botsu-color-accent,/);
assert.match(css, /outline:\s*2px solid var\(--botsu-color-focus,/);
});
test('Radio page offers curator-only create, update, archive, and restore workflows', async () => {
const page = await read('./BotsuRadioPage.tsx');
assert.match(page, /canManage/);
assert.match(page, /Créer une chaîne/);
assert.match(page, /Détecter la durée/);
assert.match(page, /Analyser la vidéo/);
assert.match(page, /Archiver/);
assert.match(page, /Restaurer/);
assert.match(page, /Cette publication relancera immédiatement la programmation/);
});
test('Radio provider is global, its page is routed, and the catalogue exposes it', async () => {
const [router, paths, catalogue] = await Promise.all([
read('../../app/pages/Router.tsx'),
read('../../app/pages/paths.ts'),
read('../apps/catalog.ts'),
]);
assert.match(router, /<RadioProvider>/);
assert.match(router, /path=\{_BOTSU_RADIO_PATH\} element=\{<BotsuRadioPage \/>\}/);
assert.match(paths, /_BOTSU_RADIO_PATH = 'radio\/'/);
assert.match(catalogue, /id: 'radio'[\s\S]*availability: 'available'/);
});
+302
View File
@@ -0,0 +1,302 @@
.botsu-radio-popover {
width: min(23rem, calc(100vw - 5.5rem));
max-height: min(42rem, calc(100vh - 1rem));
overflow: auto;
padding: 0.75rem;
color: var(--botsu-color-text, var(--tc-surface-normal, #f5f5f5));
background: var(--botsu-color-surface, var(--bg-surface, #111111));
border: var(--botsu-border-width, 1px) solid
var(--botsu-color-border, var(--bg-surface-border, #3a3a3a));
border-radius: var(--botsu-radius, 2px);
box-shadow: 0 1rem 3rem color-mix(in srgb, var(--botsu-color-canvas, #050505) 55%, transparent);
font-family: var(--botsu-font-family, var(--font-secondary));
}
.botsu-radio-popover,
.botsu-radio-popover * {
box-sizing: border-box;
}
.botsu-radio-popover button,
.botsu-radio-popover a,
.botsu-radio-page button {
cursor: pointer;
}
.botsu-radio-popover__header,
.botsu-radio-channel-list li,
.botsu-radio-now,
.botsu-radio-volume {
display: flex;
align-items: center;
gap: 0.65rem;
}
.botsu-radio-popover__header {
justify-content: space-between;
padding-bottom: 0.65rem;
border-bottom: var(--botsu-border-width, 1px) solid
var(--botsu-color-border, var(--bg-surface-border, #3a3a3a));
}
.botsu-radio-popover__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.botsu-radio-popover__actions button {
width: 2rem;
height: 2rem;
padding: 0;
color: var(--botsu-color-text, var(--tc-surface-normal, #f5f5f5));
background: var(--botsu-color-surface-raised, var(--bg-surface-high, #181818));
border: var(--botsu-border-width, 1px) solid
var(--botsu-color-border, var(--bg-surface-border, #3a3a3a));
border-radius: 999px;
font: inherit;
}
.botsu-radio-popover__actions a {
color: var(--botsu-color-accent, var(--tc-link, currentColor));
text-underline-offset: 0.2em;
}
.botsu-radio-popover__header > span:first-child,
.botsu-radio-channel-list li > span,
.botsu-radio-now > span {
min-width: 0;
display: grid;
gap: 0.15rem;
}
.botsu-radio-popover small,
.botsu-radio-muted {
color: var(--botsu-color-text-muted, var(--tc-surface-low, #a3a3a3));
}
.botsu-radio-muted {
margin-block: 0.65rem;
}
.botsu-radio-channel-list {
display: grid;
gap: 0.35rem;
margin: 0.65rem 0;
padding: 0;
list-style: none;
}
.botsu-radio-channel-list li {
justify-content: space-between;
padding: 0.55rem;
border: var(--botsu-border-width, 1px) solid transparent;
border-radius: var(--botsu-radius, 2px);
}
.botsu-radio-channel-list li.is-listening {
border-color: color-mix(
in srgb,
var(--botsu-color-accent, var(--bg-primary, currentColor)) 42%,
var(--botsu-color-border, transparent)
);
background: color-mix(
in srgb,
var(--botsu-color-accent, var(--bg-primary, currentColor)) 10%,
var(--botsu-color-surface-raised, var(--bg-surface-high, transparent))
);
}
.botsu-radio-channel-list button,
.botsu-radio-volume button {
min-height: 2rem;
padding: 0.25rem 0.6rem;
color: var(--botsu-color-text, var(--tc-surface-normal, #f5f5f5));
background: var(--botsu-color-surface-raised, var(--bg-surface-high, #181818));
border: var(--botsu-border-width, 1px) solid
var(--botsu-color-border, var(--bg-surface-border, #3a3a3a));
border-radius: 999px;
font: inherit;
}
.botsu-radio-popover button:hover {
border-color: var(--botsu-color-accent, var(--bg-primary, currentColor));
background: color-mix(
in srgb,
var(--botsu-color-accent, var(--bg-primary, currentColor)) 12%,
var(--botsu-color-surface-raised, var(--bg-surface-high, transparent))
);
}
.botsu-radio-popover button:focus-visible,
.botsu-radio-popover a:focus-visible,
.botsu-radio-popover input:focus-visible {
outline: 2px solid var(--botsu-color-focus, var(--bg-primary, currentColor));
outline-offset: 2px;
}
.botsu-radio-now {
padding: 0.65rem 0;
border-top: var(--botsu-border-width, 1px) solid
var(--botsu-color-border, var(--bg-surface-border, #3a3a3a));
}
.botsu-radio-live-dot {
width: 0.65rem;
height: 0.65rem;
flex: none;
background: #ef4444;
border-radius: 50%;
box-shadow: 0 0 0.75rem rgb(239 68 68 / 80%);
}
.botsu-radio-youtube {
width: 100%;
min-width: 200px;
min-height: 200px;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #000;
border-radius: 0.5rem;
}
.botsu-radio-youtube iframe {
width: 100%;
height: 100%;
border: 0;
}
.botsu-radio-volume {
align-items: end;
padding-top: 0.65rem;
}
.botsu-radio-volume label {
min-width: 0;
display: grid;
flex: 1;
gap: 0.2rem;
font-size: 0.75rem;
}
.botsu-radio-volume input {
width: 100%;
accent-color: var(--botsu-color-accent, var(--bg-primary, currentColor));
}
.botsu-radio-alert {
padding: 0.5rem;
color: #fecaca;
background: rgb(127 29 29 / 40%);
border-radius: 0.4rem;
}
.botsu-radio-page {
width: min(72rem, 100%);
margin: 0 auto;
padding: clamp(1rem, 4vw, 3rem);
}
.botsu-radio-page__header,
.botsu-radio-page article header,
.botsu-radio-page article footer,
.botsu-radio-editor header,
.botsu-radio-editor footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.botsu-radio-page__header h1,
.botsu-radio-page article h2,
.botsu-radio-editor h2 {
margin: 0;
}
.botsu-radio-page__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 0.75rem;
margin-top: 1.5rem;
}
.botsu-radio-page article,
.botsu-radio-editor {
padding: 1rem;
background: var(--botsu-color-surface);
border: var(--botsu-border-width) solid var(--botsu-color-border);
border-radius: var(--botsu-radius);
}
.botsu-radio-page article.is-archived {
opacity: 0.62;
}
.botsu-radio-page article small,
.botsu-radio-page article b {
font-size: 0.68rem;
letter-spacing: 0.1em;
}
.botsu-radio-page button,
.botsu-radio-editor input,
.botsu-radio-editor textarea,
.botsu-radio-editor select {
min-height: 2.35rem;
padding: 0.45rem 0.65rem;
color: inherit;
background: transparent;
border: 1px solid var(--botsu-color-border);
border-radius: max(2px, calc(var(--botsu-radius) / 2));
font: inherit;
}
.botsu-radio-editor {
display: grid;
gap: 1rem;
margin-top: 1.5rem;
}
.botsu-radio-editor label,
.botsu-radio-editor fieldset {
min-width: 0;
display: grid;
gap: 0.4rem;
}
.botsu-radio-editor textarea {
min-height: 5rem;
resize: vertical;
}
.botsu-radio-track {
display: grid;
grid-template-columns: minmax(8rem, 0.8fr) minmax(12rem, 1.5fr) auto auto auto;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
}
.botsu-radio-warning {
padding: 0.75rem;
color: #fde68a;
background: rgb(120 53 15 / 45%);
border: 1px solid rgb(245 158 11 / 45%);
}
.botsu-radio-probe {
width: min(32rem, 100%);
}
@media (max-width: 48rem) {
.botsu-radio-page__header,
.botsu-radio-track {
grid-template-columns: 1fr;
}
.botsu-radio-page__header {
align-items: start;
flex-direction: column;
}
}
+4 -20
View File
@@ -1,20 +1,19 @@
import React, { CSSProperties, useMemo, useRef } from 'react';
import { color, config, vars } from 'folds';
import React, { useMemo, useRef } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { BotsuPresence } from '../presence/BotsuPresence';
import { PixelCanvasProvider } from '../start/PixelCanvasContext';
import { MapViewportProvider } from '../openstreetmap/MapViewportContext';
import { botsuCinnyThemeVars } from './theme';
import '@botsu/ui/styles.css';
import './shell.css';
type BotsuThemeStyle = CSSProperties & Record<`--botsu-${string}`, string>;
const BOTSU_ROUTE_LABELS: Record<string, string> = {
botsu: 'botsu',
apps: 'apps',
documents: 'documents',
moodboard: 'pinterest',
services: 'services',
radio: 'radio',
home: 'home',
};
@@ -27,28 +26,13 @@ const buildBreadcrumbs = (pathname: string): { label: string; to: string }[] =>
}));
};
const botsuCinnyThemeVars = {
'--botsu-color-canvas': color.Background.Container,
'--botsu-color-surface': color.Surface.Container,
'--botsu-color-surface-raised': color.SurfaceVariant.Container,
'--botsu-color-text': color.Background.OnContainer,
'--botsu-color-text-muted': color.SurfaceVariant.OnContainer,
'--botsu-color-border': color.Surface.ContainerLine,
'--botsu-color-focus': vars.outline.FocusRing,
'--botsu-color-accent': color.Primary.Main,
'--botsu-color-on-accent': color.Primary.OnMain,
'--botsu-border-width': config.borderWidth.B300,
'--botsu-radius': config.radii.R300,
'--botsu-font-family': 'var(--font-secondary)',
} satisfies BotsuThemeStyle;
export function BotsuFrame() {
const rootRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const breadcrumbs = useMemo(() => buildBreadcrumbs(location.pathname), [location.pathname]);
return (
<div ref={rootRef} className="botsu-theme botsu-shell" style={botsuCinnyThemeVars as CSSProperties}>
<div ref={rootRef} className="botsu-theme botsu-shell" style={botsuCinnyThemeVars}>
<PixelCanvasProvider>
<MapViewportProvider>
<div className="botsu-suite-strip">
+1
View File
@@ -22,6 +22,7 @@ const APP_NAV_IDS: readonly BotsuAppId[] = [
'moodboard',
'wikipedia',
'openstreetmap',
'radio',
];
const GENERATIONS_NAV_IDS = new Set(['generations-audiovisuel', 'generations-vision', 'generations-textuel']);
const COOKIES_NAV_IDS: readonly BotsuAppId[] = ['shop', 'emoji-tcg', 'casino', 'bourse', 'pets'];
+49 -8
View File
@@ -5,6 +5,7 @@ import test from 'node:test';
const shellCssUrl = new URL('./shell.css', import.meta.url);
const shellFrameUrl = new URL('./BotsuFrame.tsx', import.meta.url);
const shellThemeUrl = new URL('./theme.ts', import.meta.url);
const shellNavUrl = new URL('./BotsuNav.tsx', import.meta.url);
const botsuLinksUrl = new URL('./BotsuLinks.tsx', import.meta.url);
const drawingRoomUrl = new URL('../drawing/BotsuDrawingRoomView.tsx', import.meta.url);
@@ -13,6 +14,7 @@ const shellTabUrl = new URL('./BotsuTab.tsx', import.meta.url);
const shellLogoUrl = new URL('./BotsuLogo.tsx', import.meta.url);
const presenceUrl = new URL('../presence/BotsuPresence.tsx', import.meta.url);
const routerUrl = new URL('../../app/pages/Router.tsx', import.meta.url);
const mobileFriendlyUrl = new URL('../../app/pages/MobileFriendly.tsx', import.meta.url);
const clientNonUiUrl = new URL('../../app/pages/client/ClientNonUIFeatures.tsx', import.meta.url);
const settingsUrl = new URL('../../app/state/settings.ts', import.meta.url);
const generalSettingsUrl = new URL('../../app/features/settings/general/General.tsx', import.meta.url);
@@ -75,9 +77,10 @@ test('BOTSU shell leaves room for the active call footer', async () => {
});
test('BOTSU shell keeps a minimal presence strip above the application surface', async () => {
const [css, frame, presence] = await Promise.all([
const [css, frame, theme, presence] = await Promise.all([
readFile(shellCssUrl, 'utf8'),
readFile(shellFrameUrl, 'utf8'),
readFile(shellThemeUrl, 'utf8'),
readFile(presenceUrl, 'utf8'),
]);
const stripIndex = frame.indexOf('className="botsu-suite-strip"');
@@ -89,12 +92,13 @@ test('BOTSU shell keeps a minimal presence strip above the application surface',
assert.ok(presenceIndex > stripIndex);
assert.ok(mainIndex > presenceIndex);
assert.match(frame.slice(stripIndex, presenceIndex), /className="botsu-breadcrumb"/);
assert.match(frame, /import \{ color, config, vars \} from 'folds';/);
assert.match(frame, /const botsuCinnyThemeVars = \{/);
assert.match(frame, /'--botsu-color-canvas': color\.Background\.Container/);
assert.match(frame, /'--botsu-color-accent': color\.Primary\.Main/);
assert.match(frame, /'--botsu-font-family': 'var\(--font-secondary\)'/);
assert.match(frame, /style=\{botsuCinnyThemeVars as CSSProperties\}/);
assert.match(frame, /import \{ botsuCinnyThemeVars \} from '\.\/theme';/);
assert.match(frame, /style=\{botsuCinnyThemeVars\}/);
assert.match(theme, /import \{ color, config, vars \} from 'folds';/);
assert.match(theme, /export const botsuCinnyThemeVars(?:: BotsuThemeStyle)? = \{/);
assert.match(theme, /'--botsu-color-canvas': color\.Background\.Container/);
assert.match(theme, /'--botsu-color-accent': color\.Primary\.Main/);
assert.match(theme, /'--botsu-font-family': 'var\(--font-secondary\)'/);
assert.doesNotMatch(frame, /DocumentSyncProvider/);
assert.match(frame, /<Link to=\{crumb\.to\}>\{crumb\.label\}<\/Link>/);
assert.doesNotMatch(frame, /const isBotsuHome|className="botsu-draw-toggle"/);
@@ -167,6 +171,8 @@ test('Vite defaults to loopback and keeps the private test host configurable', a
assert.ok(viteConfig.includes("env.BOTSU_DEV_ALLOWED_HOSTS || 'localhost,127.0.0.1'"));
assert.ok(viteConfig.includes('env.BOTSU_DEV_HMR_HOST'));
assert.ok(viteConfig.includes("protocol: env.BOTSU_DEV_HMR_PROTOCOL || 'wss'"));
assert.ok(viteConfig.includes("'/presence/ws'"));
assert.match(viteConfig, /rewrite:\s*\(requestPath\)\s*=>\s*requestPath\.replace\('\/presence\/ws', '\/ws'\)/);
assert.ok(viteConfig.includes("'/presence'"));
assert.ok(viteConfig.includes("'/godot'"));
assert.ok(
@@ -332,7 +338,7 @@ test('BOTSU route restores the default left page panel', async () => {
assert.match(nav, /appId === 'discussions'\) return Icons\.Pencil/);
assert.match(
nav,
/const APP_NAV_IDS: readonly BotsuAppId\[\] = \[\s*'godot',\s*'moodboard',\s*'wikipedia',\s*'openstreetmap',\s*\];/s
/const APP_NAV_IDS: readonly BotsuAppId\[\] = \[\s*'godot',\s*'moodboard',\s*'wikipedia',\s*'openstreetmap',\s*'radio',\s*\];/s
);
assert.match(nav, /COOKIES_NAV_IDS/);
assert.match(nav, /GENERATIONS_NAV_IDS = new Set\(\['generations-audiovisuel', 'generations-vision', 'generations-textuel'\]\)/);
@@ -383,7 +389,42 @@ test('BOTSU route restores the default left page panel', async () => {
assert.match(services, /label: 'Convertisseur'/);
});
test('mobile section roots render only the global sidebar and page panel', async () => {
const [router, mobileFriendly] = await Promise.all([
readFile(routerUrl, 'utf8'),
readFile(mobileFriendlyUrl, 'utf8'),
]);
assert.match(
router,
/import \{[\s\S]*MobileFriendlyPageNav,[\s\S]*MobileFriendlyClientNav,[\s\S]*MobileFriendlyPageContent,[\s\S]*\} from '\.\/MobileFriendly';/
);
assert.match(mobileFriendly, /export function MobileFriendlyPageContent/);
assert.match(mobileFriendly, /BOTSU_PATH/);
assert.match(
mobileFriendly,
/const botsuMatch = useMatch\(\{ path: BOTSU_PATH, caseSensitive: true, end: true \}\);/
);
assert.match(mobileFriendly, /homeMatch \|\| botsuMatch \|\| directMatch/);
assert.match(
mobileFriendly,
/if \(screenSize === ScreenSize\.Mobile && exactPath\) \{\s*return null;\s*\}/s
);
assert.match(
router,
/<MobileFriendlyPageContent path=\{BOTSU_PATH\}>\s*<BotsuFrame \/>\s*<\/MobileFriendlyPageContent>/s
);
for (const path of ['HOME_PATH', 'DIRECT_PATH', 'SPACE_PATH', 'EXPLORE_PATH', 'INBOX_PATH']) {
assert.match(
router,
new RegExp(
`<MobileFriendlyPageContent path=\\{${path}\\}>\\s*<Outlet \\/>\\s*<\\/MobileFriendlyPageContent>`,
's'
)
);
}
});
test('BOTSU exposes a Matrix Drawing Room type with a Paint canvas surface', async () => {
const [types, selector, createRoom, modal, roomUtils, room, header, drawingView] = await Promise.all([
+19
View File
@@ -0,0 +1,19 @@
import type { CSSProperties } from 'react';
import { color, config, vars } from 'folds';
export type BotsuThemeStyle = CSSProperties & Record<`--botsu-${string}`, string>;
export const botsuCinnyThemeVars: BotsuThemeStyle = {
'--botsu-color-canvas': color.Background.Container,
'--botsu-color-surface': color.Surface.Container,
'--botsu-color-surface-raised': color.SurfaceVariant.Container,
'--botsu-color-text': color.Background.OnContainer,
'--botsu-color-text-muted': color.SurfaceVariant.OnContainer,
'--botsu-color-border': color.Surface.ContainerLine,
'--botsu-color-focus': vars.outline.FocusRing,
'--botsu-color-accent': color.Primary.Main,
'--botsu-color-on-accent': color.Primary.OnMain,
'--botsu-border-width': config.borderWidth.B300,
'--botsu-radius': config.radii.R300,
'--botsu-font-family': 'var(--font-secondary)',
};
@@ -1,6 +1,7 @@
import React, { MutableRefObject, useEffect, useRef } from 'react';
import type { WatchSourceDescriptor } from './providers';
import { loadYouTubeApi } from '../media/youtube';
export type WatchPlaybackController = {
ready: () => boolean;
@@ -29,34 +30,6 @@ type EmbedProps = {
onError: (message: string) => void;
};
type YouTubePlayer = {
destroy: () => void;
getCurrentTime: () => number;
getDuration: () => number;
mute: () => void;
pauseVideo: () => void;
playVideo: () => void;
seekTo: (seconds: number, allowSeekAhead: boolean) => void;
setVolume: (volume: number) => void;
unMute: () => void;
};
type YouTubeApi = {
Player: new (
element: HTMLElement,
options: {
videoId?: string;
playerVars?: Record<string, number | string>;
events: {
onReady: () => void;
onStateChange: (event: { data: number }) => void;
onError: () => void;
};
}
) => YouTubePlayer;
PlayerState: { ENDED: number };
};
type TwitchPlayer = {
addEventListener: (event: string, listener: () => void) => void;
destroy?: () => void;
@@ -78,9 +51,7 @@ type TwitchApi = {
declare global {
interface Window {
YT?: YouTubeApi;
Twitch?: TwitchApi;
onYouTubeIframeAPIReady?: () => void;
}
}
@@ -108,20 +79,6 @@ const loadScript = (src: string): Promise<void> => {
return promise;
};
const loadYouTube = async (): Promise<YouTubeApi> => {
if (window.YT?.Player) return window.YT;
await new Promise<void>((resolve, reject) => {
const previous = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
previous?.();
resolve();
};
void loadScript('https://www.youtube.com/iframe_api').catch(reject);
});
if (!window.YT?.Player) throw new Error('youtube_sdk_missing');
return window.YT;
};
const loadTwitch = async (): Promise<TwitchApi> => {
if (window.Twitch?.Player) return window.Twitch;
await loadScript('/vendor/twitch/embed-v1.js');
@@ -161,7 +118,7 @@ export function WatchEmbedPlayer({
const mount = async () => {
if (source.kind === 'youtube') {
const api = await loadYouTube();
const api = await loadYouTubeApi();
if (disposed) return;
let ready = false;
const youtubeParams = new URLSearchParams({
+7 -5
View File
@@ -8,6 +8,7 @@ const roomPath = new URL('../../app/features/room/Room.tsx', import.meta.url);
const headerPath = new URL('../../app/features/room/RoomViewHeader.tsx', import.meta.url);
const statePath = new URL('../../app/state/callEmbed.ts', import.meta.url);
const embedPath = new URL('./WatchEmbedPlayer.tsx', import.meta.url);
const youtubeLoaderPath = new URL('../media/youtube.ts', import.meta.url);
const jellyfinPath = new URL('./jellyfin.ts', import.meta.url);
const twitchSdkPath = new URL('../../../vendor/twitch/embed-v1.js', import.meta.url);
const twitchReadmePath = new URL('../../../vendor/twitch/README.md', import.meta.url);
@@ -71,9 +72,10 @@ test('Watch Room provides an OLED fullscreen player with fading VLC controls and
});
test('Watch Room wires synchronized YouTube, Twitch, and token-local Jellyfin playback', async () => {
const [watchView, embed, jellyfin, twitchSdk, twitchReadme] = await Promise.all([
const [watchView, embed, youtubeLoader, jellyfin, twitchSdk, twitchReadme] = await Promise.all([
readFile(watchViewPath, 'utf8'),
readFile(embedPath, 'utf8'),
readFile(youtubeLoaderPath, 'utf8'),
readFile(jellyfinPath, 'utf8'),
readFile(twitchSdkPath, 'utf8'),
readFile(twitchReadmePath, 'utf8'),
@@ -88,10 +90,10 @@ test('Watch Room wires synchronized YouTube, Twitch, and token-local Jellyfin pl
assert.match(watchView, /Sous-titres personnels/);
assert.match(watchView, /\.loadSubtitle\(/);
assert.match(watchView, /xhrSetup/);
assert.match(embed, /https:\/\/www\.youtube\.com\/iframe_api/);
assert.doesNotMatch(embed, /script\.crossOrigin = 'anonymous'/);
assert.match(embed, /found\?\.remove\(\)/);
assert.match(embed, /scripts\.delete\(src\)/);
assert.match(youtubeLoader, /https:\/\/www\.youtube\.com\/iframe_api/);
assert.doesNotMatch(youtubeLoader, /script\.crossOrigin = 'anonymous'/);
assert.match(youtubeLoader, /youtubeApiPromise/);
assert.match(embed, /loadYouTubeApi/);
assert.match(embed, /youtubeCredentialMode/);
assert.match(embed, /youtubeCredentialMode !== 'account'/);
assert.match(embed, /iframe\.setAttribute\('credentialless', ''\)/);
+1
View File
@@ -13,6 +13,7 @@
},
"include": [
"src/botsu/apps/*.ts",
"src/botsu/call/*.ts",
"src/botsu/documents/model.ts",
"src/botsu/documents/document-share.ts",
"src/botsu/documents/document-sync-bridge.ts",
+6
View File
@@ -140,6 +140,12 @@ export default defineConfig(({ mode }) => {
'Cache-Control': 'no-store',
},
proxy: {
'/presence/ws': {
target: env.BOTSU_PRESENCE_PROXY_URL || 'http://127.0.0.1:8091',
ws: true,
changeOrigin: false,
rewrite: (requestPath) => requestPath.replace('/presence/ws', '/ws'),
},
'/presence': {
target: env.BOTSU_PRESENCE_PROXY_URL || 'http://127.0.0.1:8091',
ws: true,
+284
View File
@@ -0,0 +1,284 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import WebSocket from "ws";
import { BotsuRadioStore } from "./radio-store.ts";
import { createPresenceServer } from "./server.ts";
const TEST_ORIGIN = "https://radio-test.botsu.net";
const ALICE_TOKEN = "alice-openid-token-123456789";
const BOB_TOKEN = "bob-openid-token-12345678901";
const request = async (port: number, path: string, token: string, init: RequestInit = {}): Promise<Response> =>
fetch(`http://127.0.0.1:${port}${path}`, {
...init,
headers: {
origin: TEST_ORIGIN,
authorization: `Bearer ${token}`,
"content-type": "application/json",
...init.headers,
},
});
const nextJsonOfType = <T>(socket: WebSocket, type: string): Promise<T> =>
new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for ${type}`));
}, 1_000);
const onMessage = (data: WebSocket.RawData) => {
try {
const value = JSON.parse(data.toString()) as { type?: string };
if (value.type !== type) return;
cleanup();
resolve(value as T);
} catch (error) {
cleanup();
reject(error);
}
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
clearTimeout(timeout);
socket.off("message", onMessage);
socket.off("error", onError);
};
socket.on("message", onMessage);
socket.on("error", onError);
});
const authenticateSocket = async (socket: WebSocket, token: string): Promise<void> => {
await once(socket, "open");
const ready = nextJsonOfType(socket, "presence.ready");
socket.send(
JSON.stringify({
type: "presence.hello",
protocolVersion: 1,
appId: "radio",
openIdToken: {
accessToken: token,
matrixServerName: "botsu.net",
expiresIn: 300,
},
})
);
await ready;
};
const draft = {
name: "Radio API",
description: "Tests API",
source: { kind: "live_stream", url: "https://radio.botsu.net/live.mp3" },
} as const;
test("radio HTTP API is readable by members and writable only by curators", async (context) => {
const root = await mkdtemp(join(tmpdir(), "botsu-radio-api-"));
const radioStore = new BotsuRadioStore({
root,
createId: () => "radio_12345678",
});
const service = createPresenceServer({
allowedOrigin: TEST_ORIGIN,
radioStore,
radioCuratorIds: new Set(["@alice:botsu.net"]),
verifyOpenId: async ({ accessToken }) => ({
userId: accessToken === ALICE_TOKEN ? "@alice:botsu.net" : "@bob:botsu.net",
displayName: accessToken === ALICE_TOKEN ? "Alice" : "Bob",
}),
});
service.httpServer.listen(0, "127.0.0.1");
await once(service.httpServer, "listening");
context.after(() => service.close());
const address = service.httpServer.address();
assert.ok(address && typeof address === "object");
const unauthorized = await fetch(`http://127.0.0.1:${address.port}/presence/radio/channels`, {
headers: { origin: TEST_ORIGIN },
});
assert.equal(unauthorized.status, 401);
const initial = await request(address.port, "/presence/radio/channels", BOB_TOKEN);
assert.equal(initial.status, 200);
assert.deepEqual(await initial.json(), { channels: [], canManage: false });
const forbidden = await request(address.port, "/presence/radio/channels", BOB_TOKEN, {
method: "POST",
body: JSON.stringify(draft),
});
assert.equal(forbidden.status, 403);
const curatorSocket = new WebSocket(`ws://127.0.0.1:${address.port}/ws`, {
origin: TEST_ORIGIN,
});
await authenticateSocket(curatorSocket, ALICE_TOKEN);
context.after(() => curatorSocket.close());
const catalogChanged = nextJsonOfType(curatorSocket, "radio.catalog.changed");
const createdResponse = await request(address.port, "/presence/radio/channels", ALICE_TOKEN, {
method: "POST",
body: JSON.stringify(draft),
});
assert.equal(createdResponse.status, 201);
await catalogChanged;
const { channel } = (await createdResponse.json()) as {
channel: { id: string; revision: number };
};
assert.equal(channel.id, "radio_12345678");
const memberCatalog = await request(address.port, "/presence/radio/channels", BOB_TOKEN);
const catalogJson = (await memberCatalog.json()) as {
channels: unknown[];
canManage: boolean;
};
assert.equal(catalogJson.canManage, false);
assert.equal(catalogJson.channels.length, 1);
const conflict = await request(address.port, `/presence/radio/channels/${channel.id}`, ALICE_TOKEN, {
method: "PUT",
body: JSON.stringify({ expectedRevision: 99, draft }),
});
assert.equal(conflict.status, 409);
const archiveChanged = nextJsonOfType(curatorSocket, "radio.catalog.changed");
const archived = await request(address.port, `/presence/radio/channels/${channel.id}/archive`, ALICE_TOKEN, {
method: "POST",
body: JSON.stringify({ expectedRevision: channel.revision }),
});
assert.equal(archived.status, 200);
await archiveChanged;
const archivedChannel = (await archived.json()) as {
channel: { revision: number };
};
const afterArchive = (await (await request(address.port, "/presence/radio/channels", BOB_TOKEN)).json()) as {
channels: unknown[];
};
assert.equal(afterArchive.channels.length, 0);
const restored = await request(address.port, `/presence/radio/channels/${channel.id}/restore`, ALICE_TOKEN, {
method: "POST",
body: JSON.stringify({
expectedRevision: archivedChannel.channel.revision,
}),
});
assert.equal(restored.status, 200);
const afterRestore = (await (await request(address.port, "/presence/radio/channels", BOB_TOKEN)).json()) as {
channels: unknown[];
};
assert.equal(afterRestore.channels.length, 1);
});
test("an empty radio curator allowlist keeps every mutation read-only", async (context) => {
const root = await mkdtemp(join(tmpdir(), "botsu-radio-readonly-"));
const service = createPresenceServer({
allowedOrigin: TEST_ORIGIN,
radioStore: new BotsuRadioStore({ root }),
radioCuratorIds: new Set(),
verifyOpenId: async () => ({
userId: "@alice:botsu.net",
displayName: "Alice",
}),
});
service.httpServer.listen(0, "127.0.0.1");
await once(service.httpServer, "listening");
context.after(() => service.close());
const address = service.httpServer.address();
assert.ok(address && typeof address === "object");
const catalog = await request(address.port, "/presence/radio/channels", ALICE_TOKEN);
assert.deepEqual(await catalog.json(), { channels: [], canManage: false });
const mutation = await request(address.port, "/presence/radio/channels", ALICE_TOKEN, {
method: "POST",
body: JSON.stringify(draft),
});
assert.equal(mutation.status, 403);
});
test("radio websocket broadcasts playback and counts unique Matrix listeners", async (context) => {
const root = await mkdtemp(join(tmpdir(), "botsu-radio-ws-"));
const radioStore = new BotsuRadioStore({
root,
createId: () => "radio_abcdefgh",
});
const channel = await radioStore.create({
creatorId: "@alice:botsu.net",
draft,
});
const service = createPresenceServer({
allowedOrigin: TEST_ORIGIN,
radioStore,
verifyOpenId: async ({ accessToken }) => ({
userId: accessToken === BOB_TOKEN ? "@bob:botsu.net" : "@alice:botsu.net",
displayName: accessToken === BOB_TOKEN ? "Bob" : "Alice",
}),
});
service.httpServer.listen(0, "127.0.0.1");
await once(service.httpServer, "listening");
context.after(() => service.close());
const address = service.httpServer.address();
assert.ok(address && typeof address === "object");
const connect = async (token: string) => {
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/ws`, {
origin: TEST_ORIGIN,
});
await authenticateSocket(socket, token);
context.after(() => socket.close());
return socket;
};
const aliceOne = await connect(ALICE_TOKEN);
const aliceTwo = await connect(ALICE_TOKEN);
const bob = await connect(BOB_TOKEN);
const firstPlayback = nextJsonOfType<{ snapshot: { listenerCount: number } }>(aliceOne, "radio.playback");
aliceOne.send(
JSON.stringify({
type: "radio.listen",
protocolVersion: 1,
channelId: channel.id,
})
);
assert.equal((await firstPlayback).snapshot.listenerCount, 1);
const duplicateAudience = nextJsonOfType<{ listenerCount: number }>(aliceOne, "radio.audience");
aliceTwo.send(
JSON.stringify({
type: "radio.listen",
protocolVersion: 1,
channelId: channel.id,
})
);
assert.equal((await duplicateAudience).listenerCount, 1);
const twoListeners = nextJsonOfType<{ listenerCount: number }>(aliceOne, "radio.audience");
bob.send(
JSON.stringify({
type: "radio.listen",
protocolVersion: 1,
channelId: channel.id,
})
);
assert.equal((await twoListeners).listenerCount, 2);
const afterDisconnect = nextJsonOfType<{ listenerCount: number }>(aliceOne, "radio.audience");
bob.close();
assert.equal((await afterDisconnect).listenerCount, 1);
const bobReconnected = await connect(BOB_TOKEN);
const resynchronized = nextJsonOfType<{
snapshot: { listenerCount: number };
}>(bobReconnected, "radio.playback");
bobReconnected.send(
JSON.stringify({
type: "radio.listen",
protocolVersion: 1,
channelId: channel.id,
})
);
assert.equal((await resynchronized).snapshot.listenerCount, 2);
});
+189
View File
@@ -0,0 +1,189 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { BotsuRadioSource } from "@botsu/protocol";
import { BotsuRadioStore, createDailyRadioOrder } from "./radio-store.ts";
const playlist: Extract<BotsuRadioSource, { kind: "audio_playlist" }> = {
kind: "audio_playlist",
tracks: [
{
id: "track_alpha",
title: "Alpha",
url: "https://media.botsu.net/alpha.mp3",
durationMs: 60_000,
},
{
id: "track_beta",
title: "Beta",
url: "https://media.botsu.net/beta.mp3",
durationMs: 90_000,
},
{
id: "track_gamma",
title: "Gamma",
url: "https://media.botsu.net/gamma.mp3",
durationMs: 120_000,
},
],
};
test("daily radio order is deterministic and contains each track once", () => {
const ids = playlist.tracks.map((track) => track.id);
const first = createDailyRadioOrder("radio_12345678", 2, "2026-08-10", ids);
const second = createDailyRadioOrder("radio_12345678", 2, "2026-08-10", ids);
assert.deepEqual(first, second);
assert.deepEqual([...first].sort(), [...ids].sort());
assert.notDeepEqual(first, createDailyRadioOrder("radio_12345678", 2, "2026-08-11", ids));
});
test("radio store preserves programming for metadata edits and restarts changed programs", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-radio-store-"));
let now = Date.UTC(2026, 7, 10, 20, 0, 0);
const store = new BotsuRadioStore({
root,
now: () => now,
createId: () => "radio_12345678",
});
const created = await store.create({
creatorId: "@alice:botsu.net",
draft: {
name: "Radio Test",
description: "Première version",
source: playlist,
},
});
const initial = await store.playback(created.id, 0);
now += 10_000;
const renamed = await store.update({
id: created.id,
expectedRevision: created.revision,
draft: {
name: "Radio Renommée",
description: "Nouveau texte",
source: playlist,
},
});
const afterRename = await store.playback(created.id, 0);
assert.equal(renamed.programRevision, created.programRevision);
assert.equal(afterRename.startedAtMs, initial.startedAtMs);
now += 10_000;
const changed = await store.update({
id: created.id,
expectedRevision: renamed.revision,
draft: {
name: renamed.name,
description: renamed.description,
source: { ...playlist, tracks: playlist.tracks.slice(0, 2) },
},
});
const afterChange = await store.playback(created.id, 0);
assert.equal(changed.programRevision, created.programRevision + 1);
assert.equal(afterChange.startedAtMs, now);
await assert.rejects(
store.update({
id: created.id,
expectedRevision: renamed.revision,
draft: { name: "Conflit", description: "", source: playlist },
}),
/conflict/i
);
});
test("daily shuffle waits for the current track and resumes after a restart", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-radio-clock-"));
let now = Date.UTC(2026, 7, 10, 21, 59, 30); // 23:59:30 Europe/Paris
const store = new BotsuRadioStore({
root,
now: () => now,
createId: () => "radio_abcdefgh",
});
const channel = await store.create({
creatorId: "@alice:botsu.net",
draft: {
name: "Radio Minuit",
description: "",
source: { ...playlist, tracks: playlist.tracks.slice(0, 2) },
},
});
const beforeMidnight = await store.playback(channel.id, 1);
now += 40_000; // 00:00:10; first track is still playing
const afterMidnight = await store.playback(channel.id, 1);
assert.equal(afterMidnight.item?.id, beforeMidnight.item?.id);
assert.equal(afterMidnight.startedAtMs, beforeMidnight.startedAtMs);
now = beforeMidnight.startedAtMs + (beforeMidnight.item?.durationMs ?? 0) + 5_000;
const afterTrack = await store.playback(channel.id, 1);
assert.notEqual(afterTrack.item?.id, beforeMidnight.item?.id);
assert.equal(afterTrack.positionMs, 5_000);
const restarted = new BotsuRadioStore({ root, now: () => now + 180_000 });
const recovered = await restarted.playback(channel.id, 1);
assert.equal(recovered.serverTimeMs, now + 180_000);
assert.ok(recovered.positionMs >= 0);
assert.ok(recovered.positionMs < (recovered.item?.durationMs ?? Number.MAX_SAFE_INTEGER));
});
test("radio clock catches up a long one-second-track outage without replaying every transition", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-radio-long-outage-"));
let now = Date.UTC(2025, 0, 1, 12, 0, 0);
const store = new BotsuRadioStore({
root,
now: () => now,
createId: () => "radio_longgap1",
});
const channel = await store.create({
creatorId: "@alice:botsu.net",
draft: {
name: "Radio Horloge",
description: "",
source: {
kind: "audio_playlist",
tracks: [
{
id: "track_tick",
title: "Tick",
url: "https://media.botsu.net/tick.mp3",
durationMs: 1_000,
},
],
},
},
});
now += 365 * 24 * 60 * 60 * 1_000 + 432;
const playback = await store.playback(channel.id, 0);
assert.equal(playback.positionMs, 432);
assert.equal(playback.nextTransitionAtMs, now + 568);
});
test("radio store archives reversibly and isolates corrupt channel files", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-radio-archive-"));
const store = new BotsuRadioStore({
root,
createId: () => "radio_12345678",
});
const channel = await store.create({
creatorId: "@alice:botsu.net",
draft: {
name: "Radio Live",
description: "",
source: { kind: "live_stream", url: "https://radio.botsu.net/live.mp3" },
},
});
const archived = await store.setArchived(channel.id, channel.revision, true);
assert.equal((await store.list(false)).length, 0);
assert.equal((await store.list(true)).length, 1);
const restored = await store.setArchived(channel.id, archived.revision, false);
assert.equal(restored.archived, false);
await mkdir(join(root, "channels"), { recursive: true });
await writeFile(join(root, "channels", "radio_corrupt1.json"), "{broken", "utf8");
assert.deepEqual(
(await store.list(true)).map(({ id }) => id),
[channel.id]
);
});
+400
View File
@@ -0,0 +1,400 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
MAXIMUM_RADIO_CHANNELS,
parseBotsuRadioChannel,
parseBotsuRadioChannelDraft,
type BotsuRadioChannel,
type BotsuRadioChannelDraft,
type BotsuRadioPlaybackSnapshot,
type BotsuRadioTrack,
} from "@botsu/protocol";
type RadioRuntime = {
programRevision: number;
orderDate: string;
order: string[];
index: number;
startedAtMs: number;
};
type StoredRadioChannel = {
channel: BotsuRadioChannel;
runtime: RadioRuntime | null;
};
type BotsuRadioStoreOptions = {
root: string;
now?: () => number;
createId?: () => string;
};
type CreateRadioInput = {
creatorId: string;
draft: BotsuRadioChannelDraft;
};
type UpdateRadioInput = {
id: string;
expectedRevision: number;
draft: BotsuRadioChannelDraft;
};
const PARIS_DATE_FORMAT = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Paris",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parisDateKey = (timestamp: number): string => {
const values = new Map(PARIS_DATE_FORMAT.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]));
return `${values.get("year")}-${values.get("month")}-${values.get("day")}`;
};
const createSeed = (channelId: string, programRevision: number, date: string): number =>
createHash("sha256").update(`${channelId}\0${programRevision}\0${date}`).digest().readUInt32BE(0);
export const createDailyRadioOrder = (
channelId: string,
programRevision: number,
date: string,
trackIds: readonly string[]
): string[] => {
const order = [...trackIds];
let state = createSeed(channelId, programRevision, date) || 0x9e3779b9;
const random = (): number => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return state >>> 0;
};
for (let index = order.length - 1; index > 0; index -= 1) {
const swap = random() % (index + 1);
[order[index], order[swap]] = [order[swap]!, order[index]!];
}
return order;
};
const tracksForChannel = (channel: BotsuRadioChannel): BotsuRadioTrack[] =>
channel.source.kind === "live_stream" ? [] : channel.source.tracks;
const createRuntime = (channel: BotsuRadioChannel, now: number): RadioRuntime | null => {
const tracks = tracksForChannel(channel);
if (tracks.length === 0) return null;
const orderDate = parisDateKey(now);
return {
programRevision: channel.programRevision,
orderDate,
order: createDailyRadioOrder(
channel.id,
channel.programRevision,
orderDate,
tracks.map((track) => track.id)
),
index: 0,
startedAtMs: now,
};
};
const parseRuntime = (value: unknown, channel: BotsuRadioChannel, now: number): RadioRuntime | null => {
if (channel.source.kind === "live_stream") return null;
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return createRuntime(channel, now);
}
const runtime = value as Record<string, unknown>;
const trackIds = new Set(channel.source.tracks.map((track) => track.id));
if (
runtime.programRevision !== channel.programRevision ||
typeof runtime.orderDate !== "string" ||
!/^\d{4}-\d{2}-\d{2}$/.test(runtime.orderDate) ||
!Array.isArray(runtime.order) ||
runtime.order.length !== trackIds.size ||
runtime.order.some((id) => typeof id !== "string" || !trackIds.has(id)) ||
new Set(runtime.order).size !== trackIds.size ||
typeof runtime.index !== "number" ||
!Number.isInteger(runtime.index) ||
runtime.index < 0 ||
runtime.index >= runtime.order.length ||
typeof runtime.startedAtMs !== "number" ||
!Number.isSafeInteger(runtime.startedAtMs) ||
runtime.startedAtMs < 0 ||
runtime.startedAtMs > now
) {
return createRuntime(channel, now);
}
return {
programRevision: runtime.programRevision,
orderDate: runtime.orderDate,
order: [...runtime.order],
index: runtime.index,
startedAtMs: runtime.startedAtMs,
} as RadioRuntime;
};
const parseStoredRadioChannel = (value: unknown, now: number): StoredRadioChannel => {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeError("Stored radio channel is invalid");
}
const record = value as Record<string, unknown>;
if (Reflect.ownKeys(record).some((key) => typeof key !== "string" || (key !== "channel" && key !== "runtime"))) {
throw new TypeError("Stored radio channel keys are invalid");
}
const channel = parseBotsuRadioChannel(record.channel);
return { channel, runtime: parseRuntime(record.runtime, channel, now) };
};
const writeJsonAtomic = async (path: string, value: unknown): Promise<void> => {
await mkdir(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
await rename(temporary, path);
};
const rotateAwayFromCurrent = (order: string[], currentId: string): string[] => {
if (order.length < 2 || order[0] !== currentId) return order;
return [...order.slice(1), order[0]!];
};
const nextParisDateBoundary = (timestamp: number): number => {
const currentDate = parisDateKey(timestamp);
let lower = timestamp;
let upper = timestamp + 36 * 60 * 60 * 1_000;
while (parisDateKey(upper) === currentDate) upper += 24 * 60 * 60 * 1_000;
while (upper - lower > 1) {
const middle = lower + Math.floor((upper - lower) / 2);
if (parisDateKey(middle) === currentDate) lower = middle;
else upper = middle;
}
return upper;
};
const advanceRuntime = (channel: BotsuRadioChannel, runtime: RadioRuntime, now: number): boolean => {
const tracks = channel.source.kind === "live_stream" ? [] : channel.source.tracks;
const byId = new Map(tracks.map((track) => [track.id, track]));
let changed = false;
while (true) {
if (runtime.index === 0 && runtime.order.length > 0) {
const cycleDurationMs = runtime.order.reduce((total, trackId) => {
const track = byId.get(trackId);
return total + (track?.durationMs ?? 0);
}, 0);
const boundary = nextParisDateBoundary(runtime.startedAtMs);
const latestSameDayMs = Math.min(now, boundary - 1);
const completeCycles =
cycleDurationMs > 0 ? Math.floor((latestSameDayMs - runtime.startedAtMs) / cycleDurationMs) : 0;
if (completeCycles > 0) {
runtime.startedAtMs += completeCycles * cycleDurationMs;
changed = true;
continue;
}
}
const currentId = runtime.order[runtime.index];
const current = currentId ? byId.get(currentId) : undefined;
if (!current) return changed;
const transitionAt = runtime.startedAtMs + current.durationMs;
if (transitionAt > now) return changed;
const nextDate = parisDateKey(transitionAt);
if (nextDate !== runtime.orderDate) {
runtime.order = rotateAwayFromCurrent(
createDailyRadioOrder(
channel.id,
channel.programRevision,
nextDate,
tracks.map((track) => track.id)
),
current.id
);
runtime.orderDate = nextDate;
runtime.index = 0;
} else {
runtime.index = (runtime.index + 1) % runtime.order.length;
}
runtime.startedAtMs = transitionAt;
changed = true;
}
};
export class BotsuRadioStore {
private readonly root: string;
private readonly now: () => number;
private readonly createId: () => string;
private mutationQueue: Promise<void> = Promise.resolve();
constructor(options: BotsuRadioStoreOptions) {
this.root = options.root;
this.now = options.now ?? Date.now;
this.createId = options.createId ?? (() => `radio_${randomUUID().replace(/-/g, "")}`);
}
private channelPath(id: string): string {
if (!/^radio_[a-z0-9_-]{8,64}$/i.test(id)) throw new TypeError("Radio channel id is invalid");
return join(this.root, "channels", `${id}.json`);
}
private async withMutation<Result>(operation: () => Promise<Result>): Promise<Result> {
const previous = this.mutationQueue;
let release: () => void = () => undefined;
this.mutationQueue = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await operation();
} finally {
release();
}
}
private async read(id: string): Promise<StoredRadioChannel | undefined> {
try {
const value = JSON.parse(await readFile(this.channelPath(id), "utf8")) as unknown;
return parseStoredRadioChannel(value, this.now());
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}
private async write(record: StoredRadioChannel): Promise<void> {
await writeJsonAtomic(this.channelPath(record.channel.id), record);
}
async list(includeArchived: boolean): Promise<BotsuRadioChannel[]> {
let names: string[] = [];
try {
names = (await readdir(join(this.root, "channels"))).filter((name) =>
/^radio_[a-z0-9_-]{8,64}\.json$/i.test(name)
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const channels: BotsuRadioChannel[] = [];
for (const name of names) {
try {
const value = JSON.parse(await readFile(join(this.root, "channels", name), "utf8")) as unknown;
const { channel } = parseStoredRadioChannel(value, this.now());
if (includeArchived || !channel.archived) channels.push(channel);
} catch {
// Corrupt channels remain isolated from the rest of the catalog.
}
}
return channels.sort((left, right) => left.name.localeCompare(right.name, "fr"));
}
async get(id: string): Promise<BotsuRadioChannel | undefined> {
return (await this.read(id))?.channel;
}
async create(input: CreateRadioInput): Promise<BotsuRadioChannel> {
return this.withMutation(async () => {
if ((await this.list(true)).length >= MAXIMUM_RADIO_CHANNELS) {
throw new Error("Radio channel capacity reached");
}
const now = this.now();
const draft = parseBotsuRadioChannelDraft(input.draft);
const channel = parseBotsuRadioChannel({
version: 1,
id: this.createId(),
...draft,
archived: false,
revision: 1,
programRevision: 1,
createdBy: input.creatorId,
createdAt: now,
updatedAt: now,
});
await this.write({ channel, runtime: createRuntime(channel, now) });
return channel;
});
}
async update(input: UpdateRadioInput): Promise<BotsuRadioChannel> {
return this.withMutation(async () => {
const stored = await this.read(input.id);
if (!stored) throw new Error("Radio channel not found");
if (stored.channel.revision !== input.expectedRevision) {
throw new Error("Radio channel conflict");
}
const now = this.now();
const draft = parseBotsuRadioChannelDraft(input.draft);
const programChanged = JSON.stringify(stored.channel.source) !== JSON.stringify(draft.source);
const channel = parseBotsuRadioChannel({
...stored.channel,
...draft,
revision: stored.channel.revision + 1,
programRevision: stored.channel.programRevision + (programChanged ? 1 : 0),
updatedAt: now,
});
const runtime = programChanged ? createRuntime(channel, now) : stored.runtime;
await this.write({ channel, runtime });
return channel;
});
}
async setArchived(id: string, expectedRevision: number, archived: boolean): Promise<BotsuRadioChannel> {
return this.withMutation(async () => {
const stored = await this.read(id);
if (!stored) throw new Error("Radio channel not found");
if (stored.channel.revision !== expectedRevision) throw new Error("Radio channel conflict");
const now = this.now();
const channel = parseBotsuRadioChannel({
...stored.channel,
archived,
revision: stored.channel.revision + 1,
updatedAt: now,
});
await this.write({
channel,
runtime: archived ? stored.runtime : createRuntime(channel, now),
});
return channel;
});
}
async playback(id: string, listenerCount: number): Promise<BotsuRadioPlaybackSnapshot> {
return this.withMutation(async () => {
const stored = await this.read(id);
if (!stored || stored.channel.archived) throw new Error("Radio channel not found");
const now = this.now();
const { channel } = stored;
if (channel.source.kind === "live_stream") {
return {
version: 1,
channelId: channel.id,
channelRevision: channel.revision,
programRevision: channel.programRevision,
sourceKind: "live_stream",
item: null,
startedAtMs: channel.createdAt,
positionMs: 0,
nextTransitionAtMs: null,
serverTimeMs: now,
listenerCount,
};
}
const runtime = stored.runtime ?? createRuntime(channel, now);
if (!runtime) throw new Error("Radio program is unavailable");
const changed = advanceRuntime(channel, runtime, now);
const itemId = runtime.order[runtime.index];
const item = channel.source.tracks.find((track) => track.id === itemId);
if (!item) throw new Error("Radio program is unavailable");
if (changed || stored.runtime !== runtime) await this.write({ channel, runtime });
return {
version: 1,
channelId: channel.id,
channelRevision: channel.revision,
programRevision: channel.programRevision,
sourceKind: channel.source.kind,
item,
startedAtMs: runtime.startedAtMs,
positionMs: now - runtime.startedAtMs,
nextTransitionAtMs: runtime.startedAtMs + item.durationMs,
serverTimeMs: now,
listenerCount,
};
});
}
}
+309 -1
View File
@@ -18,7 +18,9 @@ import {
parseCookieUpgradePurchase,
parseTcgBurnRequest,
parseTcgPaginationParams,
isValidMatrixUserId,
type PresenceOpenIdToken,
type BotsuRadioChannelDraft,
} from "@botsu/protocol";
import { WebSocket, WebSocketServer, type RawData } from "ws";
import {
@@ -42,6 +44,7 @@ import { loadTcgConfig, type TcgConfig } from "./tcg-config.ts";
import { BotsuMoodboardStore, type MoodboardDraft } from "./moodboard-store.ts";
import { searchSearxngImages } from "./moodboard-search.ts";
import { fetchPinterestPins } from "./moodboard-pinterest.ts";
import { BotsuRadioStore } from "./radio-store.ts";
const HEARTBEAT_INTERVAL_MS = 15_000;
const SESSION_TIMEOUT_MS = 45_000;
@@ -87,6 +90,8 @@ type PresenceServerOptions = {
moodboardStore?: BotsuMoodboardStore;
searchMoodboardImages?: (query: string) => Promise<unknown>;
getPinterestPins?: (sourceUrl: string) => Promise<unknown>;
radioStore?: BotsuRadioStore;
radioCuratorIds?: ReadonlySet<string>;
};
type SocketState = {
@@ -102,6 +107,8 @@ type SocketState = {
drawingSubscribedRoomId?: string;
drawingGeneration: number;
documentSubscriptions: Set<string>;
appId?: string;
radioChannelId?: string;
messageCount: number;
rateWindowStartedAt: number;
};
@@ -268,6 +275,19 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
baseUrl: process.env.BOTSU_SEARXNG_URL ?? "http://192.168.1.12:8081",
}));
const getPinterestPins = options.getPinterestPins ?? fetchPinterestPins;
const radioStore =
options.radioStore ??
new BotsuRadioStore({
root: process.env.BOTSU_RADIO_STORE_ROOT ?? "/data/botsu-radio",
});
const configuredRadioCuratorIds = (process.env.BOTSU_RADIO_CURATOR_IDS ?? "")
.split(",")
.map((userId) => userId.trim())
.filter(Boolean);
if (configuredRadioCuratorIds.some((userId) => !isValidMatrixUserId(userId))) {
throw new Error("BOTSU radio curator ids are invalid");
}
const radioCuratorIds = options.radioCuratorIds ?? new Set(configuredRadioCuratorIds);
// Load catalog index on startup (non-blocking)
void tcgCatalogStore.load().catch(() => undefined);
const canvasSnapshotBroadcastIntervalMs =
@@ -293,6 +313,127 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
let pixelCanvasTimer: ReturnType<typeof setTimeout> | undefined;
const pendingCanvasScopes = new Set<string>();
let canvasSnapshotTimer: ReturnType<typeof setTimeout> | undefined;
let radioTransitionTimer: ReturnType<typeof setTimeout> | undefined;
let radioScheduleGeneration = 0;
const getRadioListenerCount = (channelId: string): number => {
const userIds = new Set<string>();
sockets.forEach((candidate) => {
const candidateState = states.get(candidate);
if (candidateState?.radioChannelId === channelId && candidateState.userId) {
userIds.add(candidateState.userId);
}
});
return userIds.size;
};
const broadcastRadioAudience = (channelId: string): void => {
const message = {
type: "radio.audience",
protocolVersion: 1,
channelId,
listenerCount: getRadioListenerCount(channelId),
} as const;
sockets.forEach((candidate) => {
if (states.get(candidate)?.radioChannelId === channelId) {
sendPresenceJson(candidate, message);
}
});
};
const broadcastRadioCatalog = (): void => {
sockets.forEach((candidate) => {
const candidateState = states.get(candidate);
if (candidateState?.sessionId && candidateState.appId === "radio") {
sendPresenceJson(candidate, {
type: "radio.catalog.changed",
protocolVersion: 1,
});
}
});
};
const sendRadioPlayback = async (socket: WebSocket, channelId: string): Promise<void> => {
const snapshot = await radioStore.playback(channelId, getRadioListenerCount(channelId));
sendPresenceJson(socket, {
type: "radio.playback",
protocolVersion: 1,
snapshot,
});
};
const broadcastRadioPlayback = async (channelId: string): Promise<void> => {
const listeners = [...sockets].filter((candidate) => states.get(candidate)?.radioChannelId === channelId);
if (listeners.length === 0) {
await radioStore.playback(channelId, 0).catch(() => undefined);
return;
}
let snapshot;
try {
snapshot = await radioStore.playback(channelId, getRadioListenerCount(channelId));
} catch {
listeners.forEach((candidate) => {
const candidateState = states.get(candidate);
if (candidateState) delete candidateState.radioChannelId;
sendPresenceJson(candidate, {
type: "radio.error",
protocolVersion: 1,
code: "unavailable",
});
});
return;
}
listeners.forEach((candidate) => {
sendPresenceJson(candidate, {
type: "radio.playback",
protocolVersion: 1,
snapshot,
});
});
};
const stopRadioChannel = (channelId: string, code: "archived" | "unavailable"): void => {
sockets.forEach((candidate) => {
const candidateState = states.get(candidate);
if (candidateState?.radioChannelId !== channelId) return;
delete candidateState.radioChannelId;
sendPresenceJson(candidate, {
type: "radio.error",
protocolVersion: 1,
code,
});
});
};
const scheduleNextRadioTransition = async (): Promise<void> => {
radioScheduleGeneration += 1;
const generation = radioScheduleGeneration;
if (radioTransitionTimer !== undefined) clearTimeout(radioTransitionTimer);
radioTransitionTimer = undefined;
const channels = await radioStore.list(false).catch(() => []);
const snapshots = await Promise.all(
channels.map((channel) =>
radioStore.playback(channel.id, getRadioListenerCount(channel.id)).catch(() => undefined)
)
);
if (generation !== radioScheduleGeneration || !acceptingSocketMessages) return;
const transitionTimes = snapshots.flatMap((snapshot) =>
snapshot?.nextTransitionAtMs === null || snapshot?.nextTransitionAtMs === undefined
? []
: [{ channelId: snapshot.channelId, at: snapshot.nextTransitionAtMs }]
);
if (transitionTimes.length === 0) return;
const nextAt = Math.min(...transitionTimes.map(({ at }) => at));
const dueChannelIds = transitionTimes.filter(({ at }) => at === nextAt).map(({ channelId }) => channelId);
radioTransitionTimer = setTimeout(() => {
void Promise.all(dueChannelIds.map(broadcastRadioPlayback)).finally(() => {
void scheduleNextRadioTransition();
});
}, Math.max(nextAt - Date.now(), 0));
radioTransitionTimer.unref();
};
void scheduleNextRadioTransition();
const sendJsonResponse = (
response: ServerResponse,
@@ -947,6 +1088,111 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
return true;
};
const handleRadioHttpRequest = async (request: IncomingMessage, response: ServerResponse): Promise<boolean> => {
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
const radioPath = requestUrl.pathname.startsWith("/presence/radio")
? requestUrl.pathname.slice("/presence".length)
: requestUrl.pathname;
if (!radioPath.startsWith("/radio/")) return false;
let identity: VerifiedMatrixIdentity;
try {
identity = await authenticateHttpRequest(request);
} catch {
sendJsonResponse(response, 401, { error: "unauthorized" });
return true;
}
const canManage = radioCuratorIds.has(identity.userId);
try {
if (radioPath === "/radio/channels" && request.method === "GET") {
sendJsonResponse(response, 200, {
channels: await radioStore.list(canManage),
canManage,
});
return true;
}
if (radioPath === "/radio/channels" && request.method === "POST") {
if (!canManage) {
sendJsonResponse(response, 403, { error: "forbidden" });
return true;
}
const body = await readRequestJson(request);
const channel = await radioStore.create({
creatorId: identity.userId,
draft: body as unknown as BotsuRadioChannelDraft,
});
sendJsonResponse(response, 201, { channel });
broadcastRadioCatalog();
void scheduleNextRadioTransition();
return true;
}
const actionMatch = /^\/radio\/channels\/(radio_[a-z0-9_-]{8,64})\/(archive|restore)$/i.exec(radioPath);
if (actionMatch && request.method === "POST") {
if (!canManage) {
sendJsonResponse(response, 403, { error: "forbidden" });
return true;
}
const body = await readRequestJson(request);
const channel = await radioStore.setArchived(
actionMatch[1]!,
typeof body.expectedRevision === "number" ? body.expectedRevision : Number.NaN,
actionMatch[2] === "archive"
);
if (channel.archived) stopRadioChannel(channel.id, "archived");
sendJsonResponse(response, 200, { channel });
broadcastRadioCatalog();
void scheduleNextRadioTransition();
return true;
}
const channelMatch = /^\/radio\/channels\/(radio_[a-z0-9_-]{8,64})$/i.exec(radioPath);
if (channelMatch && request.method === "GET") {
const channel = await radioStore.get(channelMatch[1]!);
if (!channel || (channel.archived && !canManage)) {
sendJsonResponse(response, 404, { error: "not_found" });
return true;
}
const playback = channel.archived
? undefined
: await radioStore.playback(channel.id, getRadioListenerCount(channel.id));
sendJsonResponse(response, 200, { channel, playback, canManage });
return true;
}
if (channelMatch && request.method === "PUT") {
if (!canManage) {
sendJsonResponse(response, 403, { error: "forbidden" });
return true;
}
const body = await readRequestJson(request);
const channel = await radioStore.update({
id: channelMatch[1]!,
expectedRevision: typeof body.expectedRevision === "number" ? body.expectedRevision : Number.NaN,
draft: body.draft as BotsuRadioChannelDraft,
});
sendJsonResponse(response, 200, { channel });
broadcastRadioCatalog();
if (!channel.archived) void broadcastRadioPlayback(channel.id);
void scheduleNextRadioTransition();
return true;
}
sendJsonResponse(response, 405, { error: "method_not_allowed" });
return true;
} catch (error) {
const message = (error as Error).message;
if (/conflict/i.test(message)) {
sendJsonResponse(response, 409, { error: "conflict" });
} else if (/not found/i.test(message)) {
sendJsonResponse(response, 404, { error: "not_found" });
} else if (/capacity/i.test(message)) {
sendJsonResponse(response, 409, { error: "capacity" });
} else {
sendJsonResponse(response, 400, { error: "invalid_request" });
}
return true;
}
};
const httpServer: HttpServer = createServer((request, response) => {
if (!acceptingSocketMessages) {
response.writeHead(503, { "content-type": "application/json" });
@@ -969,6 +1215,8 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
if (cookieHandled) return;
const tcgHandled = await handleTcgHttpRequest(request, response);
if (tcgHandled) return;
const radioHandled = await handleRadioHttpRequest(request, response);
if (radioHandled) return;
response.writeHead(404, {
"content-type": "application/json",
"cache-control": "no-store",
@@ -1262,6 +1510,7 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
registry.connect({ sessionId, ...identity, appId: message.appId });
state.sessionId = sessionId;
state.userId = identity.userId;
state.appId = message.appId;
clearTimeout(authenticationTimer);
sendPresenceJson(socket, {
type: "presence.ready",
@@ -1311,6 +1560,49 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
});
return;
}
if (message.type === "radio.listen") {
if (!state.userId) throw new Error("Radio authentication is required");
const channel = await radioStore.get(message.channelId);
if (!channel) {
sendPresenceJson(socket, {
type: "radio.error",
protocolVersion: 1,
code: "not_found",
});
return;
}
if (channel.archived) {
sendPresenceJson(socket, {
type: "radio.error",
protocolVersion: 1,
code: "archived",
});
return;
}
const previousChannelId = state.radioChannelId;
state.radioChannelId = channel.id;
if (previousChannelId && previousChannelId !== channel.id) {
broadcastRadioAudience(previousChannelId);
}
try {
await sendRadioPlayback(socket, channel.id);
broadcastRadioAudience(channel.id);
} catch {
delete state.radioChannelId;
sendPresenceJson(socket, {
type: "radio.error",
protocolVersion: 1,
code: "unavailable",
});
}
return;
}
if (message.type === "radio.leave") {
const previousChannelId = state.radioChannelId;
delete state.radioChannelId;
if (previousChannelId) broadcastRadioAudience(previousChannelId);
return;
}
if (message.type === "presence.location") {
if (!state.joined) throw new Error("Presence join is required");
if (registry.location(state.sessionId, message.path))
@@ -1789,13 +2081,16 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
sockets.delete(socket);
canvasSnapshotQueue.delete(socket);
const socketState = states.get(socket);
const radioChannelId = socketState?.radioChannelId;
if (socketState) {
delete socketState.drawingScope;
delete socketState.drawingSubscribedScope;
delete socketState.radioChannelId;
}
const sessionId = states.get(socket)?.sessionId;
if (sessionId && registry.disconnect(sessionId)) scheduleSnapshot();
states.get(socket)?.documentSubscriptions.clear();
if (radioChannelId) broadcastRadioAudience(radioChannelId);
});
});
@@ -1808,12 +2103,16 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
if (!state?.sessionId || !expiredIds.has(state.sessionId)) return;
canvasSnapshotQueue.delete(socket);
state.canvasSubscriptionPending = false;
const radioChannelId = state.radioChannelId;
delete state.drawingScope;
delete state.drawingSubscribedScope;
delete state.sessionId;
delete state.userId;
delete state.appId;
delete state.radioChannelId;
state.documentSubscriptions.clear();
socket.close(1008, "Session expired");
if (radioChannelId) broadcastRadioAudience(radioChannelId);
});
scheduleSnapshot();
return expired;
@@ -1837,6 +2136,7 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
const close = async (): Promise<void> => {
acceptingSocketMessages = false;
radioScheduleGeneration += 1;
const serverClosures = [
new Promise<void>((resolve, reject) =>
webSocketServer.close((error) => error ? reject(error) : resolve())
@@ -1859,6 +2159,7 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
if (snapshotTimer !== undefined) clearTimeout(snapshotTimer);
if (pixelCanvasTimer !== undefined) clearTimeout(pixelCanvasTimer);
if (canvasSnapshotTimer !== undefined) clearTimeout(canvasSnapshotTimer);
if (radioTransitionTimer !== undefined) clearTimeout(radioTransitionTimer);
canvasSnapshotQueue.clear();
sockets.forEach((socket) => socket.close(1001, "Server shutting down"));
for (const session of godotDebugSessions.values()) {
@@ -1888,7 +2189,14 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
if (errors.length > 0) throw new AggregateError(errors, "Presence shutdown failed");
};
return { httpServer, close, registry, pixelCanvas: defaultPixelCanvas, expireStaleSessions };
return {
httpServer,
close,
registry,
pixelCanvas: defaultPixelCanvas,
radioStore,
expireStaleSessions,
};
};
const entrypoint = process.argv[1];
+38
View File
@@ -0,0 +1,38 @@
# Radio BOTSU
La Radio est une application globale du client authentifié. Son `RadioProvider`
reste monté au-dessus du routeur de pages : un flux audio direct continue donc
pendant la navigation. Le lecteur YouTube demeure dans le mini-player visible et
quitter ou fermer ce mini-player met fin à l'écoute YouTube.
## Sources V1
- web-radio accessible par une URL HTTPS directe ;
- playlist de fichiers audio HTTPS avec durée validée ;
- playlist de vidéos YouTube saisies manuellement avec durée mesurée dans un
lecteur visible.
Une chaîne n'emploie qu'un seul type de source. Spotify n'est pas accepté. Le
client ne propose aucune commande de piste : l'horloge du service de présence
décide de la piste et de la position communes.
## Horloge et persistance
Les chaînes et leur horloge sont écrites atomiquement dans
`BOTSU_RADIO_STORE_ROOT` (par défaut `.data/radio` avec `npm run dev`). À chaque
redémarrage, le service recalcule les transitions manquées. Une permutation
déterministe est préparée chaque jour selon `Europe/Paris`; elle n'entre en
vigueur qu'à la fin de la piste en cours.
## Autorisation locale
`BOTSU_RADIO_CURATOR_IDS` contient une liste d'identifiants Matrix séparés par
des virgules, par exemple :
```dotenv
BOTSU_RADIO_CURATOR_IDS=@alice:botsu.net,@bob:botsu.net
```
La liste vide rend le catalogue strictement lisible. Les requêtes HTTP et les
connexions WebSocket utilisent le jeton OpenID Matrix déjà fourni par le client;
aucun identifiant de source ou jeton Matrix n'est persisté dans une chaîne.
+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/drawing.test.ts src/matrix-workspace.test.ts src/document-object.test.ts src/cookies.test.ts src/watch-room.test.ts",
"test": "node --experimental-strip-types --test src/profile.test.ts src/presence.test.ts src/drawing.test.ts src/matrix-workspace.test.ts src/document-object.test.ts src/cookies.test.ts src/watch-room.test.ts src/radio.test.ts",
"typecheck": "tsc -p tsconfig.json"
},
"devDependencies": {
+32
View File
@@ -175,3 +175,35 @@ export {
type BotsuWatchSession,
type BotsuWatchSource,
} from "./watch-room.ts";
export {
BOTSU_RADIO_SCHEMA_VERSION,
MAXIMUM_RADIO_CHANNELS,
MAXIMUM_RADIO_DESCRIPTION_LENGTH,
MAXIMUM_RADIO_NAME_LENGTH,
MAXIMUM_RADIO_TRACKS,
MAXIMUM_RADIO_TRACK_DURATION_MS,
MAXIMUM_RADIO_TRACK_TITLE_LENGTH,
MAXIMUM_RADIO_URL_LENGTH,
parseBotsuRadioChannel,
parseBotsuRadioChannelDraft,
parseBotsuRadioPlaybackSnapshot,
parseBotsuRadioSource,
parseRadioClientMessage,
parseRadioServerMessage,
type BotsuRadioAudioTrack,
type BotsuRadioChannel,
type BotsuRadioChannelDraft,
type BotsuRadioPlaybackSnapshot,
type BotsuRadioSource,
type BotsuRadioTrack,
type BotsuRadioYoutubeTrack,
type RadioAudience,
type RadioCatalogChanged,
type RadioClientMessage,
type RadioLeave,
type RadioListen,
type RadioPlayback,
type RadioServerError,
type RadioServerMessage,
} from "./radio.ts";
+25 -2
View File
@@ -3,6 +3,12 @@ import {
isValidMatrixServerName,
isValidMatrixUserId,
} from "./profile.ts";
import {
parseRadioClientMessage,
parseRadioServerMessage,
type RadioClientMessage,
type RadioServerMessage,
} from "./radio.ts";
export const BOTSU_PRESENCE_PROTOCOL_VERSION = 1 as const;
export const MAXIMUM_PRESENCE_SERVER_MESSAGE_BYTES = 2 * 1024 * 1024;
@@ -97,7 +103,8 @@ export type PresenceClientMessage =
| PresenceMapViewport
| PixelCanvasSubscribe
| PixelCanvasUnsubscribe
| PixelCanvasPatch;
| PixelCanvasPatch
| RadioClientMessage;
export type PresenceReady = {
type: "presence.ready";
@@ -178,7 +185,8 @@ export type PresenceServerMessage =
| PresenceRemoteMapViewport
| PixelCanvasSnapshot
| PixelCanvasRemotePatch
| PresenceError;
| PresenceError
| RadioServerMessage;
type UnknownRecord = Record<string, unknown>;
@@ -586,6 +594,7 @@ export const parsePresenceClientMessage = (
"lng",
"zoom",
"pixels",
"channelId",
],
"PresenceClientMessage"
);
@@ -599,6 +608,9 @@ export const parsePresenceClientMessage = (
if (message.type === "canvas.unsubscribe")
return parseCanvasUnsubscribe(message);
if (message.type === "canvas.patch") return parseCanvasPatch(message);
if (message.type === "radio.listen" || message.type === "radio.leave") {
return parseRadioClientMessage(message);
}
throw new TypeError("PresenceClientMessage.type is unsupported");
};
@@ -999,6 +1011,9 @@ export const parsePresenceServerMessage = (
"revision",
"bits",
"pixels",
"snapshot",
"channelId",
"listenerCount",
],
"PresenceServerMessage"
);
@@ -1012,5 +1027,13 @@ export const parsePresenceServerMessage = (
if (message.type === "canvas.snapshot") return parseCanvasSnapshot(message);
if (message.type === "canvas.patch") return parseCanvasRemotePatch(message);
if (message.type === "presence.error") return parseError(message);
if (
message.type === "radio.playback" ||
message.type === "radio.audience" ||
message.type === "radio.catalog.changed" ||
message.type === "radio.error"
) {
return parseRadioServerMessage(message);
}
throw new TypeError("PresenceServerMessage.type is unsupported");
};
+216
View File
@@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseBotsuRadioChannel,
parseBotsuRadioChannelDraft,
parseBotsuRadioPlaybackSnapshot,
parseRadioClientMessage,
parseRadioServerMessage,
} from "./radio.ts";
import { parsePresenceClientMessage, parsePresenceServerMessage } from "./presence.ts";
const audioSource = {
kind: "audio_playlist",
tracks: [
{
id: "track_intro",
title: "Introduction",
url: "https://media.botsu.net/radio/intro.mp3",
durationMs: 61_000,
},
],
} as const;
test("radio channel drafts accept one bounded source type", () => {
assert.deepEqual(
parseBotsuRadioChannelDraft({
name: "BOTSU Nuit",
description: "Sélection nocturne.",
source: audioSource,
}),
{
name: "BOTSU Nuit",
description: "Sélection nocturne.",
source: audioSource,
}
);
assert.deepEqual(
parseBotsuRadioChannelDraft({
name: "BOTSU Live",
description: "",
source: { kind: "live_stream", url: "https://radio.botsu.net/live.mp3" },
}).source,
{ kind: "live_stream", url: "https://radio.botsu.net/live.mp3" }
);
assert.deepEqual(
parseBotsuRadioChannelDraft({
name: "BOTSU Vidéos",
description: "Une sélection vidéo.",
source: {
kind: "youtube_playlist",
tracks: [
{
id: "track_video",
title: "Vidéo test",
videoId: "dQw4w9WgXcQ",
durationMs: 213_000,
},
],
},
}).source,
{
kind: "youtube_playlist",
tracks: [
{
id: "track_video",
title: "Vidéo test",
videoId: "dQw4w9WgXcQ",
durationMs: 213_000,
},
],
}
);
});
test("radio sources reject Spotify, credentials, mixed tracks, and excess entries", () => {
assert.throws(
() =>
parseBotsuRadioChannelDraft({
name: "Spotify",
description: "",
source: {
kind: "live_stream",
url: "https://open.spotify.com/playlist/example",
},
}),
/Spotify/
);
assert.throws(
() =>
parseBotsuRadioChannelDraft({
name: "Secret",
description: "",
source: {
kind: "live_stream",
url: "https://user:secret@radio.botsu.net/live",
},
}),
/credentials/
);
assert.throws(
() =>
parseBotsuRadioChannelDraft({
name: "Mixte",
description: "",
source: {
kind: "youtube_playlist",
tracks: [{ ...audioSource.tracks[0], videoId: "dQw4w9WgXcQ" }],
},
}),
/keys|track/
);
assert.throws(
() =>
parseBotsuRadioChannelDraft({
name: "Trop longue",
description: "",
source: {
kind: "audio_playlist",
tracks: Array.from({ length: 101 }, (_, index) => ({
id: `track_${index}`,
title: `Piste ${index}`,
url: `https://media.botsu.net/${index}.mp3`,
durationMs: 10_000,
})),
},
}),
/100/
);
});
test("stored radio channels validate revisions and Matrix ownership", () => {
const channel = {
version: 1,
id: "radio_12345678",
name: "BOTSU Nuit",
description: "Sélection nocturne.",
source: audioSource,
archived: false,
revision: 3,
programRevision: 2,
createdBy: "@alice:botsu.net",
createdAt: 1_800_000_000_000,
updatedAt: 1_800_000_100_000,
} as const;
assert.deepEqual(parseBotsuRadioChannel(channel), channel);
assert.throws(() => parseBotsuRadioChannel({ ...channel, revision: 0 }), /revision/);
assert.throws(() => parseBotsuRadioChannel({ ...channel, createdBy: "alice" }), /createdBy/);
});
test("radio playback snapshots expose only the active item and listener count", () => {
const snapshot = {
version: 1,
channelId: "radio_12345678",
channelRevision: 3,
programRevision: 2,
sourceKind: "audio_playlist",
item: audioSource.tracks[0],
startedAtMs: 1_800_000_000_000,
positionMs: 12_000,
nextTransitionAtMs: 1_800_000_049_000,
serverTimeMs: 1_800_000_012_000,
listenerCount: 4,
} as const;
assert.deepEqual(parseBotsuRadioPlaybackSnapshot(snapshot), snapshot);
assert.throws(() => parseBotsuRadioPlaybackSnapshot({ ...snapshot, listenerCount: -1 }), /listenerCount/);
});
test("radio websocket messages are exact and versioned", () => {
assert.deepEqual(
parseRadioClientMessage({
type: "radio.listen",
protocolVersion: 1,
channelId: "radio_12345678",
}),
{ type: "radio.listen", protocolVersion: 1, channelId: "radio_12345678" }
);
assert.deepEqual(parseRadioClientMessage({ type: "radio.leave", protocolVersion: 1 }), {
type: "radio.leave",
protocolVersion: 1,
});
assert.deepEqual(
parseRadioServerMessage({
type: "radio.catalog.changed",
protocolVersion: 1,
}),
{ type: "radio.catalog.changed", protocolVersion: 1 }
);
assert.deepEqual(
parsePresenceClientMessage({
type: "radio.listen",
protocolVersion: 1,
channelId: "radio_12345678",
}),
{ type: "radio.listen", protocolVersion: 1, channelId: "radio_12345678" }
);
assert.deepEqual(
parsePresenceServerMessage({
type: "radio.catalog.changed",
protocolVersion: 1,
}),
{ type: "radio.catalog.changed", protocolVersion: 1 }
);
assert.throws(
() =>
parseRadioClientMessage({
type: "radio.listen",
protocolVersion: 1,
channelId: "radio_12345678",
userId: "@alice:botsu.net",
}),
/userId/
);
});
+461
View File
@@ -0,0 +1,461 @@
import { isValidMatrixUserId } from "./profile.ts";
export const BOTSU_RADIO_SCHEMA_VERSION = 1 as const;
export const MAXIMUM_RADIO_CHANNELS = 50;
export const MAXIMUM_RADIO_TRACKS = 100;
export const MAXIMUM_RADIO_NAME_LENGTH = 80;
export const MAXIMUM_RADIO_DESCRIPTION_LENGTH = 240;
export const MAXIMUM_RADIO_TRACK_TITLE_LENGTH = 120;
export const MAXIMUM_RADIO_URL_LENGTH = 2_048;
export const MAXIMUM_RADIO_TRACK_DURATION_MS = 6 * 60 * 60 * 1_000;
export const MAXIMUM_RADIO_LISTENERS = 10_000;
export type BotsuRadioAudioTrack = {
id: string;
title: string;
url: string;
durationMs: number;
};
export type BotsuRadioYoutubeTrack = {
id: string;
title: string;
videoId: string;
durationMs: number;
};
export type BotsuRadioTrack = BotsuRadioAudioTrack | BotsuRadioYoutubeTrack;
export type BotsuRadioSource =
| { kind: "live_stream"; url: string }
| { kind: "audio_playlist"; tracks: BotsuRadioAudioTrack[] }
| { kind: "youtube_playlist"; tracks: BotsuRadioYoutubeTrack[] };
export type BotsuRadioChannelDraft = {
name: string;
description: string;
source: BotsuRadioSource;
};
export type BotsuRadioChannel = BotsuRadioChannelDraft & {
version: 1;
id: string;
archived: boolean;
revision: number;
programRevision: number;
createdBy: string;
createdAt: number;
updatedAt: number;
};
export type BotsuRadioPlaybackSnapshot = {
version: 1;
channelId: string;
channelRevision: number;
programRevision: number;
sourceKind: BotsuRadioSource["kind"];
item: BotsuRadioTrack | null;
startedAtMs: number;
positionMs: number;
nextTransitionAtMs: number | null;
serverTimeMs: number;
listenerCount: number;
};
export type RadioListen = {
type: "radio.listen";
protocolVersion: 1;
channelId: string;
};
export type RadioLeave = {
type: "radio.leave";
protocolVersion: 1;
};
export type RadioClientMessage = RadioListen | RadioLeave;
export type RadioPlayback = {
type: "radio.playback";
protocolVersion: 1;
snapshot: BotsuRadioPlaybackSnapshot;
};
export type RadioAudience = {
type: "radio.audience";
protocolVersion: 1;
channelId: string;
listenerCount: number;
};
export type RadioCatalogChanged = {
type: "radio.catalog.changed";
protocolVersion: 1;
};
export type RadioServerError = {
type: "radio.error";
protocolVersion: 1;
code: "not_found" | "archived" | "unavailable";
};
export type RadioServerMessage = RadioPlayback | RadioAudience | RadioCatalogChanged | RadioServerError;
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}.${String(unexpected)} is not allowed`);
}
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 parseText = (value: unknown, field: string, minimum: number, maximum: number): string => {
if (
typeof value !== "string" ||
value.length < minimum ||
value.length > maximum ||
value !== value.trim() ||
/[\u0000-\u001f\u007f]/.test(value)
) {
throw new TypeError(`${field} is invalid`);
}
return value;
};
const parseRadioId = (value: unknown, field = "Radio channel id"): string => {
if (typeof value !== "string" || !/^radio_[a-z0-9_-]{8,64}$/i.test(value)) {
throw new TypeError(`${field} is invalid`);
}
return value;
};
const parseTrackId = (value: unknown, field: string): string => {
if (typeof value !== "string" || !/^track_[a-z0-9_-]{1,64}$/i.test(value)) {
throw new TypeError(`${field} is invalid`);
}
return value;
};
const parsePositiveRevision = (value: unknown, field: string): number => {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
throw new TypeError(`${field} is invalid`);
}
return value;
};
const parseTimestamp = (value: unknown, field: string): number => {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new TypeError(`${field} is invalid`);
}
return value;
};
const parseDuration = (value: unknown, field: string): number => {
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value < 1_000 ||
value > MAXIMUM_RADIO_TRACK_DURATION_MS
) {
throw new TypeError(`${field} is invalid`);
}
return value;
};
const parseHttpsUrl = (value: unknown, field: string): string => {
const raw = parseText(value, field, 1, MAXIMUM_RADIO_URL_LENGTH);
let url: URL;
try {
url = new URL(raw);
} catch {
throw new TypeError(`${field} is invalid`);
}
if (url.protocol !== "https:") throw new TypeError(`${field} must use HTTPS`);
if (url.username || url.password) throw new TypeError(`${field} must not contain credentials`);
const hostname = url.hostname.toLowerCase();
if (hostname === "spotify.com" || hostname.endsWith(".spotify.com")) {
throw new TypeError(`${field} cannot use Spotify`);
}
return url.href;
};
const parseAudioTrack = (value: unknown, field: string): BotsuRadioAudioTrack => {
const track = snapshotRecord(value, ["id", "title", "url", "durationMs"], field);
return {
id: parseTrackId(track.id, `${field}.id`),
title: parseText(track.title, `${field}.title`, 1, MAXIMUM_RADIO_TRACK_TITLE_LENGTH),
url: parseHttpsUrl(track.url, `${field}.url`),
durationMs: parseDuration(track.durationMs, `${field}.durationMs`),
};
};
const parseYoutubeTrack = (value: unknown, field: string): BotsuRadioYoutubeTrack => {
const track = snapshotRecord(value, ["id", "title", "videoId", "durationMs"], field);
if (typeof track.videoId !== "string" || !/^[A-Za-z0-9_-]{11}$/.test(track.videoId)) {
throw new TypeError(`${field}.videoId is invalid`);
}
return {
id: parseTrackId(track.id, `${field}.id`),
title: parseText(track.title, `${field}.title`, 1, MAXIMUM_RADIO_TRACK_TITLE_LENGTH),
videoId: track.videoId,
durationMs: parseDuration(track.durationMs, `${field}.durationMs`),
};
};
const parseTracks = <Track extends BotsuRadioTrack>(
value: unknown,
field: string,
parseTrack: (input: unknown, trackField: string) => Track
): Track[] => {
if (!Array.isArray(value) || value.length < 1 || value.length > MAXIMUM_RADIO_TRACKS) {
throw new TypeError(`${field} must contain between 1 and ${MAXIMUM_RADIO_TRACKS} tracks`);
}
const tracks = value.map((track, index) => parseTrack(track, `${field}[${index}]`));
if (new Set(tracks.map((track) => track.id)).size !== tracks.length) {
throw new TypeError(`${field} ids must be unique`);
}
return tracks;
};
export const parseBotsuRadioSource = (value: unknown): BotsuRadioSource => {
const source = snapshotRecord(value, ["kind", "url", "tracks"], "RadioSource");
if (source.kind === "live_stream") {
if (source.tracks !== undefined) throw new TypeError("RadioSource.tracks is not allowed");
return {
kind: "live_stream",
url: parseHttpsUrl(source.url, "RadioSource.url"),
};
}
if (source.kind === "audio_playlist") {
if (source.url !== undefined) throw new TypeError("RadioSource.url is not allowed");
return {
kind: "audio_playlist",
tracks: parseTracks(source.tracks, "RadioSource.tracks", parseAudioTrack),
};
}
if (source.kind === "youtube_playlist") {
if (source.url !== undefined) throw new TypeError("RadioSource.url is not allowed");
return {
kind: "youtube_playlist",
tracks: parseTracks(source.tracks, "RadioSource.tracks", parseYoutubeTrack),
};
}
throw new TypeError("RadioSource.kind is invalid");
};
export const parseBotsuRadioChannelDraft = (value: unknown): BotsuRadioChannelDraft => {
const draft = snapshotRecord(value, ["name", "description", "source"], "RadioChannelDraft");
return {
name: parseText(draft.name, "RadioChannelDraft.name", 1, MAXIMUM_RADIO_NAME_LENGTH),
description: parseText(draft.description, "RadioChannelDraft.description", 0, MAXIMUM_RADIO_DESCRIPTION_LENGTH),
source: parseBotsuRadioSource(draft.source),
};
};
export const parseBotsuRadioChannel = (value: unknown): BotsuRadioChannel => {
const channel = snapshotRecord(
value,
[
"version",
"id",
"name",
"description",
"source",
"archived",
"revision",
"programRevision",
"createdBy",
"createdAt",
"updatedAt",
],
"RadioChannel"
);
if (channel.version !== BOTSU_RADIO_SCHEMA_VERSION) {
throw new TypeError("RadioChannel.version must be 1");
}
if (typeof channel.archived !== "boolean") {
throw new TypeError("RadioChannel.archived is invalid");
}
if (typeof channel.createdBy !== "string" || !isValidMatrixUserId(channel.createdBy)) {
throw new TypeError("RadioChannel.createdBy is invalid");
}
const createdAt = parseTimestamp(channel.createdAt, "RadioChannel.createdAt");
const updatedAt = parseTimestamp(channel.updatedAt, "RadioChannel.updatedAt");
if (updatedAt < createdAt) throw new TypeError("RadioChannel.updatedAt is invalid");
return {
version: BOTSU_RADIO_SCHEMA_VERSION,
id: parseRadioId(channel.id),
...parseBotsuRadioChannelDraft({
name: channel.name,
description: channel.description,
source: channel.source,
}),
archived: channel.archived,
revision: parsePositiveRevision(channel.revision, "RadioChannel.revision"),
programRevision: parsePositiveRevision(channel.programRevision, "RadioChannel.programRevision"),
createdBy: channel.createdBy,
createdAt,
updatedAt,
};
};
export const parseBotsuRadioPlaybackSnapshot = (value: unknown): BotsuRadioPlaybackSnapshot => {
const snapshot = snapshotRecord(
value,
[
"version",
"channelId",
"channelRevision",
"programRevision",
"sourceKind",
"item",
"startedAtMs",
"positionMs",
"nextTransitionAtMs",
"serverTimeMs",
"listenerCount",
],
"RadioPlaybackSnapshot"
);
if (snapshot.version !== BOTSU_RADIO_SCHEMA_VERSION) {
throw new TypeError("RadioPlaybackSnapshot.version must be 1");
}
if (
snapshot.sourceKind !== "live_stream" &&
snapshot.sourceKind !== "audio_playlist" &&
snapshot.sourceKind !== "youtube_playlist"
) {
throw new TypeError("RadioPlaybackSnapshot.sourceKind is invalid");
}
let item: BotsuRadioTrack | null;
if (snapshot.sourceKind === "live_stream") {
if (snapshot.item !== null) throw new TypeError("RadioPlaybackSnapshot.item must be null");
item = null;
} else if (snapshot.sourceKind === "audio_playlist") {
item = parseAudioTrack(snapshot.item, "RadioPlaybackSnapshot.item");
} else {
item = parseYoutubeTrack(snapshot.item, "RadioPlaybackSnapshot.item");
}
const startedAtMs = parseTimestamp(snapshot.startedAtMs, "RadioPlaybackSnapshot.startedAtMs");
const serverTimeMs = parseTimestamp(snapshot.serverTimeMs, "RadioPlaybackSnapshot.serverTimeMs");
if (
typeof snapshot.positionMs !== "number" ||
!Number.isSafeInteger(snapshot.positionMs) ||
snapshot.positionMs < 0
) {
throw new TypeError("RadioPlaybackSnapshot.positionMs is invalid");
}
if (
snapshot.nextTransitionAtMs !== null &&
(typeof snapshot.nextTransitionAtMs !== "number" ||
!Number.isSafeInteger(snapshot.nextTransitionAtMs) ||
snapshot.nextTransitionAtMs <= startedAtMs)
) {
throw new TypeError("RadioPlaybackSnapshot.nextTransitionAtMs is invalid");
}
if (
typeof snapshot.listenerCount !== "number" ||
!Number.isSafeInteger(snapshot.listenerCount) ||
snapshot.listenerCount < 0 ||
snapshot.listenerCount > MAXIMUM_RADIO_LISTENERS
) {
throw new TypeError("RadioPlaybackSnapshot.listenerCount is invalid");
}
return {
version: BOTSU_RADIO_SCHEMA_VERSION,
channelId: parseRadioId(snapshot.channelId),
channelRevision: parsePositiveRevision(snapshot.channelRevision, "RadioPlaybackSnapshot.channelRevision"),
programRevision: parsePositiveRevision(snapshot.programRevision, "RadioPlaybackSnapshot.programRevision"),
sourceKind: snapshot.sourceKind,
item,
startedAtMs,
positionMs: snapshot.positionMs,
nextTransitionAtMs: snapshot.nextTransitionAtMs,
serverTimeMs,
listenerCount: snapshot.listenerCount,
};
};
export const parseRadioClientMessage = (value: unknown): RadioClientMessage => {
const message = snapshotRecord(value, ["type", "protocolVersion", "channelId"], "RadioClientMessage");
if (message.protocolVersion !== BOTSU_RADIO_SCHEMA_VERSION) {
throw new TypeError("RadioClientMessage.protocolVersion must be 1");
}
if (message.type === "radio.listen") {
return {
type: "radio.listen",
protocolVersion: 1,
channelId: parseRadioId(message.channelId),
};
}
if (message.type === "radio.leave") {
if (message.channelId !== undefined) {
throw new TypeError("RadioClientMessage.channelId is not allowed");
}
return { type: "radio.leave", protocolVersion: 1 };
}
throw new TypeError("RadioClientMessage.type is unsupported");
};
export const parseRadioServerMessage = (value: unknown): RadioServerMessage => {
const message = snapshotRecord(
value,
["type", "protocolVersion", "snapshot", "channelId", "listenerCount", "code"],
"RadioServerMessage"
);
if (message.protocolVersion !== BOTSU_RADIO_SCHEMA_VERSION) {
throw new TypeError("RadioServerMessage.protocolVersion must be 1");
}
if (message.type === "radio.playback") {
return {
type: "radio.playback",
protocolVersion: 1,
snapshot: parseBotsuRadioPlaybackSnapshot(message.snapshot),
};
}
if (message.type === "radio.audience") {
const listenerCount = message.listenerCount;
if (
typeof listenerCount !== "number" ||
!Number.isSafeInteger(listenerCount) ||
listenerCount < 0 ||
listenerCount > MAXIMUM_RADIO_LISTENERS
) {
throw new TypeError("RadioServerMessage.listenerCount is invalid");
}
return {
type: "radio.audience",
protocolVersion: 1,
channelId: parseRadioId(message.channelId),
listenerCount,
};
}
if (message.type === "radio.catalog.changed") {
return { type: "radio.catalog.changed", protocolVersion: 1 };
}
if (message.type === "radio.error") {
if (message.code !== "not_found" && message.code !== "archived" && message.code !== "unavailable") {
throw new TypeError("RadioServerMessage.code is invalid");
}
return { type: "radio.error", protocolVersion: 1, code: message.code };
}
throw new TypeError("RadioServerMessage.type is unsupported");
};
+1
View File
@@ -30,6 +30,7 @@ const pathDefaults = {
BOTSU_COOKIE_STORE_ROOT: '.data/cookies',
BOTSU_TCG_STORE_ROOT: '.data/tcg',
BOTSU_MOODBOARD_STORE_ROOT: '.data/moodboard',
BOTSU_RADIO_STORE_ROOT: '.data/radio',
TCG_CATALOG_INDEX_PATH: '.data/tcg/catalog-index.json',
EMOJI_KITCHEN_ROOT: '.data/emoji-kitchen/stickers',
};