studio/src/admin-preview/GdMusicPreview.jsx
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026).
Содержит изменения которые делались в процессе подготовки прод-окружения:

Фиксы импортов после выноса из minecraftia:
- Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/)
- Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/
- API.js скопирован из минки целиком (было 8 экспортов, стало 312)
- Добавлены PLAYER_URL, MyButton_1, недостающие компоненты
- Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require)

Структура ассетов:
- public/kubikon-templates/ → public/assets/kubikon-templates/
- public/kubikon-learn/ → public/assets/kubikon-learn/
- (код искал в /assets/, файлы лежали без /assets/)

Навигация роутов внутри студии:
- /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced)
- /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X

UI:
- Новый компонент StudioHeader (61px, как в минке) + копия favicon
- WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера
- SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке)
- Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык)

Документация:
- docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR
- docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API
- API_USAGE.md — список эндпоинтов backend
- README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/

.gitignore:
- public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 05:01:13 +03:00

194 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* GdMusicPreview — превью 20 треков GD-музыки.
*
* Маршрут: /admin-preview/gd-music
*
* Для каждого трека:
* - проверяет есть ли реальный mp3 в /music/gd/
* - если есть — играет файл через <audio>
* - если нет — играет процедурный fallback через Web Audio (SynthPlayer)
*
* Показывает BPM, длительность, эпоху, kind (main/boss).
* Одновременно играет только один трек.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext.jsx';
import styles from './GdMusicPreview.module.css';
import { TRACKS, EPOCH_INFO } from './gdMusic/musicCatalog';
import { SynthPlayer, trackFileExists } from './gdMusic/musicSynth';
function formatDuration(sec) {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function TrackCard({ track, isPlaying, onPlay, onStop, fileStatus }) {
const epoch = EPOCH_INFO[track.epoch - 1];
const isBoss = track.kind === 'boss';
return (
<div className={`${styles.card} ${isBoss ? styles.cardBoss : ''}`}>
<div className={styles.cardHeader} style={{ borderTopColor: epoch.color }}>
<span className={styles.epochBadge}>
{epoch.emoji} E{track.epoch}
</span>
<span className={isBoss ? styles.kindBoss : styles.kindMain}>
{isBoss ? '⚔ БОСС' : '♪ main'}
</span>
</div>
<div className={styles.cardBody}>
<div className={styles.title}>{track.title}</div>
<div className={styles.stats}>
<span><strong>{track.bpm}</strong> BPM</span>
<span>{formatDuration(track.durationSec)}</span>
<span className={fileStatus === 'file' ? styles.statusFile : styles.statusSynth}>
{fileStatus === 'file' ? '📁 файл' : (fileStatus === 'synth' ? '🎹 синтез' : '⋯')}
</span>
</div>
</div>
<div className={styles.cardActions}>
{isPlaying ? (
<button className={styles.btnStop} onClick={onStop}> Стоп</button>
) : (
<button className={styles.btnPlay} onClick={onPlay}> Играть</button>
)}
<code className={styles.trackId}>{track.id}</code>
</div>
</div>
);
}
function GdMusicPreview() {
const { userRole, isLoading } = useAuth();
const navigate = useNavigate();
const [playingId, setPlayingId] = useState(null);
const [fileStatuses, setFileStatuses] = useState({}); // trackId → 'file' | 'synth' | undefined
const audioRef = useRef(null);
const synthRef = useRef(null);
// При монтировании — проверяем все mp3-файлы (HEAD-запросы)
useEffect(() => {
let cancelled = false;
(async () => {
const statuses = {};
// параллельно
await Promise.all(TRACKS.map(async (t) => {
const exists = await trackFileExists(t.file);
statuses[t.id] = exists ? 'file' : 'synth';
}));
if (!cancelled) setFileStatuses(statuses);
})();
return () => { cancelled = true; };
}, []);
// Остановить всё
const stopAll = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
if (synthRef.current) {
synthRef.current.stop();
synthRef.current = null;
}
setPlayingId(null);
};
const play = async (track) => {
stopAll();
const status = fileStatuses[track.id];
setPlayingId(track.id);
if (status === 'file') {
const a = new Audio(track.file);
a.volume = 0.7;
a.loop = false;
audioRef.current = a;
a.onended = () => setPlayingId(curr => curr === track.id ? null : curr);
try { await a.play(); }
catch (e) { console.warn('[gd-music] audio.play() failed', e); }
} else {
const p = new SynthPlayer(track.fallbackSynth, track.bpm);
synthRef.current = p;
await p.start();
}
};
// Cleanup при unmount
useEffect(() => {
return () => stopAll();
}, []);
if (isLoading) return <div className={styles.loading}>Загрузка...</div>;
if (userRole !== 'admin') {
return (
<div className={styles.denied}>
<h2>Доступ только для администратора</h2>
<button onClick={() => navigate('/')} className={styles.backBtn}>На главную</button>
</div>
);
}
// Группируем по эпохам
const tracksByEpoch = {};
for (const t of TRACKS) {
if (!tracksByEpoch[t.epoch]) tracksByEpoch[t.epoch] = [];
tracksByEpoch[t.epoch].push(t);
}
const filesFound = Object.values(fileStatuses).filter(s => s === 'file').length;
const synthCount = Object.values(fileStatuses).filter(s => s === 'synth').length;
return (
<div className={styles.root}>
<div className={styles.topbar}>
<h1 className={styles.h1}>GD-Музыка превью {TRACKS.length} треков</h1>
<div className={styles.subline}>
{filesFound > 0 && <span>📁 файлов: {filesFound}</span>}
{synthCount > 0 && <span style={{ marginLeft: 16 }}>🎹 синтез: {synthCount} (Web Audio fallback)</span>}
<div style={{ marginTop: 6 }}>
Промпты для Suno: <code>RUBLOX_GD_MUSIC_PROMPTS.md</code> ·
Файлы класть в: <code>/public/music/gd/&lt;trackId&gt;.mp3</code>
</div>
</div>
</div>
{EPOCH_INFO.map(epoch => (
<div key={epoch.n} className={styles.epochSection}>
<h2 className={styles.epochTitle} style={{ color: epoch.color }}>
{epoch.emoji} Эпоха {epoch.n} {epoch.name}
</h2>
<div className={styles.grid}>
{(tracksByEpoch[epoch.n] || []).map(track => (
<TrackCard
key={track.id}
track={track}
isPlaying={playingId === track.id}
onPlay={() => play(track)}
onStop={stopAll}
fileStatus={fileStatuses[track.id]}
/>
))}
</div>
</div>
))}
<div className={styles.footer}>
<p><strong>Как заменить fallback на реальную музыку:</strong></p>
<ol>
<li>Купить Suno Pro ($10/мес)</li>
<li>Скопировать промпт из <code>RUBLOX_GD_MUSIC_PROMPTS.md</code></li>
<li>Сгенерировать в Suno, скачать mp3</li>
<li>Положить в <code>/public/music/gd/&lt;trackId&gt;.mp3</code></li>
<li>Перезагрузить страницу плеер автоматически подцепит файл</li>
</ol>
<p>Синтез нужен ровно для теста <strong>темпа</strong> (BPM правильный) пока нет реальных треков.</p>
</div>
</div>
);
}
export default GdMusicPreview;