soundboardPresentation.ts 6.1 Ko
Télécharger le fichier
import { ISoundboardPlayOptions, ISoundboardSound } from '@octane/renderer';
import { EMPTY_SOUNDBOARD_MANIFEST, SoundboardManifest } from './soundboardManifest';
export const SOUNDBOARD_TONES = ['blue', 'green', 'gold', 'purple'] as const;
export type SoundboardTone = (typeof SOUNDBOARD_TONES)[number];
export interface SoundboardCategory {
id: string;
label: string;
}
export interface SoundboardPresentationEntry {
id: number;
categoryId: string | null;
tone: SoundboardTone;
keywords: string[];
}
export interface SoundboardLayout {
categories: SoundboardCategory[];
sounds: SoundboardPresentationEntry[];
}
export interface DisplaySoundboardSound extends ISoundboardSound {
classname: string;
categoryId: string | null;
tone: SoundboardTone;
keywords: string[];
group?: string;
gain?: number;
}
/** What the sound manager needs to know about a pad besides where its file is. */
export const soundboardPlayOptions = (pad: { group?: string; gain?: number } | null | undefined): ISoundboardPlayOptions | undefined =>
pad ? { group: pad.group || undefined, gain: pad.gain } : undefined;
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const normalizeText = (value: string): string => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
export const normalizeSoundboardLayout = (input: unknown): SoundboardLayout => {
const source = isRecord(input) ? input : {};
const categories: SoundboardCategory[] = [];
const categoryIds = new Set<string>();
if (Array.isArray(source.categories)) {
for (const candidate of source.categories) {
if (!isRecord(candidate) || typeof candidate.id !== 'string' || typeof candidate.label !== 'string') continue;
const id = candidate.id.trim();
const label = candidate.label.trim();
if (!id || !label || id === 'all' || id === 'recent' || id === 'favorites' || categoryIds.has(id)) continue;
categoryIds.add(id);
categories.push({ id, label });
}
}
const sounds: SoundboardPresentationEntry[] = [];
const soundIds = new Set<number>();
if (Array.isArray(source.sounds)) {
for (const candidate of source.sounds) {
if (!isRecord(candidate) || typeof candidate.id !== 'number' || !Number.isInteger(candidate.id) || candidate.id <= 0 || soundIds.has(candidate.id)) continue;
soundIds.add(candidate.id);
const rawCategory = typeof candidate.category === 'string' ? candidate.category.trim() : '';
const tone = typeof candidate.tone === 'string' && (SOUNDBOARD_TONES as readonly string[]).includes(candidate.tone)
? candidate.tone as SoundboardTone
: 'blue';
const keywords = Array.isArray(candidate.keywords)
? [...new Set(candidate.keywords
.filter((keyword): keyword is string => typeof keyword === 'string')
.map((keyword) => keyword.trim())
.filter(Boolean)
.filter((keyword, index, values) => values.findIndex((value) => normalizeText(value) === normalizeText(keyword)) === index))]
: [];
sounds.push({
id: candidate.id,
categoryId: categoryIds.has(rawCategory) ? rawCategory : null,
tone,
keywords
});
}
}
return { categories, sounds };
};
/**
* Presentation comes from two places, in this order:
*
* 1. the asset manifest, keyed by classname — the pad's own metadata, which
* travels with the file and survives a database reseed;
* 2. `soundboard-layout.jsonc`, keyed by the numeric database id — the hotel's
* explicit override, which therefore wins.
*
* Keeping the id-keyed layout working is deliberate: existing installs have
* one, and their ids are whatever their emulator assigned.
*/
export const mergeSoundboardPresentation = (
serverSounds: ISoundboardSound[],
layout: SoundboardLayout,
manifest: SoundboardManifest = EMPTY_SOUNDBOARD_MANIFEST
): DisplaySoundboardSound[] => {
const presentationById = new Map(layout.sounds.map((sound) => [sound.id, sound]));
return serverSounds.map((sound) => {
const override = presentationById.get(sound.id);
const classname = sound.classname?.trim().toLowerCase() ?? '';
const asset = classname ? manifest.byClassname.get(classname) : null;
return {
id: sound.id,
name: sound.name || asset?.name || '',
url: sound.url,
classname,
categoryId: override?.categoryId ?? asset?.categoryId ?? null,
tone: override?.tone ?? asset?.tone ?? 'blue',
keywords: override?.keywords?.length ? override.keywords : (asset?.keywords ?? []),
group: asset?.group ?? '',
gain: asset?.gain ?? 1
};
});
};
export const filterSoundboardSounds = (
sounds: DisplaySoundboardSound[],
query: string,
categoryId: string,
recentIds: number[],
favoriteIds: number[] = []
): DisplaySoundboardSound[] => {
const normalizedQuery = normalizeText(query.trim());
const matchesQuery = (sound: DisplaySoundboardSound) => !normalizedQuery ||
normalizeText([sound.name, ...sound.keywords].join(' ')).includes(normalizedQuery);
if (categoryId === 'recent' || categoryId === 'favorites') {
const soundsById = new Map(sounds.map((sound) => [sound.id, sound]));
return (categoryId === 'recent' ? recentIds : favoriteIds).map((id) => soundsById.get(id)).filter((sound): sound is DisplaySoundboardSound => !!sound && matchesQuery(sound));
}
return sounds.filter((sound) => (categoryId === 'all' || sound.categoryId === categoryId) && matchesQuery(sound));
};
export const pushRecentSound = (recentIds: number[], soundId: number, limit = 10): number[] => {
if (!Number.isInteger(soundId) || soundId <= 0 || limit <= 0) return recentIds.slice(0, Math.max(0, limit));
return [soundId, ...recentIds.filter((id) => id !== soundId)].slice(0, limit);
};
