1e51ef7def
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/ - 12 code-workspace-Files - STATUS-*.md - viele M/D/R-Änderungen an bereits getrackten Files - .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
765 lines
33 KiB
PHP
765 lines
33 KiB
PHP
<?php
|
|
/**
|
|
* Heli Audio-Editor — Schnittprogramm fuer Mission-Audio-Timelines.
|
|
*
|
|
* Tool-Seite (Review-Tool, nicht fuer Schueler*innen). Konzept:
|
|
* docs/konzept-audio-schnitt-tool.md
|
|
*
|
|
* Funktion:
|
|
* - Mission-Switcher (M1 ... M25)
|
|
* - Horizontale Timeline pro Phase (Plan, Flug, evtl. Abflug/Landung als Read-only)
|
|
* - Audio-Balken mit Drag&Drop verschieben
|
|
* - Audio-Pool unten: Audios via Drag in die Timeline ziehen
|
|
* - Event-Marker (Wegpunkt sichtbar/erreicht, Phase-Ende)
|
|
* - Klick auf Balken → Audio anhoeren + loeschen + verschieben
|
|
* - Speichern → POST an /api/heli-timeline?mission=mX
|
|
* - Beim ersten Aufruf: Audio-Dauern client-seitig messen + cachen
|
|
*/
|
|
require_once __DIR__ . '/../../php/config/app.php';
|
|
|
|
$base = BASE_PATH;
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Heli Audio-Editor — Timeline-Schnittprogramm</title>
|
|
<link rel="icon" type="image/png" href="<?= $base ?>/favicon-96x96.png">
|
|
<link rel="stylesheet" href="<?= $base ?>/assets/fonts/inter.css">
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
html,body{height:100%;font-family:'Inter',system-ui,sans-serif;background:#1f2a24;color:#e8e4d8;overflow:hidden}
|
|
body{display:flex;flex-direction:column}
|
|
|
|
/* Topbar */
|
|
.topbar{background:#0f1814;padding:.55rem 1rem;display:flex;align-items:center;gap:.7rem;border-bottom:1px solid #2d3e34;flex-shrink:0}
|
|
.topbar h1{font-size:.95rem;font-weight:800;color:#e8c547}
|
|
.topbar select{background:#1f2a24;color:#e8e4d8;border:1px solid #3d5165;padding:.3rem .55rem;border-radius:5px;font-family:inherit;font-size:.85rem;min-width:280px;cursor:pointer}
|
|
.topbar button{background:#c97a3f;color:#fff;border:none;padding:.4rem .8rem;border-radius:5px;font-weight:700;font-size:.78rem;cursor:pointer;font-family:inherit}
|
|
.topbar button:hover{background:#b06020}
|
|
.topbar button.btn-save{background:#5a8a5e}
|
|
.topbar button.btn-save:hover{background:#4a7c4e}
|
|
.topbar button.btn-reset{background:#3d5165}
|
|
.topbar .status{padding:.3rem .65rem;border-radius:4px;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em}
|
|
.status.default{background:#3d5165;color:#e8e4d8}
|
|
.status.edited{background:#c97a3f;color:#fff}
|
|
.status.saved{background:#5a8a5e;color:#fff}
|
|
.topbar .spacer{flex:1}
|
|
.topbar a{color:#7a8a7a;text-decoration:none;font-size:.75rem}
|
|
.topbar a:hover{color:#e8c547}
|
|
|
|
/* Mission-Info */
|
|
.m-info{background:#152019;padding:.4rem 1rem;display:flex;gap:1rem;font-size:.78rem;color:#a8b9a0;border-bottom:1px solid #2d3e34;flex-shrink:0}
|
|
.m-info b{color:#e8e4d8}
|
|
|
|
/* Timeline-Container */
|
|
.tl-wrap{flex:1;overflow:auto;background:#0f1814;position:relative}
|
|
.tl-zoom{position:sticky;top:0;background:#152019;padding:.35rem .8rem;border-bottom:1px solid #2d3e34;display:flex;gap:.5rem;align-items:center;z-index:10;font-size:.72rem;color:#a8b9a0}
|
|
.tl-zoom button{background:#2d3e34;color:#e8e4d8;border:none;padding:.25rem .55rem;border-radius:3px;cursor:pointer;font-family:'JetBrains Mono',monospace;font-size:.72rem}
|
|
.tl-zoom button:hover{background:#3d5165}
|
|
.tl-zoom .gap-input{background:#2d3e34;color:#e8e4d8;border:1px solid #3d5165;padding:.2rem .4rem;border-radius:3px;width:60px;font-family:'JetBrains Mono',monospace;font-size:.72rem}
|
|
|
|
/* Eine Phase */
|
|
.phase{margin-bottom:1rem;border-left:4px solid #3d5165}
|
|
.phase-head{background:#152019;padding:.4rem .9rem;font-size:.82rem;font-weight:700;border-bottom:1px solid #2d3e34;display:flex;justify-content:space-between;align-items:center}
|
|
.phase-head .dur{font-family:'JetBrains Mono',monospace;color:#a8b9a0;font-size:.7rem;font-weight:500}
|
|
/* Tracks pro Phase: Audio + Events */
|
|
.track{position:relative;background:#152019;border-bottom:1px solid #2d3e34}
|
|
.track-audio{height:50px}
|
|
.track-events{height:32px}
|
|
.track-axis{height:24px;background:#0f1814;font-family:'JetBrains Mono',monospace;font-size:.62rem;color:#5a6b5a}
|
|
.tick{position:absolute;top:0;bottom:0;width:1px;background:#2d3e34}
|
|
.tick.major{background:#3d5165}
|
|
.tick-label{position:absolute;top:4px;font-size:.62rem;color:#5a6b5a;white-space:nowrap;transform:translateX(-50%)}
|
|
|
|
/* Audio-Balken */
|
|
.audio{position:absolute;top:7px;height:36px;border-radius:4px;cursor:grab;overflow:hidden;background:#4a7c8a;border:1.5px solid #5d97a5;box-shadow:0 1px 3px rgba(0,0,0,.4);user-select:none;font-size:.7rem;color:#fff;padding:3px 6px 3px 22px;line-height:1.2;display:flex;flex-direction:column;justify-content:center;transition:box-shadow .15s}
|
|
.audio:hover{box-shadow:0 3px 8px rgba(74,124,138,.5);z-index:5}
|
|
.audio.dragging{opacity:.5;cursor:grabbing}
|
|
.audio.selected{box-shadow:0 0 0 2px #e8c547,0 3px 8px rgba(0,0,0,.4);z-index:10}
|
|
.audio.overlap{background:#c85c4a;border-color:#e07868;box-shadow:0 0 0 2px #c85c4a}
|
|
.audio.playing{box-shadow:0 0 0 2px #5a8a5e,0 3px 8px rgba(0,0,0,.4)}
|
|
.audio .a-id{font-family:'JetBrains Mono',monospace;font-size:.62rem;font-weight:700;opacity:.95;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.audio .a-note{font-size:.6rem;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.audio.color-plan{background:#4a7c8a;border-color:#5d97a5}
|
|
.audio.color-flug{background:#5a8a5e;border-color:#6d9d72}
|
|
.audio.color-startflug{background:#c97a3f;border-color:#dc9258}
|
|
.audio.color-bergung{background:#a25cc8;border-color:#b878d8}
|
|
/* Play-Button auf Audio-Balken (links) */
|
|
.play-btn{position:absolute;left:3px;top:50%;transform:translateY(-50%);width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.35);color:#fff;font-size:.62rem;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;line-height:1;font-family:inherit;z-index:6}
|
|
.play-btn:hover{background:rgba(255,255,255,.32)}
|
|
.play-btn.playing{background:#e8c547;color:#1f2a24;border-color:#e8c547}
|
|
/* Play-Button im Pool */
|
|
.pool-item .play-btn-pool{width:18px;height:18px;border-radius:50%;background:#3d5165;color:#e8e4d8;border:1px solid #5a6b7a;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:.65rem;padding:0;line-height:1;font-family:inherit;flex-shrink:0}
|
|
.pool-item .play-btn-pool:hover{background:#4a7c8a;color:#fff}
|
|
.pool-item .play-btn-pool.playing{background:#e8c547;color:#1f2a24}
|
|
|
|
/* Event-Marker */
|
|
.event{position:absolute;top:0;bottom:0;width:2px;background:#e8c547;cursor:help}
|
|
.event::before{content:'';position:absolute;top:0;left:-4px;width:10px;height:10px;background:#e8c547;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,.4)}
|
|
.event.click{background:#c97a3f}
|
|
.event.click::before{background:#c97a3f}
|
|
.event-label{position:absolute;top:12px;left:6px;background:rgba(15,24,20,.92);color:#e8e4d8;padding:2px 6px;border-radius:3px;font-size:.62rem;white-space:nowrap;pointer-events:none;border:1px solid #3d5165}
|
|
|
|
/* Audio-Pool unten */
|
|
.pool{background:#0a1310;border-top:2px solid #2d3e34;flex-shrink:0;max-height:280px;display:flex;flex-direction:column}
|
|
.pool-head{padding:.45rem .9rem;background:#152019;font-size:.78rem;font-weight:700;display:flex;align-items:center;gap:.7rem;border-bottom:1px solid #2d3e34}
|
|
.pool-head input{background:#2d3e34;color:#e8e4d8;border:1px solid #3d5165;padding:.25rem .5rem;border-radius:3px;font-family:inherit;font-size:.75rem;flex:1;max-width:240px}
|
|
.pool-head .filter-btn{background:#3d5165;color:#e8e4d8;border:none;padding:.22rem .55rem;border-radius:3px;font-size:.7rem;cursor:pointer;font-family:inherit}
|
|
.pool-head .filter-btn.on{background:#c97a3f}
|
|
.pool-list{overflow-y:auto;padding:.4rem .6rem;flex:1}
|
|
.pool-item{display:flex;align-items:center;gap:.7rem;padding:.32rem .55rem;background:#152019;border-radius:4px;margin-bottom:3px;cursor:grab;font-size:.72rem;border:1px solid transparent;user-select:none}
|
|
.pool-item:hover{background:#1f2e24;border-color:#3d5165}
|
|
.pool-item.used{opacity:.4;cursor:not-allowed}
|
|
.pool-item.dragging{opacity:.5;cursor:grabbing}
|
|
.pool-item .pi-id{font-family:'JetBrains Mono',monospace;color:#a8b9a0;min-width:200px}
|
|
.pool-item .pi-dur{font-family:'JetBrains Mono',monospace;color:#5a8a5e;min-width:50px;text-align:right;font-weight:700}
|
|
.pool-item .pi-text{color:#e8e4d8;flex:1;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.pool-item.no-dur .pi-dur{color:#c85c4a}
|
|
|
|
/* Hilfe-Footer */
|
|
.help{background:#0a1310;padding:.35rem 1rem;font-size:.68rem;color:#5a6b5a;border-top:1px solid #2d3e34;flex-shrink:0;display:flex;gap:1rem;flex-wrap:wrap}
|
|
.help b{color:#a8b9a0}
|
|
|
|
/* Loading-Overlay */
|
|
.loader{position:fixed;inset:0;background:rgba(15,24,20,.92);display:flex;align-items:center;justify-content:center;z-index:9999;color:#e8c547;font-weight:700;flex-direction:column;gap:.6rem}
|
|
.loader[hidden]{display:none}
|
|
.loader .progress{font-family:'JetBrains Mono',monospace;font-size:.85rem;color:#a8b9a0}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="topbar">
|
|
<h1>🎚 Heli Audio-Editor</h1>
|
|
<select id="missionSelect"></select>
|
|
<span class="status default" id="statusBadge">DEFAULT</span>
|
|
<button class="btn-save" id="btnSave">💾 Speichern</button>
|
|
<button class="btn-reset" id="btnReset" title="Auf Default-Vorschlag zurücksetzen">↩ Reset</button>
|
|
<button id="btnPlayInGame" title="Mission im Spiel öffnen (echter Test)">▶ Im Spiel öffnen</button>
|
|
<button id="btnRemeasure" title="Audio-Dauern für ALLE Audios neu messen (z.B. nach MP3-Update)">🔄 Dauern neu messen</button>
|
|
<div class="spacer"></div>
|
|
<a href="<?= $base ?>/heli-missionen">← Drehbuch</a>
|
|
<a href="<?= $base ?>/sim">Cockpit</a>
|
|
</div>
|
|
|
|
<div class="m-info" id="mInfo">—</div>
|
|
|
|
<div class="tl-wrap" id="tlWrap">
|
|
<div class="tl-zoom">
|
|
<span>Zoom:</span>
|
|
<button data-zoom="0.05">5 px/s</button>
|
|
<button data-zoom="0.1">10 px/s</button>
|
|
<button data-zoom="0.2" class="on">20 px/s</button>
|
|
<button data-zoom="0.5">50 px/s</button>
|
|
<button data-zoom="1.0">100 px/s</button>
|
|
<span style="margin-left:1rem">Audio-Gap:</span>
|
|
<input class="gap-input" id="gapInput" type="number" min="0" max="5" step="0.1" value="0.5">
|
|
<span style="margin-left:1rem;color:#5a6b5a">Drag-Audio zum Verschieben · Drop auf leere Stelle zum Hinzufügen · Klick markiert</span>
|
|
</div>
|
|
<div id="phasesHost"></div>
|
|
</div>
|
|
|
|
<div class="pool">
|
|
<div class="pool-head">
|
|
<span>🔊 Audio-Pool</span>
|
|
<input id="poolSearch" placeholder="Suche Audio-ID oder Text…">
|
|
<button class="filter-btn on" data-filter="all">Alle</button>
|
|
<button class="filter-btn" data-filter="unused">Ungenutzt</button>
|
|
<button class="filter-btn" data-filter="missing-dur">Ohne Dauer</button>
|
|
<span style="margin-left:auto;font-size:.7rem;color:#7a8a7a" id="poolStats">—</span>
|
|
</div>
|
|
<div class="pool-list" id="poolList"></div>
|
|
</div>
|
|
|
|
<div class="help">
|
|
<span><b>Drag</b> Audio-Balken oder Pool-Item zum Verschieben/Hinzufügen.</span>
|
|
<span><b>Klick</b> Audio zum Auswählen — <kbd>Entf</kbd> oder <kbd>Backspace</kbd> löscht.</span>
|
|
<span><b>Strg+S</b> speichert.</span>
|
|
<span><b>Pool-Items ohne Dauer</b> werden beim ersten Tool-Aufruf automatisch gemessen (siehe rote Sek).</span>
|
|
</div>
|
|
|
|
<div class="loader" id="loader" hidden>
|
|
<div>⏱ Audio-Dauern werden gemessen …</div>
|
|
<div class="progress" id="loaderProgress">0 / 0</div>
|
|
</div>
|
|
|
|
<script>
|
|
const BASE = <?= json_encode($base) ?>;
|
|
const API_BASE = BASE + '/api/heli-timeline';
|
|
|
|
// ============================================================
|
|
// STATE
|
|
// ============================================================
|
|
const state = {
|
|
missions: [], // [{id, title, ...}]
|
|
currentMid: null, // 'm1'
|
|
timeline: null, // { phases: [{id, audios:[], events:[]}] }
|
|
audios: [], // alle verfuegbaren Audios [{id, text, dur}]
|
|
durations: {}, // {audioId: seconds}
|
|
zoom: 20, // px pro Sekunde
|
|
selected: null, // { phaseId, audioId } | null
|
|
poolFilter: 'all',
|
|
poolSearch: '',
|
|
};
|
|
|
|
// ============================================================
|
|
// API
|
|
// ============================================================
|
|
async function apiFetch(url, opts = {}) {
|
|
const r = await fetch(url, opts);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status} ${url}`);
|
|
return r.json();
|
|
}
|
|
const api = {
|
|
missions: () => apiFetch(`${API_BASE}?missions=1`),
|
|
audios: () => apiFetch(`${API_BASE}?audios=1`),
|
|
durations: () => apiFetch(`${API_BASE}?durations=1`),
|
|
durSave: (durations) => apiFetch(`${API_BASE}?durations=1`, {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ durations }) }),
|
|
timeline: (mid) => apiFetch(`${API_BASE}?mission=${mid}`),
|
|
timelineSave: (mid, body) => apiFetch(`${API_BASE}?mission=${mid}`, {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify(body) }),
|
|
};
|
|
|
|
// ============================================================
|
|
// AUDIO-DAUERN MESSEN (clientseitig)
|
|
// ============================================================
|
|
async function measureDurations(audios) {
|
|
const need = audios.filter(a => a.dur == null);
|
|
if (!need.length) return {};
|
|
const loader = document.getElementById('loader');
|
|
const prog = document.getElementById('loaderProgress');
|
|
loader.hidden = false;
|
|
const measured = {};
|
|
for (let i = 0; i < need.length; i++) {
|
|
prog.textContent = `${i+1} / ${need.length}: ${need[i].id}`;
|
|
try {
|
|
const dur = await measureOne(need[i].id);
|
|
if (dur != null && isFinite(dur) && dur > 0.1) measured[need[i].id] = Math.round(dur * 10) / 10;
|
|
} catch(_) {}
|
|
}
|
|
// Cache speichern
|
|
if (Object.keys(measured).length) {
|
|
try { await api.durSave(measured); } catch(_) {}
|
|
Object.assign(state.durations, measured);
|
|
// audios-Liste updaten
|
|
state.audios.forEach(a => { if (measured[a.id] != null) a.dur = measured[a.id]; });
|
|
}
|
|
loader.hidden = true;
|
|
return measured;
|
|
}
|
|
function measureOne(audioId, cacheBust) {
|
|
return new Promise(resolve => {
|
|
const audio = new Audio();
|
|
audio.preload = 'metadata';
|
|
const bust = cacheBust ? '?_t=' + Date.now() : '';
|
|
const url = `${BASE}/sims/heli/sounds/radio/${audioId}.mp3` + bust;
|
|
let done = false;
|
|
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
audio.addEventListener('loadedmetadata', () => finish(audio.duration));
|
|
audio.addEventListener('error', () => finish(null));
|
|
setTimeout(() => finish(null), 6000);
|
|
audio.src = url;
|
|
});
|
|
}
|
|
|
|
// Force-Remeasure: alle Audios mit Cache-Buster neu messen + Backend updaten
|
|
async function measureDurationsForced(audios) {
|
|
const loader = document.getElementById('loader');
|
|
const prog = document.getElementById('loaderProgress');
|
|
loader.hidden = false;
|
|
const measured = {};
|
|
for (let i = 0; i < audios.length; i++) {
|
|
prog.textContent = `${i+1} / ${audios.length}: ${audios[i].id}`;
|
|
try {
|
|
const dur = await measureOne(audios[i].id, true); // cache-buster
|
|
if (dur != null && isFinite(dur) && dur > 0.1) measured[audios[i].id] = Math.round(dur * 10) / 10;
|
|
} catch(_) {}
|
|
}
|
|
if (Object.keys(measured).length) {
|
|
try { await api.durSave(measured); } catch(_) {}
|
|
Object.assign(state.durations, measured);
|
|
state.audios.forEach(a => { if (measured[a.id] != null) a.dur = measured[a.id]; });
|
|
}
|
|
loader.hidden = true;
|
|
return measured;
|
|
}
|
|
|
|
// ============================================================
|
|
// RENDERING
|
|
// ============================================================
|
|
function renderTopbar() {
|
|
const sel = document.getElementById('missionSelect');
|
|
sel.innerHTML = state.missions.map(m => {
|
|
const st = (m.status || 'default').toUpperCase();
|
|
return `<option value="${m.id}">${m.id.toUpperCase()} · ${m.title} (${m.region}) · ${st}</option>`;
|
|
}).join('');
|
|
sel.value = state.currentMid;
|
|
// Status-Badge
|
|
const mInfo = state.missions.find(m => m.id === state.currentMid);
|
|
const badge = document.getElementById('statusBadge');
|
|
badge.className = 'status ' + (mInfo?.status || 'default');
|
|
badge.textContent = (mInfo?.status || 'default').toUpperCase();
|
|
// Mission-Info-Zeile
|
|
document.getElementById('mInfo').innerHTML =
|
|
`<b>${mInfo?.title || state.currentMid}</b> · ${mInfo?.region || ''} · ` +
|
|
`Stützpunkt <b>${mInfo?.base || ''}</b> · Heli <b>${mInfo?.heliKey || ''}</b> · ` +
|
|
`Szenario <b>${mInfo?.scen || ''}</b> · Wegpunkte: <b>${(mInfo?.waypoints || []).length}</b>`;
|
|
}
|
|
|
|
function renderTimeline() {
|
|
const host = document.getElementById('phasesHost');
|
|
host.innerHTML = '';
|
|
state.timeline.phases.forEach(phase => host.appendChild(renderPhase(phase)));
|
|
}
|
|
|
|
function renderPhase(phase) {
|
|
const phaseDur = phaseEndTime(phase);
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'phase';
|
|
wrap.style.borderLeftColor = phase.color || '#3d5165';
|
|
wrap.innerHTML = `
|
|
<div class="phase-head">
|
|
<span>${phase.label || phase.id}</span>
|
|
<span class="dur">${phase.audios.length} Audios · ${phaseDur.toFixed(1)}s</span>
|
|
</div>
|
|
<div class="track track-events" data-phase="${phase.id}" data-track="events"></div>
|
|
<div class="track track-axis" data-phase="${phase.id}" data-track="axis"></div>
|
|
<div class="track track-audio" data-phase="${phase.id}" data-track="audio"></div>
|
|
`;
|
|
// Tracks befuellen
|
|
fillAxisTrack(wrap.querySelector('[data-track="axis"]'), phaseDur);
|
|
fillEventsTrack(wrap.querySelector('[data-track="events"]'), phase);
|
|
fillAudioTrack(wrap.querySelector('[data-track="audio"]'), phase);
|
|
return wrap;
|
|
}
|
|
|
|
function fillAxisTrack(el, totalSec) {
|
|
const width = Math.max(800, (totalSec + 10) * state.zoom);
|
|
el.style.width = width + 'px';
|
|
el.innerHTML = '';
|
|
const step = state.zoom >= 50 ? 1 : (state.zoom >= 20 ? 5 : 10);
|
|
for (let s = 0; s <= totalSec + 5; s += step) {
|
|
const tick = document.createElement('div');
|
|
tick.className = 'tick' + (s % (step * 2) === 0 ? ' major' : '');
|
|
tick.style.left = (s * state.zoom) + 'px';
|
|
el.appendChild(tick);
|
|
if (s % (step * 2) === 0) {
|
|
const lbl = document.createElement('div');
|
|
lbl.className = 'tick-label';
|
|
lbl.style.left = (s * state.zoom) + 'px';
|
|
lbl.textContent = fmtMmSs(s);
|
|
el.appendChild(lbl);
|
|
}
|
|
}
|
|
}
|
|
function fillEventsTrack(el, phase) {
|
|
const totalSec = phaseEndTime(phase);
|
|
el.style.width = Math.max(800, (totalSec + 10) * state.zoom) + 'px';
|
|
el.innerHTML = '';
|
|
phase.events.forEach(ev => {
|
|
const m = document.createElement('div');
|
|
m.className = 'event' + (ev.click ? ' click' : '');
|
|
m.style.left = (ev.t * state.zoom) + 'px';
|
|
m.title = `${fmtMmSs(ev.t)} — ${ev.label}`;
|
|
const lbl = document.createElement('div');
|
|
lbl.className = 'event-label';
|
|
lbl.textContent = ev.label;
|
|
m.appendChild(lbl);
|
|
el.appendChild(m);
|
|
});
|
|
}
|
|
function fillAudioTrack(el, phase) {
|
|
const totalSec = phaseEndTime(phase);
|
|
el.style.width = Math.max(800, (totalSec + 10) * state.zoom) + 'px';
|
|
el.innerHTML = '';
|
|
// Drop-Target fuer Pool-Drag
|
|
el.addEventListener('dragover', e => { e.preventDefault(); });
|
|
el.addEventListener('drop', e => onTrackDrop(e, phase));
|
|
phase.audios.forEach(a => el.appendChild(renderAudioBar(a, phase)));
|
|
}
|
|
function renderAudioBar(a, phase) {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'audio color-' + phase.id;
|
|
if (state.selected && state.selected.phaseId === phase.id && state.selected.audioId === a.id) {
|
|
bar.classList.add('selected');
|
|
}
|
|
if (state.playingAudioId === a.audioId) bar.classList.add('playing');
|
|
bar.draggable = true;
|
|
bar.style.left = (a.t * state.zoom) + 'px';
|
|
bar.style.width = Math.max(60, (a.dur || 3) * state.zoom) + 'px';
|
|
bar.title = `${a.audioId} @ ${fmtMmSs(a.t)} (${(a.dur||0).toFixed(1)}s)${a.note ? '\n'+a.note : ''}`;
|
|
bar.innerHTML = `
|
|
<button class="play-btn${state.playingAudioId === a.audioId ? ' playing' : ''}" data-aid="${a.audioId}" title="Audio abspielen">${state.playingAudioId === a.audioId ? '■' : '▶'}</button>
|
|
<div class="a-id">${a.audioId}</div>
|
|
<div class="a-note">${a.note || ''}</div>
|
|
`;
|
|
// Play-Button (links): klicken spielt Audio ab, propagiert NICHT zum Balken
|
|
const playBtn = bar.querySelector('.play-btn');
|
|
playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(a.audioId); });
|
|
playBtn.addEventListener('mousedown', e => e.stopPropagation());
|
|
playBtn.addEventListener('dragstart', e => e.preventDefault());
|
|
// Balken-Klick: markiert
|
|
bar.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
state.selected = { phaseId: phase.id, audioId: a.id };
|
|
renderTimeline();
|
|
});
|
|
// Drag
|
|
bar.addEventListener('dragstart', e => {
|
|
e.dataTransfer.setData('text/plain', JSON.stringify({
|
|
from: 'timeline', phaseId: phase.id, audioId: a.id
|
|
}));
|
|
bar.classList.add('dragging');
|
|
});
|
|
bar.addEventListener('dragend', () => bar.classList.remove('dragging'));
|
|
return bar;
|
|
}
|
|
|
|
// === Globaler Audio-Player (nur EIN Audio zur Zeit) ===
|
|
function togglePlay(audioId) {
|
|
// Wenn dieses Audio gerade spielt → stoppen
|
|
if (state.playingAudioId === audioId && state.currentAudio) {
|
|
state.currentAudio.pause();
|
|
state.currentAudio = null;
|
|
state.playingAudioId = null;
|
|
renderTimeline();
|
|
renderPool();
|
|
return;
|
|
}
|
|
// Anderes Audio läuft → stoppen
|
|
if (state.currentAudio) {
|
|
state.currentAudio.pause();
|
|
}
|
|
// Neues Audio starten (Cache-Buster, damit ueberschriebene MP3s
|
|
// nicht aus dem Browser-Cache kommen)
|
|
const audio = new Audio(`${BASE}/sims/heli/sounds/radio/${audioId}.mp3?_t=${Date.now()}`);
|
|
state.currentAudio = audio;
|
|
state.playingAudioId = audioId;
|
|
audio.addEventListener('ended', () => {
|
|
if (state.playingAudioId === audioId) {
|
|
state.currentAudio = null;
|
|
state.playingAudioId = null;
|
|
renderTimeline();
|
|
renderPool();
|
|
}
|
|
});
|
|
audio.addEventListener('error', () => {
|
|
state.currentAudio = null;
|
|
state.playingAudioId = null;
|
|
renderTimeline();
|
|
renderPool();
|
|
});
|
|
audio.play().catch(() => {
|
|
state.currentAudio = null;
|
|
state.playingAudioId = null;
|
|
renderTimeline();
|
|
renderPool();
|
|
});
|
|
renderTimeline();
|
|
renderPool();
|
|
}
|
|
|
|
function fmtMmSs(sec) {
|
|
sec = Math.max(0, sec);
|
|
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
|
|
return `${m}:${String(s).padStart(2,'0')}`;
|
|
}
|
|
|
|
function phaseEndTime(phase) {
|
|
let end = 0;
|
|
phase.audios.forEach(a => { end = Math.max(end, a.t + (a.dur || 3)); });
|
|
phase.events.forEach(e => { end = Math.max(end, e.t); });
|
|
return end;
|
|
}
|
|
|
|
// ============================================================
|
|
// AUDIO-POOL
|
|
// ============================================================
|
|
function renderPool() {
|
|
const list = document.getElementById('poolList');
|
|
const search = state.poolSearch.toLowerCase();
|
|
const usedIds = new Set();
|
|
state.timeline.phases.forEach(p => p.audios.forEach(a => usedIds.add(a.audioId)));
|
|
|
|
const filtered = state.audios.filter(a => {
|
|
if (state.poolFilter === 'unused' && usedIds.has(a.id)) return false;
|
|
if (state.poolFilter === 'missing-dur' && a.dur != null) return false;
|
|
if (search && !(a.id.toLowerCase().includes(search) || (a.text||'').toLowerCase().includes(search))) return false;
|
|
return true;
|
|
});
|
|
list.innerHTML = filtered.map(a => {
|
|
const used = usedIds.has(a.id);
|
|
const noDur = a.dur == null;
|
|
const isPlaying = state.playingAudioId === a.id;
|
|
return `<div class="pool-item${used ? ' used' : ''}${noDur ? ' no-dur' : ''}" draggable="${!used}" data-aid="${a.id}">
|
|
<button class="play-btn-pool${isPlaying ? ' playing' : ''}" data-play="${a.id}" title="Audio abspielen">${isPlaying ? '■' : '▶'}</button>
|
|
<span class="pi-id">${a.id}</span>
|
|
<span class="pi-dur">${noDur ? '?' : a.dur.toFixed(1) + 's'}</span>
|
|
<span class="pi-text">${esc(a.text || '')}</span>
|
|
</div>`;
|
|
}).join('');
|
|
// Play-Buttons im Pool
|
|
list.querySelectorAll('.play-btn-pool').forEach(btn => {
|
|
btn.addEventListener('click', e => { e.stopPropagation(); togglePlay(btn.dataset.play); });
|
|
btn.addEventListener('mousedown', e => e.stopPropagation());
|
|
btn.addEventListener('dragstart', e => e.preventDefault());
|
|
});
|
|
// Drag-Handler
|
|
list.querySelectorAll('.pool-item:not(.used)').forEach(el => {
|
|
el.addEventListener('dragstart', e => {
|
|
e.dataTransfer.setData('text/plain', JSON.stringify({
|
|
from: 'pool', audioId: el.dataset.aid
|
|
}));
|
|
el.classList.add('dragging');
|
|
});
|
|
el.addEventListener('dragend', () => el.classList.remove('dragging'));
|
|
});
|
|
document.getElementById('poolStats').textContent =
|
|
`${filtered.length} / ${state.audios.length} Audios · ${usedIds.size} in Timeline`;
|
|
}
|
|
|
|
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
|
|
// ============================================================
|
|
// DROP-HANDLER (Timeline)
|
|
// ============================================================
|
|
function onTrackDrop(e, phase) {
|
|
e.preventDefault();
|
|
const raw = e.dataTransfer.getData('text/plain');
|
|
if (!raw) return;
|
|
let data;
|
|
try { data = JSON.parse(raw); } catch(_) { return; }
|
|
// Drop-Position in Sekunden
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const dropT = Math.max(0, x / state.zoom);
|
|
|
|
if (data.from === 'pool') {
|
|
// Neues Audio aus Pool hinzufuegen
|
|
const audio = state.audios.find(a => a.id === data.audioId);
|
|
if (!audio) return;
|
|
const dur = audio.dur || 3;
|
|
phase.audios.push({
|
|
id: 'a' + Date.now(),
|
|
audioId: audio.id,
|
|
t: dropT,
|
|
dur: dur,
|
|
note: '',
|
|
});
|
|
fixOverlapsInPhase(phase);
|
|
markEdited();
|
|
renderTimeline();
|
|
renderPool();
|
|
} else if (data.from === 'timeline') {
|
|
// Bestehendes Audio verschieben (kann zwischen Phasen wandern)
|
|
let src = null, srcPhase = null;
|
|
state.timeline.phases.forEach(p => {
|
|
const found = p.audios.find(a => a.id === data.audioId);
|
|
if (found) { src = found; srcPhase = p; }
|
|
});
|
|
if (!src) return;
|
|
if (srcPhase !== phase) {
|
|
srcPhase.audios = srcPhase.audios.filter(a => a.id !== src.id);
|
|
phase.audios.push(src);
|
|
}
|
|
src.t = dropT;
|
|
fixOverlapsInPhase(phase);
|
|
if (srcPhase !== phase) fixOverlapsInPhase(srcPhase);
|
|
markEdited();
|
|
renderTimeline();
|
|
renderPool();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// OVERLAP-FIX (sequenz-garantie)
|
|
// ============================================================
|
|
function fixOverlapsInPhase(phase) {
|
|
const gap = state.timeline.gap ?? 0.5;
|
|
phase.audios.sort((a,b) => a.t - b.t);
|
|
for (let i = 1; i < phase.audios.length; i++) {
|
|
const prev = phase.audios[i-1];
|
|
const cur = phase.audios[i];
|
|
const minStart = prev.t + (prev.dur || 3) + gap;
|
|
if (cur.t < minStart) cur.t = minStart;
|
|
}
|
|
// Events neu interpolieren: pro Wegpunkt-Audio Event "sichtbar" vor und "erreicht" nach Audio
|
|
// (Auto-Update nur fuer von Atlas markierte Events mit waypoint-Feld)
|
|
phase.events.forEach(ev => {
|
|
if (!ev.waypoint) return;
|
|
// Suche das zugeordnete Audio
|
|
const aud = phase.audios.find(a => a.audioId === 'r_wp_geo_' + ev.waypoint);
|
|
if (!aud) return;
|
|
if (ev.click) ev.t = aud.t + (aud.dur || 3); // erreicht = nach Audio-Ende
|
|
else ev.t = aud.t; // sichtbar = bei Audio-Start
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// AKTIONEN
|
|
// ============================================================
|
|
function markEdited() {
|
|
state.timeline.edited = true;
|
|
const badge = document.getElementById('statusBadge');
|
|
badge.className = 'status edited';
|
|
badge.textContent = 'EDITED (ungespeichert)';
|
|
}
|
|
|
|
async function saveTimeline() {
|
|
try {
|
|
const r = await api.timelineSave(state.currentMid, state.timeline);
|
|
state.timeline.lastSaved = r.lastSaved;
|
|
const badge = document.getElementById('statusBadge');
|
|
badge.className = 'status saved';
|
|
badge.textContent = 'SAVED · ' + new Date(r.lastSaved).toLocaleTimeString('de-AT');
|
|
// Missions-Liste neu laden (Status update)
|
|
const ml = await api.missions();
|
|
state.missions = ml.missions;
|
|
renderTopbar();
|
|
} catch(e) {
|
|
alert('Speichern fehlgeschlagen: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function resetTimeline() {
|
|
if (!confirm('Wirklich auf Default-Vorschlag zurücksetzen? Alle Änderungen gehen verloren.')) return;
|
|
// Lösche Datei serverseitig durch leeren POST? Einfacher: GET ohne edited-Flag
|
|
// Pragmatisch: Datei loeschen via Backend gibt's nicht — wir setzen edited:false + ueberschreiben mit Default-GET
|
|
try {
|
|
// Nur lokal "vergessen" und neu vom Server holen geht nicht, da Default nur kommt wenn FILE FEHLT.
|
|
// Loesung: wir loeschen die Datei via POST mit {_reset:true}? — vereinfacht: einfach client-side neu generieren
|
|
// indem wir api.timeline aufrufen aber dabei spezielles Flag setzen. Pragmatisch: alte Phasen rauswerfen + neu vom Server holen
|
|
// Hier: einfach von Default neu laden via temporaerer Datei-Loeschung
|
|
// Vereinfachte Strategie: client holt Default neu via spezieller URL
|
|
const r = await fetch(`${API_BASE}?mission=${state.currentMid}&forceDefault=1`);
|
|
const tl = await r.json();
|
|
state.timeline = tl;
|
|
renderTimeline();
|
|
renderPool();
|
|
const badge = document.getElementById('statusBadge');
|
|
badge.className = 'status default';
|
|
badge.textContent = 'DEFAULT (ungespeichert)';
|
|
} catch(e) { alert('Reset fehlgeschlagen: ' + e.message); }
|
|
}
|
|
|
|
function deleteSelected() {
|
|
if (!state.selected) return;
|
|
const phase = state.timeline.phases.find(p => p.id === state.selected.phaseId);
|
|
if (!phase) return;
|
|
phase.audios = phase.audios.filter(a => a.id !== state.selected.audioId);
|
|
state.selected = null;
|
|
fixOverlapsInPhase(phase);
|
|
markEdited();
|
|
renderTimeline();
|
|
renderPool();
|
|
}
|
|
|
|
async function switchMission(mid) {
|
|
if (state.timeline && state.timeline.edited && !confirm('Ungespeicherte Änderungen verwerfen?')) return;
|
|
state.currentMid = mid;
|
|
state.selected = null;
|
|
state.timeline = await api.timeline(mid);
|
|
renderTopbar();
|
|
renderTimeline();
|
|
renderPool();
|
|
// URL update
|
|
history.replaceState(null, '', `?mission=${mid}`);
|
|
}
|
|
|
|
function changeZoom(z) {
|
|
state.zoom = parseFloat(z) * 100; // 0.05 => 5 px/s, 1.0 => 100 px/s
|
|
document.querySelectorAll('.tl-zoom button[data-zoom]').forEach(b => {
|
|
b.classList.toggle('on', parseFloat(b.dataset.zoom) === parseFloat(z));
|
|
});
|
|
renderTimeline();
|
|
}
|
|
|
|
// ============================================================
|
|
// BOOT
|
|
// ============================================================
|
|
async function boot() {
|
|
document.getElementById('missionSelect').addEventListener('change', e => switchMission(e.target.value));
|
|
document.getElementById('btnSave').addEventListener('click', saveTimeline);
|
|
document.getElementById('btnReset').addEventListener('click', resetTimeline);
|
|
document.getElementById('btnPlayInGame').addEventListener('click', () => {
|
|
window.open(`${BASE}/heli-game?mission=${state.currentMid}`, '_blank');
|
|
});
|
|
document.getElementById('btnRemeasure').addEventListener('click', async () => {
|
|
if (!confirm('Alle ' + state.audios.length + ' Audio-Dauern neu messen? Dauert ca. 1-2 Minuten.')) return;
|
|
// Cache komplett leeren (lokal)
|
|
state.audios.forEach(a => a.dur = null);
|
|
state.durations = {};
|
|
// Browser-Cache fuer Audio-Files leeren ist nicht direkt moeglich,
|
|
// aber neu-messen mit Cache-Buster erzwingt frische Daten
|
|
await measureDurationsForced(state.audios);
|
|
renderTimeline();
|
|
renderPool();
|
|
alert('Fertig — ' + Object.keys(state.durations).length + ' Audios gemessen.');
|
|
});
|
|
document.getElementById('gapInput').addEventListener('change', e => {
|
|
state.timeline.gap = parseFloat(e.target.value) || 0.5;
|
|
state.timeline.phases.forEach(fixOverlapsInPhase);
|
|
markEdited();
|
|
renderTimeline();
|
|
});
|
|
document.querySelectorAll('.tl-zoom button[data-zoom]').forEach(b => {
|
|
b.addEventListener('click', () => changeZoom(b.dataset.zoom));
|
|
});
|
|
document.getElementById('poolSearch').addEventListener('input', e => {
|
|
state.poolSearch = e.target.value;
|
|
renderPool();
|
|
});
|
|
document.querySelectorAll('.pool-head .filter-btn').forEach(b => {
|
|
b.addEventListener('click', () => {
|
|
state.poolFilter = b.dataset.filter;
|
|
document.querySelectorAll('.pool-head .filter-btn').forEach(x => x.classList.toggle('on', x === b));
|
|
renderPool();
|
|
});
|
|
});
|
|
document.addEventListener('keydown', e => {
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && state.selected && e.target.tagName !== 'INPUT') {
|
|
e.preventDefault();
|
|
deleteSelected();
|
|
}
|
|
if (e.ctrlKey && e.key === 's') {
|
|
e.preventDefault();
|
|
saveTimeline();
|
|
}
|
|
});
|
|
// Klick auf Timeline-Hintergrund deselektiert
|
|
document.getElementById('tlWrap').addEventListener('click', e => {
|
|
if (!e.target.closest('.audio')) { state.selected = null; renderTimeline(); }
|
|
});
|
|
|
|
// Daten laden
|
|
const [ml, al, dl] = await Promise.all([api.missions(), api.audios(), api.durations()]);
|
|
state.missions = ml.missions;
|
|
state.audios = al.audios;
|
|
state.durations = dl.durations || {};
|
|
|
|
// Mission aus URL oder erste
|
|
const urlMid = new URLSearchParams(location.search).get('mission');
|
|
state.currentMid = (urlMid && state.missions.find(m => m.id === urlMid)) ? urlMid : state.missions[0].id;
|
|
|
|
// Audio-Dauern fuer fehlende messen
|
|
await measureDurations(state.audios);
|
|
|
|
// Timeline laden
|
|
state.timeline = await api.timeline(state.currentMid);
|
|
renderTopbar();
|
|
renderTimeline();
|
|
renderPool();
|
|
}
|
|
|
|
boot().catch(err => {
|
|
document.body.innerHTML = `<pre style="padding:2rem;color:#c85c4a">Boot-Fehler:\n${err.stack || err}</pre>`;
|
|
});
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|