feat: i18n control, Gitea auto-update CI, license-gated updater, fixes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
|
||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||
@@ -44,6 +45,9 @@ function playLightningEffectSound(): void {
|
||||
|
||||
export function ControlApp() {
|
||||
const api = getDndApi();
|
||||
const { t } = useEditorI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const [fxState, fx] = useEffectsState();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const historyRef = useRef<GraphNodeId[]>([]);
|
||||
@@ -278,8 +282,7 @@ export function ControlApp() {
|
||||
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
sceneAudioMetaRef.current.set(ref.assetId, {
|
||||
...m,
|
||||
lastPlayError:
|
||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
|
||||
});
|
||||
setSceneAudioStateTick((x) => x + 1);
|
||||
try {
|
||||
@@ -382,8 +385,7 @@ export function ControlApp() {
|
||||
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
campaignAudioMetaRef.current.set(ref.assetId, {
|
||||
...m,
|
||||
lastPlayError:
|
||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
|
||||
});
|
||||
setCampaignAudioStateTick((x) => x + 1);
|
||||
try {
|
||||
@@ -546,21 +548,21 @@ export function ControlApp() {
|
||||
group === 'scene'
|
||||
? (sceneAudioElsRef.current.get(assetId) ?? null)
|
||||
: (campaignAudioElsRef.current.get(assetId) ?? null);
|
||||
if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' };
|
||||
if (!el) return { label: t('control.audioNoUrl'), detail: t('control.audioNoUrlDetail') };
|
||||
const meta =
|
||||
group === 'scene'
|
||||
? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null })
|
||||
: (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null });
|
||||
if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError };
|
||||
if (meta.lastPlayError) return { label: t('control.audioBlocked'), detail: meta.lastPlayError };
|
||||
if (el.error)
|
||||
return {
|
||||
label: 'Ошибка',
|
||||
detail: `MediaError code=${String(el.error.code)} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)`,
|
||||
label: t('control.audioError'),
|
||||
detail: t('control.audioMediaError', { code: String(el.error.code) }),
|
||||
};
|
||||
if (el.readyState < 2) return { label: 'Загрузка…' };
|
||||
if (!el.paused) return { label: 'Играет' };
|
||||
if (el.currentTime > 0) return { label: 'Пауза' };
|
||||
return { label: 'Остановлено' };
|
||||
if (el.readyState < 2) return { label: t('control.audioLoading') };
|
||||
if (!el.paused) return { label: t('control.audioPlaying') };
|
||||
if (el.currentTime > 0) return { label: t('control.audioPaused') };
|
||||
return { label: t('control.audioStopped') };
|
||||
}
|
||||
const nextScenes = useMemo(() => {
|
||||
if (!project) return [];
|
||||
@@ -955,21 +957,21 @@ export function ControlApp() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Surface className={styles.remote}>
|
||||
<div className={styles.remoteTitle}>ПУЛЬТ УПРАВЛЕНИЯ</div>
|
||||
<div className={styles.remoteTitle}>{t('control.remoteTitle')}</div>
|
||||
<div className={styles.spacer12} />
|
||||
{!isVideoPreviewScene ? (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>ЭФФЕКТЫ</div>
|
||||
<div className={styles.sectionLabel}>{t('control.effects')}</div>
|
||||
<div className={styles.spacer8} />
|
||||
<div className={styles.effectsStack}>
|
||||
<div className={styles.effectsGroup}>
|
||||
<div className={styles.subsectionLabel}>Инструменты</div>
|
||||
<div className={styles.subsectionLabel}>{t('control.tools')}</div>
|
||||
<div className={styles.iconRow}>
|
||||
<Button
|
||||
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Ластик"
|
||||
ariaLabel="Ластик"
|
||||
title={t('control.eraser')}
|
||||
ariaLabel={t('control.eraser')}
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🧹</span>
|
||||
@@ -977,8 +979,8 @@ export function ControlApp() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
title="Очистить эффекты"
|
||||
ariaLabel="Очистить эффекты"
|
||||
title={t('control.clearEffects')}
|
||||
ariaLabel={t('control.clearEffects')}
|
||||
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
|
||||
>
|
||||
<span className={styles.clearIcon}>
|
||||
@@ -999,13 +1001,13 @@ export function ControlApp() {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.effectsGroup}>
|
||||
<div className={styles.subsectionLabel}>Эффекты поля</div>
|
||||
<div className={styles.subsectionLabel}>{t('control.fieldEffects')}</div>
|
||||
<div className={styles.iconRow}>
|
||||
<Button
|
||||
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Туман"
|
||||
ariaLabel="Туман"
|
||||
title={t('control.fog')}
|
||||
ariaLabel={t('control.fog')}
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🌫️</span>
|
||||
@@ -1013,8 +1015,8 @@ export function ControlApp() {
|
||||
<Button
|
||||
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Дождь"
|
||||
ariaLabel="Дождь"
|
||||
title={t('control.rain')}
|
||||
ariaLabel={t('control.rain')}
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🌧️</span>
|
||||
@@ -1022,8 +1024,8 @@ export function ControlApp() {
|
||||
<Button
|
||||
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Огонь"
|
||||
ariaLabel="Огонь"
|
||||
title={t('control.fire')}
|
||||
ariaLabel={t('control.fire')}
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🔥</span>
|
||||
@@ -1031,8 +1033,8 @@ export function ControlApp() {
|
||||
<Button
|
||||
variant={tool.tool === 'water' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Вода"
|
||||
ariaLabel="Вода"
|
||||
title={t('control.water')}
|
||||
ariaLabel={t('control.water')}
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'water' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>💧</span>
|
||||
@@ -1040,13 +1042,13 @@ export function ControlApp() {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.effectsGroup}>
|
||||
<div className={styles.subsectionLabel}>Эффекты действий</div>
|
||||
<div className={styles.subsectionLabel}>{t('control.actionEffects')}</div>
|
||||
<div className={styles.iconRow}>
|
||||
<Button
|
||||
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Молния"
|
||||
ariaLabel="Молния"
|
||||
title={t('control.lightning')}
|
||||
ariaLabel={t('control.lightning')}
|
||||
onClick={() =>
|
||||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })
|
||||
}
|
||||
@@ -1056,8 +1058,8 @@ export function ControlApp() {
|
||||
<Button
|
||||
variant={tool.tool === 'sunbeam' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Луч света"
|
||||
ariaLabel="Луч света"
|
||||
title={t('control.sunbeam')}
|
||||
ariaLabel={t('control.sunbeam')}
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'sunbeam' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>☀️</span>
|
||||
@@ -1065,8 +1067,8 @@ export function ControlApp() {
|
||||
<Button
|
||||
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Заморозка"
|
||||
ariaLabel="Заморозка"
|
||||
title={t('control.freeze')}
|
||||
ariaLabel={t('control.freeze')}
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>❄️</span>
|
||||
@@ -1074,8 +1076,8 @@ export function ControlApp() {
|
||||
<Button
|
||||
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Облако яда"
|
||||
ariaLabel="Облако яда"
|
||||
title={t('control.poisonCloud')}
|
||||
ariaLabel={t('control.poisonCloud')}
|
||||
onClick={() =>
|
||||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'poisonCloud' } })
|
||||
}
|
||||
@@ -1085,7 +1087,7 @@ export function ControlApp() {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.radiusRow}>
|
||||
<div className={styles.radiusLabel}>Радиус кисти</div>
|
||||
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.015}
|
||||
@@ -1098,7 +1100,7 @@ export function ControlApp() {
|
||||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
|
||||
}}
|
||||
className={styles.range}
|
||||
aria-label="Радиус кисти"
|
||||
aria-label={t('control.brushRadius')}
|
||||
/>
|
||||
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
||||
</div>
|
||||
@@ -1107,7 +1109,7 @@ export function ControlApp() {
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.storyWrap}>
|
||||
<div className={styles.sectionLabel}>СЮЖЕТНАЯ ЛИНИЯ</div>
|
||||
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.storyScroll}>
|
||||
{history.map((gnId, idx) => {
|
||||
@@ -1122,7 +1124,7 @@ export function ControlApp() {
|
||||
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
title={project && !isCurrent ? 'Перейти к этой сцене' : undefined}
|
||||
title={project && !isCurrent ? t('control.gotoScene') : undefined}
|
||||
onClick={() => {
|
||||
if (!project) return;
|
||||
if (isCurrent) return;
|
||||
@@ -1132,15 +1134,17 @@ export function ControlApp() {
|
||||
}}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<div className={styles.historyBadge}>ТЕКУЩАЯ СЦЕНА</div>
|
||||
<div className={styles.historyBadge}>{t('control.currentSceneBadge')}</div>
|
||||
) : (
|
||||
<div className={styles.historyMuted}>Пройдено</div>
|
||||
<div className={styles.historyMuted}>{t('control.passed')}</div>
|
||||
)}
|
||||
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{history.length === 0 ? <div className={styles.emptyStory}>Нет активной сцены.</div> : null}
|
||||
{history.length === 0 ? (
|
||||
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
@@ -1148,20 +1152,15 @@ export function ControlApp() {
|
||||
<div className={styles.rightStack}>
|
||||
<Surface className={styles.surfacePad}>
|
||||
<div className={styles.previewHeader}>
|
||||
<div className={styles.previewTitle}>Предпросмотр экрана</div>
|
||||
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
|
||||
<div className={styles.previewActions}>
|
||||
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
||||
Выключить демонстрацию
|
||||
{t('control.stopPresentation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
{isVideoPreviewScene ? (
|
||||
<div className={styles.videoHint}>
|
||||
Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для
|
||||
изображения).
|
||||
</div>
|
||||
) : null}
|
||||
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.previewFrame}>
|
||||
<div ref={previewHostRef} className={styles.previewHost}>
|
||||
@@ -1258,33 +1257,33 @@ export function ControlApp() {
|
||||
</Surface>
|
||||
|
||||
<Surface className={styles.surfacePad}>
|
||||
<div className={styles.branchTitle}>Варианты ветвления</div>
|
||||
<div className={styles.branchTitle}>{t('control.branches')}</div>
|
||||
<div className={styles.branchGrid}>
|
||||
{nextScenes.map((o, i) => (
|
||||
<div key={o.graphNodeId} className={styles.branchCard}>
|
||||
<div className={styles.branchCardHeader}>
|
||||
<div className={styles.branchOption}>ОПЦИЯ {String(i + 1)}</div>
|
||||
<div className={styles.branchOption}>{t('control.option', { n: String(i + 1) })}</div>
|
||||
</div>
|
||||
<div className={styles.branchName}>{o.scene.title || 'Без названия'}</div>
|
||||
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
|
||||
}
|
||||
>
|
||||
Переключить
|
||||
{t('control.switchScene')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{nextScenes.length === 0 ? (
|
||||
<div className={styles.branchEmpty}>
|
||||
<div>Нет вариантов перехода.</div>
|
||||
<div>{t('control.noBranches')}</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!session?.project?.currentGraphNodeId}
|
||||
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
|
||||
>
|
||||
Завершить показ
|
||||
{t('control.endPresentation')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1293,13 +1292,13 @@ export function ControlApp() {
|
||||
|
||||
<Surface className={styles.surfacePad}>
|
||||
<div className={styles.musicHeader}>
|
||||
<div className={styles.previewTitle}>Музыка</div>
|
||||
<div className={styles.previewTitle}>{t('control.music')}</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.sectionLabel}>МУЗЫКА СЦЕНЫ</div>
|
||||
<div className={styles.sectionLabel}>{t('control.sceneMusic')}</div>
|
||||
<div className={styles.spacer10} />
|
||||
{sceneAudios.length === 0 ? (
|
||||
<div className={styles.musicEmpty}>В текущей сцене нет аудио.</div>
|
||||
<div className={styles.musicEmpty}>{t('control.noSceneAudio')}</div>
|
||||
) : null}
|
||||
{sceneAudios.length > 0 ? (
|
||||
<div className={styles.audioList}>
|
||||
@@ -1314,8 +1313,8 @@ export function ControlApp() {
|
||||
<div className={styles.audioMeta}>
|
||||
<div className={styles.audioName}>{asset.originalName}</div>
|
||||
<div className={styles.audioBadges}>
|
||||
<div>{ref.autoplay ? 'Авто' : 'Ручн.'}</div>
|
||||
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
|
||||
<div>{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}</div>
|
||||
<div>{ref.loop ? t('control.loop') : t('control.once')}</div>
|
||||
<div title={st.detail}>{st.label}</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
@@ -1344,7 +1343,7 @@ export function ControlApp() {
|
||||
styles.audioScrub,
|
||||
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
|
||||
].join(' ')}
|
||||
title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'}
|
||||
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
|
||||
>
|
||||
<div
|
||||
className={styles.scrubFill}
|
||||
@@ -1369,7 +1368,7 @@ export function ControlApp() {
|
||||
({ lastPlayError: null } as const);
|
||||
sceneAudioMetaRef.current.set(ref.assetId, {
|
||||
...mm,
|
||||
lastPlayError: 'Не удалось запустить.',
|
||||
lastPlayError: t('control.playFailed'),
|
||||
});
|
||||
setSceneAudioStateTick((x) => x + 1);
|
||||
});
|
||||
@@ -1403,10 +1402,10 @@ export function ControlApp() {
|
||||
) : null}
|
||||
|
||||
<div className={styles.spacer12} />
|
||||
<div className={styles.sectionLabel}>МУЗЫКА ИГРЫ</div>
|
||||
<div className={styles.sectionLabel}>{t('control.gameMusic')}</div>
|
||||
<div className={styles.spacer10} />
|
||||
{campaignAudios.length === 0 ? (
|
||||
<div className={styles.musicEmpty}>В игре нет аудио.</div>
|
||||
<div className={styles.musicEmpty}>{t('control.noGameAudio')}</div>
|
||||
) : (
|
||||
<div className={styles.audioList}>
|
||||
{campaignAudios.map(({ ref, asset }) => {
|
||||
@@ -1420,10 +1419,12 @@ export function ControlApp() {
|
||||
<div className={styles.audioMeta}>
|
||||
<div className={styles.audioName}>{asset.originalName}</div>
|
||||
<div className={styles.audioBadges}>
|
||||
<div>{ref.autoplay ? 'Авто' : 'Ручн.'}</div>
|
||||
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
|
||||
<div>{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}</div>
|
||||
<div>{ref.loop ? t('control.loop') : t('control.once')}</div>
|
||||
<div title={st.detail}>{st.label}</div>
|
||||
{!allowCampaignAudio ? <div title="В сцене есть музыка">Пауза (сцена)</div> : null}
|
||||
{!allowCampaignAudio ? (
|
||||
<div title={t('control.pauseSceneMusicTitle')}>{t('control.pauseSceneMusic')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div
|
||||
@@ -1451,7 +1452,7 @@ export function ControlApp() {
|
||||
styles.audioScrub,
|
||||
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
|
||||
].join(' ')}
|
||||
title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'}
|
||||
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
|
||||
>
|
||||
<div
|
||||
className={styles.scrubFill}
|
||||
@@ -1466,7 +1467,7 @@ export function ControlApp() {
|
||||
<div className={styles.audioTransport}>
|
||||
<Button
|
||||
variant="primary"
|
||||
title={!allowCampaignAudio ? 'Пауза: в сцене есть музыка' : undefined}
|
||||
title={!allowCampaignAudio ? t('control.pauseCampaignTitle') : undefined}
|
||||
onClick={() => {
|
||||
if (!el) return;
|
||||
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
@@ -1484,7 +1485,7 @@ export function ControlApp() {
|
||||
({ lastPlayError: null } as const);
|
||||
campaignAudioMetaRef.current.set(ref.assetId, {
|
||||
...mm,
|
||||
lastPlayError: 'Не удалось запустить.',
|
||||
lastPlayError: t('control.playFailed'),
|
||||
});
|
||||
setCampaignAudioStateTick((x) => x + 1);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
|
||||
@@ -23,6 +24,7 @@ function fmt(sec: number): string {
|
||||
}
|
||||
|
||||
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
|
||||
const { t } = useEditorI18n();
|
||||
const [vp, video] = useVideoPlaybackState();
|
||||
const scene =
|
||||
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
||||
@@ -102,7 +104,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||
>
|
||||
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
|
||||
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
|
||||
</video>
|
||||
) : (
|
||||
<div className={styles.placeholder} />
|
||||
@@ -132,7 +134,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1);
|
||||
}}
|
||||
title="Клик — перемотка"
|
||||
title={t('control.scrubSeek')}
|
||||
>
|
||||
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
|
||||
</div>
|
||||
@@ -142,7 +144,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={() => void video.dispatch({ kind: 'play' })}
|
||||
title="Play"
|
||||
title={t('control.transportPlay')}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
@@ -150,7 +152,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={() => void video.dispatch({ kind: 'pause' })}
|
||||
title="Pause"
|
||||
title={t('control.transportPause')}
|
||||
>
|
||||
❚❚
|
||||
</button>
|
||||
@@ -161,7 +163,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
void video.dispatch({ kind: 'stop' });
|
||||
setTick((x) => x + 1);
|
||||
}}
|
||||
title="Stop"
|
||||
title={t('control.transportStop')}
|
||||
>
|
||||
■
|
||||
</button>
|
||||
|
||||
@@ -47,28 +47,28 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', (
|
||||
|
||||
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('ЭФФЕКТЫ'));
|
||||
assert.ok(src.includes('Инструменты'));
|
||||
assert.ok(src.includes('Эффекты поля'));
|
||||
assert.ok(src.includes('Эффекты действий'));
|
||||
assert.ok(src.includes('Луч света'));
|
||||
assert.ok(src.includes('title="Вода"'));
|
||||
assert.ok(src.includes('title="Облако яда"'));
|
||||
assert.ok(src.includes('title="Туман"'));
|
||||
assert.ok(src.includes('ariaLabel="Туман"'));
|
||||
assert.ok(src.includes("t('control.effects')"));
|
||||
assert.ok(src.includes("t('control.tools')"));
|
||||
assert.ok(src.includes("t('control.fieldEffects')"));
|
||||
assert.ok(src.includes("t('control.actionEffects')"));
|
||||
assert.ok(src.includes("t('control.sunbeam')"));
|
||||
assert.ok(src.includes("title={t('control.water')}"));
|
||||
assert.ok(src.includes("title={t('control.poisonCloud')}"));
|
||||
assert.ok(src.includes("title={t('control.fog')}"));
|
||||
assert.ok(src.includes("ariaLabel={t('control.fog')}"));
|
||||
assert.ok(src.includes('iconOnly'));
|
||||
assert.ok(src.includes('title="Очистить эффекты"'));
|
||||
assert.ok(src.includes('ariaLabel="Очистить эффекты"'));
|
||||
assert.ok(src.includes("title={t('control.clearEffects')}"));
|
||||
assert.ok(src.includes("ariaLabel={t('control.clearEffects')}"));
|
||||
assert.ok(src.includes('#e5484d'));
|
||||
const fx = src.indexOf('ЭФФЕКТЫ');
|
||||
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
|
||||
const fx = src.indexOf("t('control.effects')");
|
||||
const story = src.indexOf("t('control.storyLine')");
|
||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
||||
});
|
||||
|
||||
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
|
||||
const story = src.indexOf("t('control.storyLine')");
|
||||
assert.ok(story !== -1);
|
||||
assert.ok(src.includes('className={styles.storyScroll}'));
|
||||
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
||||
@@ -86,8 +86,8 @@ void test('ControlApp: слой кисти не использует курсо
|
||||
|
||||
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
|
||||
const src = readControlApp();
|
||||
const previewLabel = src.indexOf('Предпросмотр экрана');
|
||||
const radius = src.indexOf('Радиус кисти');
|
||||
const previewLabel = src.indexOf("t('control.screenPreview')");
|
||||
const radius = src.indexOf("t('control.brushRadius')");
|
||||
assert.ok(previewLabel !== -1 && radius !== -1);
|
||||
assert.ok(
|
||||
radius < previewLabel,
|
||||
@@ -97,8 +97,8 @@ void test('ControlApp: радиус кисти не в блоке предпро
|
||||
|
||||
void test('ControlApp: музыка разделена на сцену и кампанию', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('МУЗЫКА СЦЕНЫ'));
|
||||
assert.ok(src.includes('МУЗЫКА ИГРЫ'));
|
||||
assert.ok(src.includes("t('control.sceneMusic')"));
|
||||
assert.ok(src.includes("t('control.gameMusic')"));
|
||||
// при музыке сцены — кампанию ставим на паузу
|
||||
assert.ok(src.includes('allowCampaignAudio'));
|
||||
assert.ok(
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
|
||||
import { ControlApp } from './ControlApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
@@ -11,6 +13,8 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<ControlApp />
|
||||
<EditorI18nProvider>
|
||||
<ControlApp />
|
||||
</EditorI18nProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user