Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ba4f8157 | ||
|
|
7fb0ab1f3b | ||
|
|
9bb8899577 | ||
|
|
a660597791 |
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) });
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
};
|
||||
@@ -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 n’a encore été 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 l’analyse 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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 }),
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './BotsuRadioPage';
|
||||
export * from './RadioContext';
|
||||
export * from './RadioTab';
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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'/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', ''\)/);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user