feat(playlists): библиотека плейлистов приложения

CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-03 10:37:21 +08:00
parent cfb6e62331
commit 278eb78637
46 changed files with 2438 additions and 41 deletions
+87
View File
@@ -0,0 +1,87 @@
/** App-local библиотека плейлистов (userData, не в project zip по умолчанию). */
import {
asPlaylistAudioId,
asPlaylistId,
asPlaylistTrackId,
type PlaylistAudioId,
type PlaylistId,
type PlaylistTrackId,
} from './ids';
export type { PlaylistAudioId, PlaylistId, PlaylistTrackId };
export { asPlaylistAudioId, asPlaylistId, asPlaylistTrackId };
/** Физический аудиофайл в библиотеке (один файл — много вхождений в плейлистах). */
export type PlaylistAudio = {
id: PlaylistAudioId;
/** Отображаемое имя (общее для всех вхождений). */
title: string;
originalName: string;
/** Относительный путь внутри `userData/playlists/`. */
relPath: string;
sha256: string;
mime: string;
sizeBytes: number;
};
/** Вхождение трека в конкретный плейлист. */
export type PlaylistTrack = {
id: PlaylistTrackId;
audioId: PlaylistAudioId;
};
export type AppPlaylist = {
id: PlaylistId;
name: string;
tracks: PlaylistTrack[];
};
export type PlaylistsState = {
playlists: AppPlaylist[];
audios: PlaylistAudio[];
};
/** Снимок для упаковки в экспорт проекта / импорта. */
export type PlaylistsExportBundle = {
version: 1;
playlists: AppPlaylist[];
audios: PlaylistAudio[];
/** Имя файла ассета → байты читаются отдельно в zip. */
};
export function normalizePlaylistName(name: string): string {
return name.trim() || 'Playlist';
}
export function titleFromAudioFileName(fileName: string): string {
const base = fileName.replace(/\.[^.]+$/u, '').trim();
return base || fileName.trim() || 'Track';
}
export function filterPlaylistsByQuery(
state: PlaylistsState,
query: string,
): { playlists: AppPlaylist[]; matchedAudioIds: Set<PlaylistAudioId> } {
const q = query.trim().toLowerCase();
if (!q) {
return { playlists: state.playlists, matchedAudioIds: new Set() };
}
const audioById = new Map(state.audios.map((a) => [a.id, a]));
const matchedAudioIds = new Set<PlaylistAudioId>();
const playlists: AppPlaylist[] = [];
for (const pl of state.playlists) {
const nameHit = pl.name.toLowerCase().includes(q);
let trackHit = false;
for (const tr of pl.tracks) {
const audio = audioById.get(tr.audioId);
if (!audio) continue;
if (audio.title.toLowerCase().includes(q) || audio.originalName.toLowerCase().includes(q)) {
matchedAudioIds.add(audio.id);
trackHit = true;
}
}
if (nameHit || trackHit) playlists.push(pl);
}
return { playlists, matchedAudioIds };
}
+15
View File
@@ -13,6 +13,9 @@ export type SceneTokenId = Brand<string, 'SceneTokenId'>;
export type PlayerId = Brand<string, 'PlayerId'>;
export type PlayerTeamId = Brand<string, 'PlayerTeamId'>;
export type SceneNpcTokenId = Brand<string, 'SceneNpcTokenId'>;
export type PlaylistId = Brand<string, 'PlaylistId'>;
export type PlaylistAudioId = Brand<string, 'PlaylistAudioId'>;
export type PlaylistTrackId = Brand<string, 'PlaylistTrackId'>;
export function asProjectId(value: string): ProjectId {
return value as ProjectId;
@@ -65,3 +68,15 @@ export function asPlayerTeamId(value: string): PlayerTeamId {
export function asSceneNpcTokenId(value: string): SceneNpcTokenId {
return value as SceneNpcTokenId;
}
export function asPlaylistId(value: string): PlaylistId {
return value as PlaylistId;
}
export function asPlaylistAudioId(value: string): PlaylistAudioId {
return value as PlaylistAudioId;
}
export function asPlaylistTrackId(value: string): PlaylistTrackId {
return value as PlaylistTrackId;
}
+1
View File
@@ -1,4 +1,5 @@
export * from './appPlayers';
export * from './appPlaylists';
export * from './appTokens';
export * from './domain';
export * from './effects';