Files
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- 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>
2026-07-08 02:27:02 +02:00

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>