feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,7 @@ export type AppWindowKind =
|
||||
| 'boot'
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'playlists'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
@@ -34,6 +35,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||
boot: { ru: 'Загрузка', en: 'Loading' },
|
||||
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
|
||||
materials: { ru: 'Материалы', en: 'Materials' },
|
||||
playlists: { ru: 'Плейлисты', en: 'Playlists' },
|
||||
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
||||
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
||||
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
||||
|
||||
@@ -20,6 +20,12 @@ import type {
|
||||
AppToken,
|
||||
AppPlayer,
|
||||
AppPlayerTeam,
|
||||
AppPlaylist,
|
||||
PlaylistAudio,
|
||||
PlaylistAudioId,
|
||||
PlaylistId,
|
||||
PlaylistTrackId,
|
||||
PlaylistsState,
|
||||
PlayerId,
|
||||
PlayerTeamId,
|
||||
PlayerImageOffset,
|
||||
@@ -149,6 +155,8 @@ export const ipcChannels = {
|
||||
sceneDescriptionContent: 'windows.sceneDescriptionContent',
|
||||
openMaterials: 'windows.openMaterials',
|
||||
closeMaterials: 'windows.closeMaterials',
|
||||
openPlaylists: 'windows.openPlaylists',
|
||||
closePlaylists: 'windows.closePlaylists',
|
||||
openNpcsEditor: 'windows.openNpcsEditor',
|
||||
closeNpcsEditor: 'windows.closeNpcsEditor',
|
||||
openNpcs: 'windows.openNpcs',
|
||||
@@ -243,6 +251,21 @@ export const ipcChannels = {
|
||||
upsertProgress: 'players.upsertProgress',
|
||||
stateChanged: 'players.stateChanged',
|
||||
},
|
||||
playlists: {
|
||||
getState: 'playlists.getState',
|
||||
create: 'playlists.create',
|
||||
rename: 'playlists.rename',
|
||||
duplicate: 'playlists.duplicate',
|
||||
delete: 'playlists.delete',
|
||||
setOrder: 'playlists.setOrder',
|
||||
setTracksOrder: 'playlists.setTracksOrder',
|
||||
addAudioFiles: 'playlists.addAudioFiles',
|
||||
pickAudioFiles: 'playlists.pickAudioFiles',
|
||||
removeTrack: 'playlists.removeTrack',
|
||||
renameAudio: 'playlists.renameAudio',
|
||||
audioUrl: 'playlists.audioUrl',
|
||||
stateChanged: 'playlists.stateChanged',
|
||||
},
|
||||
sceneNpcTokensSession: {
|
||||
getState: 'sceneNpcTokensSession.getState',
|
||||
dispatch: 'sceneNpcTokensSession.dispatch',
|
||||
@@ -342,6 +365,7 @@ export type IpcEventMap = {
|
||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
[ipcChannels.playlists.stateChanged]: PlaylistsState;
|
||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
|
||||
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
|
||||
@@ -667,6 +691,7 @@ export type IpcInvokeMap = {
|
||||
storylineSelections: StorylineSelection[];
|
||||
npcIds: string[];
|
||||
labels: StorylineLabels;
|
||||
playlistIds?: string[];
|
||||
};
|
||||
res: { canceled: true } | { canceled: false };
|
||||
};
|
||||
@@ -726,6 +751,14 @@ export type IpcInvokeMap = {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.openPlaylists]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.closePlaylists]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.openNpcsEditor]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
@@ -914,6 +947,54 @@ export type IpcInvokeMap = {
|
||||
req: { id: PlayerId };
|
||||
res: { url: string | null };
|
||||
};
|
||||
[ipcChannels.playlists.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: PlaylistsState;
|
||||
};
|
||||
[ipcChannels.playlists.create]: {
|
||||
req: { name?: string };
|
||||
res: { playlist: AppPlaylist; state: PlaylistsState };
|
||||
};
|
||||
[ipcChannels.playlists.rename]: {
|
||||
req: { id: PlaylistId; name: string };
|
||||
res: { playlist: AppPlaylist; state: PlaylistsState };
|
||||
};
|
||||
[ipcChannels.playlists.duplicate]: {
|
||||
req: { id: PlaylistId };
|
||||
res: { playlist: AppPlaylist; state: PlaylistsState };
|
||||
};
|
||||
[ipcChannels.playlists.delete]: {
|
||||
req: { id: PlaylistId };
|
||||
res: { state: PlaylistsState };
|
||||
};
|
||||
[ipcChannels.playlists.setOrder]: {
|
||||
req: { playlistIds: PlaylistId[] };
|
||||
res: { state: PlaylistsState };
|
||||
};
|
||||
[ipcChannels.playlists.setTracksOrder]: {
|
||||
req: { playlistId: PlaylistId; trackIds: PlaylistTrackId[] };
|
||||
res: { playlist: AppPlaylist; state: PlaylistsState };
|
||||
};
|
||||
[ipcChannels.playlists.addAudioFiles]: {
|
||||
req: { playlistId: PlaylistId; filePaths: string[] };
|
||||
res: { playlist: AppPlaylist; state: PlaylistsState };
|
||||
};
|
||||
[ipcChannels.playlists.pickAudioFiles]: {
|
||||
req: Record<string, never>;
|
||||
res: { filePaths: string[] };
|
||||
};
|
||||
[ipcChannels.playlists.removeTrack]: {
|
||||
req: { playlistId: PlaylistId; trackId: PlaylistTrackId };
|
||||
res: { playlist: AppPlaylist; state: PlaylistsState };
|
||||
};
|
||||
[ipcChannels.playlists.renameAudio]: {
|
||||
req: { audioId: PlaylistAudioId; title: string };
|
||||
res: { audio: PlaylistAudio; state: PlaylistsState };
|
||||
};
|
||||
[ipcChannels.playlists.audioUrl]: {
|
||||
req: { id: PlaylistAudioId };
|
||||
res: { url: string | null };
|
||||
};
|
||||
[ipcChannels.sceneNpcTokensSession.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: SceneNpcTokensSessionState };
|
||||
@@ -996,6 +1077,7 @@ export type LegacyIpcEventMap = {
|
||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||
[ipcChannels.playlists.stateChanged]: PlaylistsState;
|
||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
|
||||
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from './appPlayers';
|
||||
export * from './appPlaylists';
|
||||
export * from './appTokens';
|
||||
export * from './domain';
|
||||
export * from './effects';
|
||||
|
||||
Reference in New Issue
Block a user