feat: i18n control, Gitea auto-update CI, license-gated updater, fixes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-05-11 22:20:14 +08:00
parent 36776f4c5d
commit f462e65581
23 changed files with 2049 additions and 440 deletions
+74 -73
View File
@@ -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);
});