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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user