feat(audio): volume в проекте, DnD, параллельные плейлисты
Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -379,7 +379,7 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
const assetId = await resolveAndImport(ctx, manifest, audioPath);
|
const assetId = await resolveAndImport(ctx, manifest, audioPath);
|
||||||
if (!assetId) continue;
|
if (!assetId) continue;
|
||||||
if (ctx.assets[assetId]?.type !== 'audio') continue;
|
if (ctx.assets[assetId]?.type !== 'audio') continue;
|
||||||
audioRefs.push({ assetId, autoplay: true, loop: true });
|
audioRefs.push({ assetId, autoplay: true, loop: true, volume: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
scenes[sceneId] = {
|
scenes[sceneId] = {
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
|
|||||||
previewThumbAssetId: 'th' as AssetId,
|
previewThumbAssetId: 'th' as AssetId,
|
||||||
media: {
|
media: {
|
||||||
videos: ['v1' as AssetId],
|
videos: ['v1' as AssetId],
|
||||||
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }],
|
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true, volume: 1 }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
|
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true, volume: 1 }],
|
||||||
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
|
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
|
||||||
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
|
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
|
||||||
} as unknown as Project;
|
} as unknown as Project;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ void test('readProjectJsonFromZip: campaignAudios round-trips inside project.jso
|
|||||||
},
|
},
|
||||||
scenes: {},
|
scenes: {},
|
||||||
assets: {},
|
assets: {},
|
||||||
campaignAudios: [{ assetId, autoplay: true, loop: false }],
|
campaignAudios: [{ assetId, autoplay: true, loop: false, volume: 1 }],
|
||||||
currentSceneId: null,
|
currentSceneId: null,
|
||||||
currentGraphNodeId: null,
|
currentGraphNodeId: null,
|
||||||
sceneGraphNodes: [],
|
sceneGraphNodes: [],
|
||||||
|
|||||||
@@ -45,12 +45,13 @@ import type {
|
|||||||
ProjectNpcGroup,
|
ProjectNpcGroup,
|
||||||
ProjectNpcRelation,
|
ProjectNpcRelation,
|
||||||
Scene,
|
Scene,
|
||||||
|
SceneAudioRef,
|
||||||
SceneGraphEdge,
|
SceneGraphEdge,
|
||||||
SceneGraphNode,
|
SceneGraphNode,
|
||||||
SceneId,
|
SceneId,
|
||||||
SceneTrap,
|
SceneTrap,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||||
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
||||||
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
||||||
import {
|
import {
|
||||||
@@ -817,7 +818,14 @@ export class ZipProjectStore {
|
|||||||
: null),
|
: null),
|
||||||
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
|
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
|
||||||
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
...(patch.media
|
||||||
|
? {
|
||||||
|
media: {
|
||||||
|
videos: patch.media.videos ?? base.media.videos,
|
||||||
|
audios: (patch.media.audios ?? base.media.audios).map((a) => normalizeSceneAudioRef(a)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null),
|
||||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1180,8 +1188,9 @@ export class ZipProjectStore {
|
|||||||
else
|
else
|
||||||
media.audios.push({
|
media.audios.push({
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
autoplay: sc.settings.autoplayAudio,
|
autoplay: false,
|
||||||
loop: sc.settings.loopAudio,
|
loop: false,
|
||||||
|
volume: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1232,7 +1241,7 @@ export class ZipProjectStore {
|
|||||||
for (const asset of staged) {
|
for (const asset of staged) {
|
||||||
assets[asset.id] = asset;
|
assets[asset.id] = asset;
|
||||||
if (asset.type !== 'audio') continue;
|
if (asset.type !== 'audio') continue;
|
||||||
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false });
|
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false, volume: 1 });
|
||||||
}
|
}
|
||||||
return { ...p, assets, campaignAudios };
|
return { ...p, assets, campaignAudios };
|
||||||
});
|
});
|
||||||
@@ -1245,7 +1254,10 @@ export class ZipProjectStore {
|
|||||||
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
|
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
|
||||||
const open = this.openProject;
|
const open = this.openProject;
|
||||||
if (!open) throw new Error('No open project');
|
if (!open) throw new Error('No open project');
|
||||||
await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] }));
|
const normalized = (Array.isArray(audios) ? audios : [])
|
||||||
|
.filter((a) => a && typeof a === 'object' && a.assetId)
|
||||||
|
.map((a) => normalizeSceneAudioRef(a));
|
||||||
|
await this.updateProject((p) => ({ ...p, campaignAudios: normalized }));
|
||||||
const latest = this.getOpenProject();
|
const latest = this.getOpenProject();
|
||||||
if (!latest) throw new Error('No open project');
|
if (!latest) throw new Error('No open project');
|
||||||
return latest;
|
return latest;
|
||||||
@@ -2624,20 +2636,26 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
const audios = rawAudios
|
const audios = rawAudios
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
if (typeof a === 'string') {
|
if (typeof a === 'string') {
|
||||||
return {
|
return normalizeSceneAudioRef({
|
||||||
assetId: a as AssetId,
|
assetId: a as AssetId,
|
||||||
autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio),
|
autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio),
|
||||||
loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio),
|
loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio),
|
||||||
};
|
volume: 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (a && typeof a === 'object') {
|
if (a && typeof a === 'object') {
|
||||||
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
|
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
|
||||||
if (!obj.assetId) return null;
|
if (!obj.assetId) return null;
|
||||||
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
|
return normalizeSceneAudioRef({
|
||||||
|
assetId: obj.assetId,
|
||||||
|
autoplay: Boolean(obj.autoplay),
|
||||||
|
loop: Boolean(obj.loop),
|
||||||
|
volume: obj.volume,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
|
.filter((x): x is SceneAudioRef => Boolean(x));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
@@ -2682,15 +2700,20 @@ function normalizeProject(p: Project): Project {
|
|||||||
const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
|
const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
|
||||||
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
|
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false };
|
if (typeof a === 'string') return normalizeSceneAudioRef({ assetId: a as AssetId, autoplay: false, loop: false, volume: 1 });
|
||||||
if (a && typeof a === 'object') {
|
if (a && typeof a === 'object') {
|
||||||
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
|
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
|
||||||
if (!obj.assetId) return null;
|
if (!obj.assetId) return null;
|
||||||
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
|
return normalizeSceneAudioRef({
|
||||||
|
assetId: obj.assetId,
|
||||||
|
autoplay: Boolean(obj.autoplay),
|
||||||
|
loop: Boolean(obj.loop),
|
||||||
|
volume: obj.volume,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
|
.filter((x): x is SceneAudioRef => Boolean(x));
|
||||||
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
|
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
|
||||||
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
|
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
|
|||||||
@@ -107,15 +107,30 @@ function clampAudioGain(v: number): number {
|
|||||||
return Math.max(0, Math.min(1, v));
|
return Math.max(0, Math.min(1, v));
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAudioGain(gains: Map<string, number>, assetId: string): number {
|
function readAudioGain(gains: Map<string, number>, assetId: string, projectDefault = 1): number {
|
||||||
return gains.get(assetId) ?? 1;
|
return gains.get(assetId) ?? clampAudioGain(projectDefault);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */
|
/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */
|
||||||
function applyAudioGain(el: HTMLAudioElement, gains: Map<string, number>, assetId: string, factor = 1): void {
|
function applyAudioGain(
|
||||||
|
el: HTMLAudioElement,
|
||||||
|
gains: Map<string, number>,
|
||||||
|
assetId: string,
|
||||||
|
factor = 1,
|
||||||
|
projectDefault = 1,
|
||||||
|
): void {
|
||||||
|
if (!gains.has(assetId)) gains.set(assetId, clampAudioGain(projectDefault));
|
||||||
el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor);
|
el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedAudioGainFromRef(
|
||||||
|
gains: Map<string, number>,
|
||||||
|
assetId: string,
|
||||||
|
projectVolume: number | undefined,
|
||||||
|
): void {
|
||||||
|
if (!gains.has(assetId)) gains.set(assetId, clampAudioGain(projectVolume ?? 1));
|
||||||
|
}
|
||||||
|
|
||||||
/** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */
|
/** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */
|
||||||
function lightningEffectSoundUrl(): string {
|
function lightningEffectSoundUrl(): string {
|
||||||
return new URL('molniya.mp3', window.location.href).href;
|
return new URL('molniya.mp3', window.location.href).href;
|
||||||
@@ -615,6 +630,7 @@ export function ControlApp() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||||||
for (const item of sceneAudioRefs) {
|
for (const item of sceneAudioRefs) {
|
||||||
|
seedAudioGainFromRef(sceneAudioGainRef.current, item.assetId, item.volume);
|
||||||
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
||||||
if (sceneAudioLoadRunRef.current !== runId) return;
|
if (sceneAudioLoadRunRef.current !== runId) return;
|
||||||
if (!r.url) continue;
|
if (!r.url) continue;
|
||||||
@@ -622,7 +638,7 @@ export function ControlApp() {
|
|||||||
el.loop = item.loop;
|
el.loop = item.loop;
|
||||||
el.preload = 'auto';
|
el.preload = 'auto';
|
||||||
if (item.autoplay) el.volume = 0;
|
if (item.autoplay) el.volume = 0;
|
||||||
else applyAudioGain(el, sceneAudioGainRef.current, item.assetId);
|
else applyAudioGain(el, sceneAudioGainRef.current, item.assetId, 1, item.volume);
|
||||||
sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||||||
el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1));
|
el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1));
|
||||||
el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1));
|
el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1));
|
||||||
@@ -725,6 +741,7 @@ export function ControlApp() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||||||
for (const item of campaignAudioRefs) {
|
for (const item of campaignAudioRefs) {
|
||||||
|
seedAudioGainFromRef(campaignAudioGainRef.current, item.assetId, item.volume);
|
||||||
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
||||||
if (campaignAudioLoadRunRef.current !== runId) return;
|
if (campaignAudioLoadRunRef.current !== runId) return;
|
||||||
if (!r.url) continue;
|
if (!r.url) continue;
|
||||||
@@ -732,7 +749,7 @@ export function ControlApp() {
|
|||||||
el.loop = item.loop;
|
el.loop = item.loop;
|
||||||
el.preload = 'auto';
|
el.preload = 'auto';
|
||||||
if (item.autoplay) el.volume = 0;
|
if (item.autoplay) el.volume = 0;
|
||||||
else applyAudioGain(el, campaignAudioGainRef.current, item.assetId);
|
else applyAudioGain(el, campaignAudioGainRef.current, item.assetId, 1, item.volume);
|
||||||
campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||||||
el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1));
|
el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1));
|
||||||
el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1));
|
el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1));
|
||||||
@@ -3111,7 +3128,7 @@ export function ControlApp() {
|
|||||||
statusLabel={st.label}
|
statusLabel={st.label}
|
||||||
{...(st.detail ? { statusDetail: st.detail } : {})}
|
{...(st.detail ? { statusDetail: st.detail } : {})}
|
||||||
audioEl={el}
|
audioEl={el}
|
||||||
initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId)}
|
initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId, ref.volume)}
|
||||||
gainMap={sceneAudioGainRef.current}
|
gainMap={sceneAudioGainRef.current}
|
||||||
playTitle={t('control.transportPlay')}
|
playTitle={t('control.transportPlay')}
|
||||||
playLabel={t('control.transportPlay')}
|
playLabel={t('control.transportPlay')}
|
||||||
@@ -3189,7 +3206,7 @@ export function ControlApp() {
|
|||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
audioEl={el}
|
audioEl={el}
|
||||||
initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId)}
|
initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId, ref.volume)}
|
||||||
gainMap={campaignAudioGainRef.current}
|
gainMap={campaignAudioGainRef.current}
|
||||||
playTitle={
|
playTitle={
|
||||||
!allowCampaignAudio ? t('control.pauseCampaignTitle') : t('control.transportPlay')
|
!allowCampaignAudio ? t('control.pauseCampaignTitle') : t('control.transportPlay')
|
||||||
|
|||||||
@@ -1110,7 +1110,7 @@
|
|||||||
.audioList {
|
.audioList {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
max-height: 160px;
|
max-height: 280px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,10 +1120,32 @@
|
|||||||
border-radius: var(--radius-xs);
|
border-radius: var(--radius-xs);
|
||||||
background: var(--color-overlay-dark-3);
|
background: var(--color-overlay-dark-3);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioDragHandle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioDragHandle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioDragGrip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audioName {
|
.audioName {
|
||||||
@@ -1135,13 +1157,35 @@
|
|||||||
|
|
||||||
.audioControls {
|
.audioControls {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audioVolumeLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioVolumeRange {
|
||||||
|
width: 72px;
|
||||||
|
accent-color: var(--accent, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioPreviewBtn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.audioRemove {
|
.audioRemove {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import { AppLogo } from '../shared/branding/AppLogo';
|
import { AppLogo } from '../shared/branding/AppLogo';
|
||||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||||
|
import { stopAllEditorAudio } from '../shared/audio/editorAudioStop';
|
||||||
import { getDndApi } from '../shared/dndApi';
|
import { getDndApi } from '../shared/dndApi';
|
||||||
import { RotatedImage } from '../shared/RotatedImage';
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
import { Button, Input } from '../shared/ui/controls';
|
import { Button, Input } from '../shared/ui/controls';
|
||||||
@@ -38,6 +39,7 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
|
|||||||
|
|
||||||
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
|
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
|
||||||
import styles from './EditorApp.module.css';
|
import styles from './EditorApp.module.css';
|
||||||
|
import { EditorAudioTrackList } from './EditorAudioTrackList';
|
||||||
import {
|
import {
|
||||||
filterAudioFilePaths,
|
filterAudioFilePaths,
|
||||||
partitionSceneMediaDrops,
|
partitionSceneMediaDrops,
|
||||||
@@ -366,6 +368,7 @@ export function EditorApp() {
|
|||||||
const launchFromGraphNode = useCallback(
|
const launchFromGraphNode = useCallback(
|
||||||
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
||||||
if (!licenseActive || launching) return;
|
if (!licenseActive || launching) return;
|
||||||
|
stopAllEditorAudio();
|
||||||
setLaunching(true);
|
setLaunching(true);
|
||||||
setRunMenuOpen(false);
|
setRunMenuOpen(false);
|
||||||
setLaunchPlayersOpen(false);
|
setLaunchPlayersOpen(false);
|
||||||
@@ -508,6 +511,7 @@ export function EditorApp() {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
||||||
|
if (open) stopAllEditorAudio();
|
||||||
setPresentationOpen(open);
|
setPresentationOpen(open);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -2531,7 +2535,6 @@ function CampaignInspector({
|
|||||||
onDropAudio,
|
onDropAudio,
|
||||||
}: CampaignInspectorProps) {
|
}: CampaignInspectorProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
|
|
||||||
const audioDrop = useFileDropZone({
|
const audioDrop = useFileDropZone({
|
||||||
onDropPaths: onDropAudio,
|
onDropPaths: onDropAudio,
|
||||||
filterPaths: filterAudioFilePaths,
|
filterPaths: filterAudioFilePaths,
|
||||||
@@ -2547,71 +2550,19 @@ function CampaignInspector({
|
|||||||
onDrop={audioDrop.onDrop}
|
onDrop={audioDrop.onDrop}
|
||||||
>
|
>
|
||||||
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
{audioRefs.length === 0 ? (
|
||||||
<div className={styles.audioDropEmpty}>
|
<div className={styles.audioDropEmpty}>
|
||||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||||
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={styles.audioList}>
|
<EditorAudioTrackList
|
||||||
{mediaAssets
|
audioRefs={audioRefs}
|
||||||
.filter((a) => a.type === 'audio')
|
mediaAssets={mediaAssets}
|
||||||
.map((a) => (
|
removeTitle={t('campaign.removeTitle')}
|
||||||
<div key={a.id} className={styles.audioRow}>
|
onAudioRefsChange={onAudioRefsChange}
|
||||||
<span className={styles.audioName}>{a.originalName}</span>
|
/>
|
||||||
<span className={styles.audioControls}>
|
|
||||||
<label className={styles.checkboxLabelSm}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={audioById.get(a.id)?.autoplay ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = audioRefs.map((x) =>
|
|
||||||
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
|
|
||||||
);
|
|
||||||
onAudioRefsChange(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className={styles.spanXs}>{t('campaign.auto')}</span>
|
|
||||||
</label>
|
|
||||||
<label className={styles.checkboxLabelSm}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={audioById.get(a.id)?.loop ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = audioRefs.map((x) =>
|
|
||||||
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
|
|
||||||
);
|
|
||||||
onAudioRefsChange(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className={styles.spanXs}>{t('campaign.loop')}</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t('campaign.removeTitle')}
|
|
||||||
className={styles.audioRemove}
|
|
||||||
onClick={() => {
|
|
||||||
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={styles.audioRemoveIcon}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -2651,7 +2602,6 @@ function SceneInspector({
|
|||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const [descriptionModalOpen, setDescriptionModalOpen] = useState(false);
|
const [descriptionModalOpen, setDescriptionModalOpen] = useState(false);
|
||||||
const previewUrl = useAssetUrl(previewAssetId);
|
const previewUrl = useAssetUrl(previewAssetId);
|
||||||
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
|
|
||||||
const descriptionEmpty = isSceneDescriptionEmpty(description);
|
const descriptionEmpty = isSceneDescriptionEmpty(description);
|
||||||
const descriptionPreviewHtml = useMemo(
|
const descriptionPreviewHtml = useMemo(
|
||||||
() => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)),
|
() => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)),
|
||||||
@@ -2835,71 +2785,19 @@ function SceneInspector({
|
|||||||
onDrop={sceneAudioDrop.onDrop}
|
onDrop={sceneAudioDrop.onDrop}
|
||||||
>
|
>
|
||||||
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
{audioRefs.length === 0 ? (
|
||||||
<div className={styles.audioDropEmpty}>
|
<div className={styles.audioDropEmpty}>
|
||||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||||
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={styles.audioList}>
|
<EditorAudioTrackList
|
||||||
{mediaAssets
|
audioRefs={audioRefs}
|
||||||
.filter((a) => a.type === 'audio')
|
mediaAssets={mediaAssets}
|
||||||
.map((a) => (
|
removeTitle={t('scene.removeTitle')}
|
||||||
<div key={a.id} className={styles.audioRow}>
|
onAudioRefsChange={onAudioRefsChange}
|
||||||
<span className={styles.audioName}>{a.originalName}</span>
|
/>
|
||||||
<span className={styles.audioControls}>
|
|
||||||
<label className={styles.checkboxLabelSm}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={audioById.get(a.id)?.autoplay ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = audioRefs.map((x) =>
|
|
||||||
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
|
|
||||||
);
|
|
||||||
onAudioRefsChange(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className={styles.spanXs}>{t('campaign.auto')}</span>
|
|
||||||
</label>
|
|
||||||
<label className={styles.checkboxLabelSm}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={audioById.get(a.id)?.loop ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = audioRefs.map((x) =>
|
|
||||||
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
|
|
||||||
);
|
|
||||||
onAudioRefsChange(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className={styles.spanXs}>{t('campaign.loop')}</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t('scene.removeTitle')}
|
|
||||||
className={styles.audioRemove}
|
|
||||||
onClick={() => {
|
|
||||||
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={styles.audioRemoveIcon}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import {
|
||||||
|
clampSceneAudioVolume,
|
||||||
|
type AssetId,
|
||||||
|
type MediaAsset,
|
||||||
|
type SceneAudioRef,
|
||||||
|
} from '../../shared/types';
|
||||||
|
import { getDndApi } from '../shared/dndApi';
|
||||||
|
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||||
|
import { registerEditorAudioStopper } from '../shared/audio/editorAudioStop';
|
||||||
|
|
||||||
|
import styles from './EditorApp.module.css';
|
||||||
|
|
||||||
|
const AUDIO_REF_DND = 'application/x-dnd-scene-audio-ref';
|
||||||
|
|
||||||
|
function reorderRefs(refs: SceneAudioRef[], draggedId: AssetId, targetId: AssetId): SceneAudioRef[] {
|
||||||
|
if (draggedId === targetId) return refs;
|
||||||
|
const from = refs.findIndex((r) => r.assetId === draggedId);
|
||||||
|
const to = refs.findIndex((r) => r.assetId === targetId);
|
||||||
|
if (from < 0 || to < 0) return refs;
|
||||||
|
const next = [...refs];
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
if (!item) return refs;
|
||||||
|
next.splice(to, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Локальный draft — не пишет в проект на каждый tick ползунка (иначе мигает граф). */
|
||||||
|
function AudioVolumeSlider({
|
||||||
|
assetId,
|
||||||
|
volume,
|
||||||
|
isPlaying,
|
||||||
|
previewElRef,
|
||||||
|
label,
|
||||||
|
onCommit,
|
||||||
|
}: {
|
||||||
|
assetId: AssetId;
|
||||||
|
volume: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
previewElRef: React.MutableRefObject<HTMLAudioElement | null>;
|
||||||
|
label: string;
|
||||||
|
onCommit: (volume: number) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(() => clampSceneAudioVolume(volume));
|
||||||
|
const draftRef = useRef(draft);
|
||||||
|
draftRef.current = draft;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(clampSceneAudioVolume(volume));
|
||||||
|
}, [assetId, volume]);
|
||||||
|
|
||||||
|
const applyLocal = (v: number) => {
|
||||||
|
const next = clampSceneAudioVolume(v);
|
||||||
|
setDraft(next);
|
||||||
|
if (isPlaying && previewElRef.current) previewElRef.current.volume = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
const next = clampSceneAudioVolume(draftRef.current);
|
||||||
|
if (next === clampSceneAudioVolume(volume)) return;
|
||||||
|
onCommit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={styles.audioVolumeLabel} title={label}>
|
||||||
|
<span className={styles.spanXs}>{label}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={styles.audioVolumeRange}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={draft}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(e) => applyLocal(Number(e.target.value))}
|
||||||
|
onPointerUp={commit}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') {
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditorAudioTrackListProps = {
|
||||||
|
audioRefs: SceneAudioRef[];
|
||||||
|
mediaAssets: MediaAsset[];
|
||||||
|
removeTitle: string;
|
||||||
|
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditorAudioTrackList({
|
||||||
|
audioRefs,
|
||||||
|
mediaAssets,
|
||||||
|
removeTitle,
|
||||||
|
onAudioRefsChange,
|
||||||
|
}: EditorAudioTrackListProps) {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const assetById = useMemo(() => new Map(mediaAssets.map((a) => [a.id, a])), [mediaAssets]);
|
||||||
|
const [playingId, setPlayingId] = useState<AssetId | null>(null);
|
||||||
|
const previewElRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const el = previewElRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.pause();
|
||||||
|
el.src = '';
|
||||||
|
previewElRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return registerEditorAudioStopper(() => {
|
||||||
|
const el = previewElRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.pause();
|
||||||
|
el.onended = null;
|
||||||
|
el.src = '';
|
||||||
|
}
|
||||||
|
previewElRef.current = null;
|
||||||
|
setPlayingId(null);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopPreview = () => {
|
||||||
|
const el = previewElRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.pause();
|
||||||
|
el.onended = null;
|
||||||
|
el.src = '';
|
||||||
|
}
|
||||||
|
previewElRef.current = null;
|
||||||
|
setPlayingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePreview = async (assetId: AssetId, volume: number) => {
|
||||||
|
if (playingId === assetId) {
|
||||||
|
stopPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPreview();
|
||||||
|
try {
|
||||||
|
const r = await getDndApi().invoke(ipcChannels.project.assetFileUrl, { assetId });
|
||||||
|
const url = r.url;
|
||||||
|
if (!url) return;
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.volume = clampSceneAudioVolume(volume);
|
||||||
|
el.onended = () => {
|
||||||
|
previewElRef.current = null;
|
||||||
|
setPlayingId(null);
|
||||||
|
};
|
||||||
|
previewElRef.current = el;
|
||||||
|
setPlayingId(assetId);
|
||||||
|
await el.play();
|
||||||
|
} catch {
|
||||||
|
stopPreview();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (audioRefs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.audioList}>
|
||||||
|
{audioRefs.map((ref) => {
|
||||||
|
const asset = assetById.get(ref.assetId);
|
||||||
|
const name = asset?.originalName ?? ref.assetId;
|
||||||
|
const volume = clampSceneAudioVolume(ref.volume);
|
||||||
|
const isPlaying = playingId === ref.assetId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ref.assetId}
|
||||||
|
className={styles.audioRow}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (e.dataTransfer.types.includes(AUDIO_REF_DND)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const dragged = e.dataTransfer.getData(AUDIO_REF_DND) as AssetId;
|
||||||
|
if (!dragged) return;
|
||||||
|
const next = reorderRefs(audioRefs, dragged, ref.assetId);
|
||||||
|
if (next !== audioRefs) onAudioRefsChange(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.audioDragHandle}
|
||||||
|
draggable
|
||||||
|
title={name}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData(AUDIO_REF_DND, ref.assetId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.audioDragGrip} aria-hidden>
|
||||||
|
⋮⋮
|
||||||
|
</span>
|
||||||
|
<span className={styles.audioName}>{name}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.audioControls}>
|
||||||
|
<label className={styles.checkboxLabelSm}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ref.autoplay}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = audioRefs.map((x) =>
|
||||||
|
x.assetId === ref.assetId ? { ...x, autoplay: e.target.checked } : x,
|
||||||
|
);
|
||||||
|
onAudioRefsChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className={styles.spanXs}>{t('campaign.auto')}</span>
|
||||||
|
</label>
|
||||||
|
<label className={styles.checkboxLabelSm}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ref.loop}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = audioRefs.map((x) =>
|
||||||
|
x.assetId === ref.assetId ? { ...x, loop: e.target.checked } : x,
|
||||||
|
);
|
||||||
|
onAudioRefsChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className={styles.spanXs}>{t('campaign.loop')}</span>
|
||||||
|
</label>
|
||||||
|
<AudioVolumeSlider
|
||||||
|
assetId={ref.assetId}
|
||||||
|
volume={volume}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
previewElRef={previewElRef}
|
||||||
|
label={t('campaign.volume')}
|
||||||
|
onCommit={(v) => {
|
||||||
|
const next = audioRefs.map((x) =>
|
||||||
|
x.assetId === ref.assetId ? { ...x, volume: v } : x,
|
||||||
|
);
|
||||||
|
onAudioRefsChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.audioPreviewBtn}
|
||||||
|
title={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
|
||||||
|
aria-label={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
|
||||||
|
onClick={() => void togglePreview(ref.assetId, volume)}
|
||||||
|
>
|
||||||
|
{isPlaying ? '■' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={removeTitle}
|
||||||
|
className={styles.audioRemove}
|
||||||
|
onClick={() => {
|
||||||
|
if (playingId === ref.assetId) stopPreview();
|
||||||
|
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== ref.assetId));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={styles.audioRemoveIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import styles from './EditorApp.module.css';
|
|||||||
import {
|
import {
|
||||||
filterMaterialImagePaths,
|
filterMaterialImagePaths,
|
||||||
getDroppedFileEntries,
|
getDroppedFileEntries,
|
||||||
|
nameFromFilePath,
|
||||||
pickFirstMaterialImagePath,
|
pickFirstMaterialImagePath,
|
||||||
useFileDropZone,
|
useFileDropZone,
|
||||||
} from './fileDrop';
|
} from './fileDrop';
|
||||||
@@ -86,6 +87,11 @@ export function MaterialEditModal({
|
|||||||
|
|
||||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||||
setFilePath(path);
|
setFilePath(path);
|
||||||
|
setName((prev) => {
|
||||||
|
if (prev.trim()) return prev;
|
||||||
|
const fromFile = nameFromFilePath(path);
|
||||||
|
return fromFile || prev;
|
||||||
|
});
|
||||||
setLocalPreviewUrl((prev) => {
|
setLocalPreviewUrl((prev) => {
|
||||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||||
return previewUrl;
|
return previewUrl;
|
||||||
|
|||||||
@@ -69,10 +69,14 @@ export function isPreviewMediaPath(filePath: string): boolean {
|
|||||||
return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
|
return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sceneTitleFromMediaPath(filePath: string): string {
|
export function nameFromFilePath(filePath: string): string {
|
||||||
const base = filePath.split(/[/\\]/).pop() ?? filePath;
|
const base = filePath.split(/[/\\]/).pop() ?? filePath;
|
||||||
const dot = base.lastIndexOf('.');
|
const dot = base.lastIndexOf('.');
|
||||||
const title = (dot > 0 ? base.slice(0, dot) : base).trim();
|
return (dot > 0 ? base.slice(0, dot) : base).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sceneTitleFromMediaPath(filePath: string): string {
|
||||||
|
const title = nameFromFilePath(filePath);
|
||||||
return title.length > 0 ? title : 'Новая сцена';
|
return title.length > 0 ? title : 'Новая сцена';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ void test('buildNextSceneCardById: does not change refs when irrelevant fields c
|
|||||||
id: sid,
|
id: sid,
|
||||||
title: 'T',
|
title: 'T',
|
||||||
description: 'A',
|
description: 'A',
|
||||||
media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true }] },
|
media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true, volume: 1 }] },
|
||||||
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
||||||
connections: [],
|
connections: [],
|
||||||
layout: { x: 0, y: 0 },
|
layout: { x: 0, y: 0 },
|
||||||
|
|||||||
@@ -411,6 +411,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'campaign.noFiles': 'Файлов пока нет. Добавьте аудио.',
|
'campaign.noFiles': 'Файлов пока нет. Добавьте аудио.',
|
||||||
'campaign.auto': 'Авто',
|
'campaign.auto': 'Авто',
|
||||||
'campaign.loop': 'Цикл',
|
'campaign.loop': 'Цикл',
|
||||||
|
'campaign.volume': 'Громк.',
|
||||||
|
'campaign.preview': 'Прослушать',
|
||||||
|
'campaign.previewStop': 'Стоп',
|
||||||
'campaign.removeTitle': 'Убрать из кампании',
|
'campaign.removeTitle': 'Убрать из кампании',
|
||||||
'campaign.upload': 'Загрузить',
|
'campaign.upload': 'Загрузить',
|
||||||
'drop.hintAudio': 'Перетащите аудиофайлы сюда',
|
'drop.hintAudio': 'Перетащите аудиофайлы сюда',
|
||||||
@@ -1154,6 +1157,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'campaign.noFiles': 'No files yet. Add audio.',
|
'campaign.noFiles': 'No files yet. Add audio.',
|
||||||
'campaign.auto': 'Auto',
|
'campaign.auto': 'Auto',
|
||||||
'campaign.loop': 'Loop',
|
'campaign.loop': 'Loop',
|
||||||
|
'campaign.volume': 'Vol.',
|
||||||
|
'campaign.preview': 'Preview',
|
||||||
|
'campaign.previewStop': 'Stop',
|
||||||
'campaign.removeTitle': 'Remove from campaign',
|
'campaign.removeTitle': 'Remove from campaign',
|
||||||
'campaign.upload': 'Upload',
|
'campaign.upload': 'Upload',
|
||||||
'drop.hintAudio': 'Drop audio files here',
|
'drop.hintAudio': 'Drop audio files here',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import editorStyles from '../editor/EditorApp.module.css';
|
|||||||
import {
|
import {
|
||||||
filterMaterialImagePaths,
|
filterMaterialImagePaths,
|
||||||
getDroppedFileEntries,
|
getDroppedFileEntries,
|
||||||
|
nameFromFilePath,
|
||||||
pickFirstMaterialImagePath,
|
pickFirstMaterialImagePath,
|
||||||
useFileDropZone,
|
useFileDropZone,
|
||||||
} from '../editor/fileDrop';
|
} from '../editor/fileDrop';
|
||||||
@@ -111,6 +112,11 @@ export function NpcEditModal({
|
|||||||
|
|
||||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||||
setFilePath(path);
|
setFilePath(path);
|
||||||
|
setName((prev) => {
|
||||||
|
if (prev.trim()) return prev;
|
||||||
|
const fromFile = nameFromFilePath(path);
|
||||||
|
return fromFile || prev;
|
||||||
|
});
|
||||||
setLocalPreviewUrl((prev) => {
|
setLocalPreviewUrl((prev) => {
|
||||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||||
return previewUrl;
|
return previewUrl;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { AppToken } from '../../shared/types';
|
|||||||
import {
|
import {
|
||||||
filterMaterialImagePaths,
|
filterMaterialImagePaths,
|
||||||
getDroppedFileEntries,
|
getDroppedFileEntries,
|
||||||
|
nameFromFilePath,
|
||||||
pickFirstMaterialImagePath,
|
pickFirstMaterialImagePath,
|
||||||
useFileDropZone,
|
useFileDropZone,
|
||||||
} from '../editor/fileDrop';
|
} from '../editor/fileDrop';
|
||||||
@@ -56,6 +57,11 @@ export function TokenEditModal({ open, initial, existingNames, onClose, onSaved
|
|||||||
|
|
||||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||||
setFilePath(path);
|
setFilePath(path);
|
||||||
|
setName((prev) => {
|
||||||
|
if (prev.trim()) return prev;
|
||||||
|
const fromFile = nameFromFilePath(path);
|
||||||
|
return fromFile || prev;
|
||||||
|
});
|
||||||
setLocalPreviewUrl((prev) => {
|
setLocalPreviewUrl((prev) => {
|
||||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||||
return previewUrl || null;
|
return previewUrl || null;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** Регистрация стопперов превью/плееров редактора — глушим при запуске игры. */
|
||||||
|
|
||||||
|
const stoppers = new Set<() => void>();
|
||||||
|
|
||||||
|
export function registerEditorAudioStopper(stop: () => void): () => void {
|
||||||
|
stoppers.add(stop);
|
||||||
|
return () => {
|
||||||
|
stoppers.delete(stop);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopAllEditorAudio(): void {
|
||||||
|
for (const stop of [...stoppers]) {
|
||||||
|
try {
|
||||||
|
stop();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,16 @@
|
|||||||
border: 1px solid var(--scene-list-selected-border);
|
border: 1px solid var(--scene-list-selected-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlistRowPlaying {
|
||||||
|
box-shadow: inset 2px 0 0 var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlistPlayingMark {
|
||||||
|
color: var(--accent2);
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.trackRowMatch {
|
.trackRowMatch {
|
||||||
outline: 1px dashed var(--accent2);
|
outline: 1px dashed var(--accent2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { filterAudioFilePaths, useFileDropZone } from '../../editor/fileDrop';
|
|||||||
import editorStyles from '../../editor/EditorApp.module.css';
|
import editorStyles from '../../editor/EditorApp.module.css';
|
||||||
import { useEditorI18n } from '../../editor/i18n/EditorI18nContext';
|
import { useEditorI18n } from '../../editor/i18n/EditorI18nContext';
|
||||||
import { getDndApi } from '../dndApi';
|
import { getDndApi } from '../dndApi';
|
||||||
|
import { registerEditorAudioStopper } from '../audio/editorAudioStop';
|
||||||
import { Button, Input } from '../ui/controls';
|
import { Button, Input } from '../ui/controls';
|
||||||
|
|
||||||
import { PlaylistPlayerBar } from './PlaylistPlayerBar';
|
import { PlaylistPlayerBar } from './PlaylistPlayerBar';
|
||||||
@@ -81,37 +82,54 @@ export function PlaylistsPanel({
|
|||||||
}
|
}
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
const elsRef = useRef<Map<PlaylistId, HTMLAudioElement>>(new Map());
|
||||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
const queuesRef = useRef<Map<PlaylistId, number>>(new Map());
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playerUi, setPlayerUi] = useState<
|
||||||
const [gain, setGain] = useState(1);
|
Record<PlaylistId, { index: number; playing: boolean; gain: number }>
|
||||||
const [queuePlaylistId, setQueuePlaylistId] = useState<PlaylistId | null>(null);
|
>({});
|
||||||
const [queueIndex, setQueueIndex] = useState(0);
|
const playerUiRef = useRef(playerUi);
|
||||||
const queueRef = useRef<{ playlistId: PlaylistId; index: number } | null>(null);
|
playerUiRef.current = playerUi;
|
||||||
|
const playAtRef = useRef<(playlist: AppPlaylist, index: number) => Promise<void>>(async () => undefined);
|
||||||
|
const statePlaylistsRef = useRef(state.playlists);
|
||||||
|
statePlaylistsRef.current = state.playlists;
|
||||||
|
|
||||||
const queuePlaylist = useMemo(
|
const bumpPlayer = useCallback((playlistId: PlaylistId, patch: Partial<{ index: number; playing: boolean; gain: number }>) => {
|
||||||
() => (queuePlaylistId ? state.playlists.find((p) => p.id === queuePlaylistId) ?? null : null),
|
setPlayerUi((prev) => {
|
||||||
[queuePlaylistId, state.playlists],
|
const cur = prev[playlistId] ?? { index: 0, playing: false, gain: 1 };
|
||||||
);
|
return { ...prev, [playlistId]: { ...cur, ...patch } };
|
||||||
const currentTrack = queuePlaylist?.tracks[queueIndex] ?? null;
|
});
|
||||||
const currentAudio = currentTrack ? audioById.get(currentTrack.audioId) ?? null : null;
|
|
||||||
|
|
||||||
const stopPlayback = useCallback(() => {
|
|
||||||
const el = audioElRef.current;
|
|
||||||
if (el) {
|
|
||||||
el.pause();
|
|
||||||
el.removeAttribute('src');
|
|
||||||
el.load();
|
|
||||||
}
|
|
||||||
audioElRef.current = null;
|
|
||||||
setAudioEl(null);
|
|
||||||
setPlaying(false);
|
|
||||||
queueRef.current = null;
|
|
||||||
setQueuePlaylistId(null);
|
|
||||||
setQueueIndex(0);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => () => stopPlayback(), [stopPlayback]);
|
const destroyPlayer = useCallback((playlistId: PlaylistId) => {
|
||||||
|
const el = elsRef.current.get(playlistId);
|
||||||
|
if (el) {
|
||||||
|
el.pause();
|
||||||
|
el.onended = null;
|
||||||
|
el.onplay = null;
|
||||||
|
el.onpause = null;
|
||||||
|
el.removeAttribute('src');
|
||||||
|
el.load();
|
||||||
|
elsRef.current.delete(playlistId);
|
||||||
|
}
|
||||||
|
queuesRef.current.delete(playlistId);
|
||||||
|
setPlayerUi((prev) => {
|
||||||
|
if (!(playlistId in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[playlistId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopAllPlayback = useCallback(() => {
|
||||||
|
for (const id of [...elsRef.current.keys()]) destroyPlayer(id);
|
||||||
|
}, [destroyPlayer]);
|
||||||
|
|
||||||
|
useEffect(() => () => stopAllPlayback(), [stopAllPlayback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readOnly) return undefined;
|
||||||
|
return registerEditorAudioStopper(() => stopAllPlayback());
|
||||||
|
}, [readOnly, stopAllPlayback]);
|
||||||
|
|
||||||
const playAt = useCallback(
|
const playAt = useCallback(
|
||||||
async (playlist: AppPlaylist, index: number) => {
|
async (playlist: AppPlaylist, index: number) => {
|
||||||
@@ -119,61 +137,57 @@ export function PlaylistsPanel({
|
|||||||
if (!track) return;
|
if (!track) return;
|
||||||
const { url } = await api.invoke(ipcChannels.playlists.audioUrl, { id: track.audioId });
|
const { url } = await api.invoke(ipcChannels.playlists.audioUrl, { id: track.audioId });
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
let el = audioElRef.current;
|
|
||||||
|
let el = elsRef.current.get(playlist.id);
|
||||||
if (!el) {
|
if (!el) {
|
||||||
el = new Audio();
|
el = new Audio();
|
||||||
el.preload = 'auto';
|
el.preload = 'auto';
|
||||||
audioElRef.current = el;
|
elsRef.current.set(playlist.id, el);
|
||||||
setAudioEl(el);
|
el.onended = () => {
|
||||||
|
const qIndex = queuesRef.current.get(playlist.id) ?? 0;
|
||||||
|
const pl = statePlaylistsRef.current.find((p) => p.id === playlist.id);
|
||||||
|
if (!pl || pl.tracks.length === 0) {
|
||||||
|
destroyPlayer(playlist.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = (qIndex + 1) % pl.tracks.length;
|
||||||
|
void playAtRef.current(pl, next);
|
||||||
|
};
|
||||||
|
el.onplay = () => bumpPlayer(playlist.id, { playing: true });
|
||||||
|
el.onpause = () => bumpPlayer(playlist.id, { playing: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gain = playerUiRef.current[playlist.id]?.gain ?? 1;
|
||||||
el.volume = gain;
|
el.volume = gain;
|
||||||
el.src = url;
|
el.src = url;
|
||||||
queueRef.current = { playlistId: playlist.id, index };
|
queuesRef.current.set(playlist.id, index);
|
||||||
setQueuePlaylistId(playlist.id);
|
bumpPlayer(playlist.id, { index, playing: false, gain });
|
||||||
setQueueIndex(index);
|
|
||||||
try {
|
try {
|
||||||
await el.play();
|
await el.play();
|
||||||
setPlaying(true);
|
bumpPlayer(playlist.id, { index, playing: true });
|
||||||
} catch {
|
} catch {
|
||||||
setPlaying(false);
|
bumpPlayer(playlist.id, { index, playing: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, gain],
|
[api, bumpPlayer, destroyPlayer],
|
||||||
);
|
);
|
||||||
|
playAtRef.current = playAt;
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedPlayer = selected ? playerUi[selected.id] : undefined;
|
||||||
const el = audioElRef.current;
|
const selectedEl = selected ? elsRef.current.get(selected.id) ?? null : null;
|
||||||
if (!el) return;
|
const selectedQueueIndex = selectedPlayer?.index ?? 0;
|
||||||
const onEnded = () => {
|
const selectedPlaying = Boolean(selectedPlayer?.playing);
|
||||||
const q = queueRef.current;
|
const selectedGain = selectedPlayer?.gain ?? 1;
|
||||||
if (!q) return;
|
const selectedCurrentTrack = selected?.tracks[selectedQueueIndex] ?? null;
|
||||||
const pl = state.playlists.find((p) => p.id === q.playlistId);
|
const selectedCurrentAudio = selectedCurrentTrack
|
||||||
if (!pl || pl.tracks.length === 0) {
|
? audioById.get(selectedCurrentTrack.audioId) ?? null
|
||||||
stopPlayback();
|
: null;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = (q.index + 1) % pl.tracks.length;
|
|
||||||
void playAt(pl, next);
|
|
||||||
};
|
|
||||||
const onPlay = () => setPlaying(true);
|
|
||||||
const onPause = () => setPlaying(false);
|
|
||||||
el.addEventListener('ended', onEnded);
|
|
||||||
el.addEventListener('play', onPlay);
|
|
||||||
el.addEventListener('pause', onPause);
|
|
||||||
return () => {
|
|
||||||
el.removeEventListener('ended', onEnded);
|
|
||||||
el.removeEventListener('play', onPlay);
|
|
||||||
el.removeEventListener('pause', onPause);
|
|
||||||
};
|
|
||||||
}, [audioEl, playAt, state.playlists, stopPlayback]);
|
|
||||||
|
|
||||||
const step = (delta: number) => {
|
const step = (delta: number) => {
|
||||||
const q = queueRef.current;
|
if (!selected || selected.tracks.length === 0) return;
|
||||||
const pl = q ? state.playlists.find((p) => p.id === q.playlistId) : selected;
|
const base = playerUi[selected.id]?.index ?? 0;
|
||||||
if (!pl || pl.tracks.length === 0) return;
|
const next = (base + delta + selected.tracks.length) % selected.tracks.length;
|
||||||
const base = q?.playlistId === pl.id ? q.index : 0;
|
void playAt(selected, next);
|
||||||
const next = (base + delta + pl.tracks.length) % pl.tracks.length;
|
|
||||||
void playAt(pl, next);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPlaylist = async () => {
|
const createPlaylist = async () => {
|
||||||
@@ -223,7 +237,7 @@ export function PlaylistsPanel({
|
|||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
const id = pendingDelete.id;
|
const id = pendingDelete.id;
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
if (queueRef.current?.playlistId === id) stopPlayback();
|
destroyPlayer(id);
|
||||||
await api.invoke(ipcChannels.playlists.delete, { id });
|
await api.invoke(ipcChannels.playlists.delete, { id });
|
||||||
setSelectedId((cur) => (cur === id ? null : cur));
|
setSelectedId((cur) => (cur === id ? null : cur));
|
||||||
};
|
};
|
||||||
@@ -252,7 +266,11 @@ export function PlaylistsPanel({
|
|||||||
<button
|
<button
|
||||||
key={pl.id}
|
key={pl.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={[styles.playlistRow, selected?.id === pl.id ? styles.playlistRowSelected : '']
|
className={[
|
||||||
|
styles.playlistRow,
|
||||||
|
selected?.id === pl.id ? styles.playlistRowSelected : '',
|
||||||
|
playerUi[pl.id]?.playing ? styles.playlistRowPlaying : '',
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
data-testid={`playlist-row-${pl.id}`}
|
data-testid={`playlist-row-${pl.id}`}
|
||||||
@@ -292,6 +310,7 @@ export function PlaylistsPanel({
|
|||||||
onClick={() => setSelectedId(pl.id)}
|
onClick={() => setSelectedId(pl.id)}
|
||||||
>
|
>
|
||||||
<span className={styles.playlistName}>{pl.name || labels.untitled}</span>
|
<span className={styles.playlistName}>{pl.name || labels.untitled}</span>
|
||||||
|
{playerUi[pl.id]?.playing ? <span className={styles.playlistPlayingMark}>▶</span> : null}
|
||||||
<span className={styles.playlistCount}>{pl.tracks.length}</span>
|
<span className={styles.playlistCount}>{pl.tracks.length}</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
@@ -360,7 +379,9 @@ export function PlaylistsPanel({
|
|||||||
const title = audio?.title ?? labels.untitled;
|
const title = audio?.title ?? labels.untitled;
|
||||||
const highlighted = query.trim() && filtered.matchedAudioIds.has(tr.audioId);
|
const highlighted = query.trim() && filtered.matchedAudioIds.has(tr.audioId);
|
||||||
const isCurrent =
|
const isCurrent =
|
||||||
queuePlaylistId === selected.id && queueIndex === idx && Boolean(currentTrack);
|
selectedPlayer?.playing === true &&
|
||||||
|
selectedQueueIndex === idx &&
|
||||||
|
Boolean(selectedCurrentTrack);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tr.id}
|
key={tr.id}
|
||||||
@@ -465,23 +486,26 @@ export function PlaylistsPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PlaylistPlayerBar
|
<PlaylistPlayerBar
|
||||||
title={currentAudio?.title ?? ''}
|
title={selectedCurrentAudio?.title ?? ''}
|
||||||
subtitle={queuePlaylist?.name}
|
subtitle={selected?.name ?? ''}
|
||||||
audioEl={audioEl}
|
audioEl={selectedEl}
|
||||||
playing={playing}
|
playing={selectedPlaying}
|
||||||
gain={gain}
|
gain={selectedGain}
|
||||||
onGainChange={setGain}
|
onGainChange={(g) => {
|
||||||
|
if (!selected) return;
|
||||||
|
const el = elsRef.current.get(selected.id);
|
||||||
|
if (el) el.volume = g;
|
||||||
|
bumpPlayer(selected.id, { gain: g });
|
||||||
|
}}
|
||||||
onTogglePlay={() => {
|
onTogglePlay={() => {
|
||||||
const el = audioElRef.current;
|
if (!selected) return;
|
||||||
|
const el = elsRef.current.get(selected.id);
|
||||||
if (!el) {
|
if (!el) {
|
||||||
if (selected && selected.tracks.length > 0) void playAt(selected, 0);
|
if (selected.tracks.length > 0) void playAt(selected, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (el.paused) void el.play().then(() => setPlaying(true)).catch(() => setPlaying(false));
|
if (el.paused) void el.play().catch(() => bumpPlayer(selected.id, { playing: false }));
|
||||||
else {
|
else el.pause();
|
||||||
el.pause();
|
|
||||||
setPlaying(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onPrev={() => step(-1)}
|
onPrev={() => step(-1)}
|
||||||
onNext={() => step(1)}
|
onNext={() => step(1)}
|
||||||
|
|||||||
@@ -110,8 +110,29 @@ export type SceneAudioRef = {
|
|||||||
assetId: AssetId;
|
assetId: AssetId;
|
||||||
autoplay: boolean;
|
autoplay: boolean;
|
||||||
loop: boolean;
|
loop: boolean;
|
||||||
|
/** Громкость 0…1; дефолт на пульте при старте сессии. */
|
||||||
|
volume: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function clampSceneAudioVolume(v: unknown): number {
|
||||||
|
if (typeof v !== 'number' || !Number.isFinite(v)) return 1;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSceneAudioRef(raw: {
|
||||||
|
assetId: AssetId;
|
||||||
|
autoplay?: boolean;
|
||||||
|
loop?: boolean;
|
||||||
|
volume?: unknown;
|
||||||
|
}): SceneAudioRef {
|
||||||
|
return {
|
||||||
|
assetId: raw.assetId,
|
||||||
|
autoplay: Boolean(raw.autoplay),
|
||||||
|
loop: Boolean(raw.loop),
|
||||||
|
volume: clampSceneAudioVolume(raw.volume),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type SceneMediaRefs = {
|
export type SceneMediaRefs = {
|
||||||
videos: AssetId[];
|
videos: AssetId[];
|
||||||
audios: SceneAudioRef[];
|
audios: SceneAudioRef[];
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { clampSceneAudioVolume, normalizeSceneAudioRef } from '../../shared/types/domain';
|
||||||
|
import type { AssetId } from '../../shared/types/ids';
|
||||||
|
|
||||||
|
void test('clampSceneAudioVolume clamps to 0…1', () => {
|
||||||
|
assert.equal(clampSceneAudioVolume(0.5), 0.5);
|
||||||
|
assert.equal(clampSceneAudioVolume(-1), 0);
|
||||||
|
assert.equal(clampSceneAudioVolume(2), 1);
|
||||||
|
assert.equal(clampSceneAudioVolume(undefined), 1);
|
||||||
|
assert.equal(clampSceneAudioVolume(NaN), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('normalizeSceneAudioRef defaults volume to 1', () => {
|
||||||
|
const id = 'a1' as AssetId;
|
||||||
|
assert.deepEqual(normalizeSceneAudioRef({ assetId: id, autoplay: true, loop: false }), {
|
||||||
|
assetId: id,
|
||||||
|
autoplay: true,
|
||||||
|
loop: false,
|
||||||
|
volume: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(normalizeSceneAudioRef({ assetId: id, autoplay: false, loop: true, volume: 0.25 }), {
|
||||||
|
assetId: id,
|
||||||
|
autoplay: false,
|
||||||
|
loop: true,
|
||||||
|
volume: 0.25,
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user