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

292 lines
12 KiB
PHP
Raw Permalink 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.
<?php
/**
* Heli-Playlist — alle Audios einer Mission in einer Wurst hintereinander.
*
* Review-Tool fuer Thomas: schnelles Anhoeren aller Audios pro Mission
* (Plan-Phase + Flug-Phase) in der Timeline-Reihenfolge.
*
* URL: /heli-playlist?mission=m2 oder /heli-playlist?mission=all
*/
require_once __DIR__ . '/../../php/config/app.php';
$base = BASE_PATH;
$missionsJ = json_decode(file_get_contents(__DIR__ . '/../../sims/heli/data/missions.json'), true);
$missions = $missionsJ['missions'] ?? [];
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Heli-Playlist — Alle Audios in einer Wurst</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}
body{font-family:'Inter',system-ui,sans-serif;background:#1f2a24;color:#e8e4d8;min-height:100vh;display:flex;flex-direction:column}
.topbar{background:#0f1814;padding:.6rem 1rem;display:flex;align-items:center;gap:.7rem;border-bottom:1px solid #2d3e34;flex-wrap:wrap}
.topbar h1{font-size:1rem;font-weight:800;color:#e8c547}
.topbar select{background:#1f2a24;color:#e8e4d8;border:1px solid #3d5165;padding:.32rem .55rem;border-radius:5px;font-family:inherit;font-size:.85rem}
.topbar button{background:#c97a3f;color:#fff;border:none;padding:.4rem .9rem;border-radius:5px;font-weight:700;font-size:.78rem;cursor:pointer;font-family:inherit}
.topbar button:hover{background:#b06020}
.topbar button.stop{background:#c85c4a}
.topbar button.skip{background:#3d5165}
.topbar .spacer{flex:1}
.topbar .speed{display:flex;align-items:center;gap:.4rem;font-size:.78rem}
.topbar .speed input{background:#2d3e34;color:#e8e4d8;border:1px solid #3d5165;padding:.2rem .4rem;border-radius:3px;width:60px;font-family:'JetBrains Mono',monospace}
.topbar a{color:#7a8a7a;text-decoration:none;font-size:.75rem;margin-left:.5rem}
.topbar a:hover{color:#e8c547}
main{flex:1;display:flex;overflow:hidden}
.now-playing{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:2rem;background:linear-gradient(135deg,#152019 0%,#0f1814 100%)}
.np-mission{font-size:.85rem;color:#7a8a7a;margin-bottom:.5rem;text-transform:uppercase;letter-spacing:.1em}
.np-id{font-family:'JetBrains Mono',monospace;font-size:1.2rem;font-weight:800;color:#e8c547;margin-bottom:1rem}
.np-phase{font-size:.8rem;color:#5a8a5e;text-transform:uppercase;letter-spacing:.05em;margin-bottom:1rem}
.np-text{font-size:1.4rem;line-height:1.5;color:#e8e4d8;font-style:italic;text-align:center;max-width:700px;margin-bottom:2rem;min-height:5rem}
.np-progress{width:90%;max-width:600px;height:8px;background:#2d3e34;border-radius:4px;overflow:hidden;margin-bottom:.8rem}
.np-progress-bar{height:100%;background:linear-gradient(90deg,#5a8a5e,#e8c547);transition:width .2s linear;width:0}
.np-stats{font-family:'JetBrains Mono',monospace;font-size:.85rem;color:#a8b9a0}
.sidebar{width:380px;background:#152019;border-left:1px solid #2d3e34;overflow-y:auto;display:flex;flex-direction:column}
.sb-head{padding:.6rem .9rem;background:#0a1310;font-size:.78rem;font-weight:700;color:#a8b9a0;border-bottom:1px solid #2d3e34;display:flex;justify-content:space-between}
.pl-item{padding:.5rem .8rem;border-bottom:1px solid #2d3e34;cursor:pointer;display:flex;align-items:center;gap:.6rem;font-size:.74rem;transition:background .15s}
.pl-item:hover{background:#1f2e24}
.pl-item.playing{background:#3d5165;border-left:4px solid #e8c547;padding-left:calc(.8rem - 4px)}
.pl-item.done{opacity:.45}
.pl-item .pl-num{font-family:'JetBrains Mono',monospace;color:#7a8a7a;min-width:24px}
.pl-item .pl-id{font-family:'JetBrains Mono',monospace;color:#a8b9a0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pl-item .pl-dur{font-family:'JetBrains Mono',monospace;color:#5a8a5e;font-weight:700;min-width:42px;text-align:right}
.pl-section{padding:.4rem .9rem;background:#0a1310;font-size:.7rem;font-weight:700;color:#5a8a5e;text-transform:uppercase;letter-spacing:.08em;border-top:1px solid #2d3e34;border-bottom:1px solid #2d3e34}
.empty{padding:2rem;text-align:center;color:#5a6b5a;font-style:italic}
</style>
</head>
<body>
<div class="topbar">
<h1>🎧 Heli-Playlist</h1>
<select id="missionSel">
<option value="all">— Alle Missionen —</option>
<?php foreach ($missions as $m): ?>
<option value="<?= htmlspecialchars($m['id']) ?>"><?= strtoupper($m['id']) ?> · <?= htmlspecialchars($m['title']) ?> (<?= htmlspecialchars($m['region']) ?>)</option>
<?php endforeach; ?>
</select>
<button id="btnPlay">▶ Play</button>
<button id="btnPause" class="stop">⏸ Pause</button>
<button id="btnSkip" class="skip">⏭ Skip</button>
<div class="spacer"></div>
<div class="speed">
<label>Speed:</label>
<input id="speedInput" type="number" min="0.5" max="2" step="0.05" value="1.25">
<button id="btnSpeed1">1.0×</button>
<button id="btnSpeed125">1.25×</button>
<button id="btnSpeed15">1.5×</button>
</div>
<a href="<?= $base ?>/heli-audio-editor">Editor</a>
<a href="<?= $base ?>/heli-missionen">Drehbuch</a>
</div>
<main>
<div class="now-playing">
<div class="np-mission" id="npMission">—</div>
<div class="np-phase" id="npPhase">—</div>
<div class="np-id" id="npId">noch nichts</div>
<div class="np-text" id="npText">Wähle eine Mission und klick auf Play</div>
<div class="np-progress"><div class="np-progress-bar" id="npBar"></div></div>
<div class="np-stats" id="npStats">—</div>
</div>
<div class="sidebar">
<div class="sb-head">
<span>Playlist</span>
<span id="plCount">0 Tracks</span>
</div>
<div id="plList"><div class="empty">Lade Mission …</div></div>
</div>
</main>
<script>
const BASE = <?= json_encode($base) ?>;
const API = BASE + '/api/heli-timeline';
const MISSIONS = <?= json_encode($missions, JSON_UNESCAPED_UNICODE) ?>;
let playlist = []; // [{missionId, missionTitle, phase, audioId, text, dur}]
let idx = -1;
let audio = null;
let isPlaying = false;
let currentMission = 'all';
let audioTexts = {}; // map audioId → text (für Anzeige)
async function loadAudioTexts() {
try {
const r = await fetch(API + '?audios=1');
const d = await r.json();
(d.audios || []).forEach(a => { audioTexts[a.id] = a.text; });
} catch(e) { console.warn(e); }
}
async function buildPlaylist(missionId) {
playlist = [];
const targets = missionId === 'all' ? MISSIONS : MISSIONS.filter(m => m.id === missionId);
for (const m of targets) {
try {
const r = await fetch(API + '?mission=' + m.id);
const tl = await r.json();
for (const phase of (tl.phases || [])) {
for (const slot of (phase.audios || [])) {
playlist.push({
missionId: m.id,
missionTitle: m.title,
phase: phase.id,
audioId: slot.audioId,
text: audioTexts[slot.audioId] || '',
dur: slot.dur || 5,
});
}
}
} catch(e) { console.warn(`Mission ${m.id} fehlt`, e); }
}
renderList();
}
function renderList() {
const el = document.getElementById('plList');
if (!playlist.length) { el.innerHTML = '<div class="empty">Keine Audios</div>'; return; }
let lastMission = null, lastPhase = null;
let html = '';
playlist.forEach((t, i) => {
if (t.missionId !== lastMission || t.phase !== lastPhase) {
html += `<div class="pl-section">${t.missionId.toUpperCase()} · ${t.phase}</div>`;
lastMission = t.missionId;
lastPhase = t.phase;
}
const cls = i === idx ? 'playing' : (i < idx ? 'done' : '');
html += `<div class="pl-item ${cls}" onclick="jumpTo(${i})">
<span class="pl-num">${i + 1}</span>
<span class="pl-id">${t.audioId}</span>
<span class="pl-dur">${t.dur.toFixed(1)}s</span>
</div>`;
});
el.innerHTML = html;
document.getElementById('plCount').textContent = `${playlist.length} Tracks`;
// Aktuell-markiertes ins Sichtfeld
const cur = el.querySelector('.pl-item.playing');
if (cur) cur.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
function updateNowPlaying() {
if (idx < 0 || idx >= playlist.length) {
document.getElementById('npMission').textContent = '—';
document.getElementById('npPhase').textContent = '—';
document.getElementById('npId').textContent = 'noch nichts';
document.getElementById('npText').textContent = 'Wähle eine Mission und klick auf Play';
document.getElementById('npStats').textContent = '—';
return;
}
const t = playlist[idx];
document.getElementById('npMission').textContent = `${t.missionId.toUpperCase()} · ${t.missionTitle}`;
document.getElementById('npPhase').textContent = t.phase;
document.getElementById('npId').textContent = t.audioId;
document.getElementById('npText').textContent = t.text || '(kein Text in audio-texts.json)';
document.getElementById('npStats').textContent = `Track ${idx + 1} / ${playlist.length} · ~${t.dur.toFixed(1)}s`;
}
function playCurrent() {
if (idx < 0 || idx >= playlist.length) return;
if (audio) { audio.pause(); audio = null; }
const t = playlist[idx];
const speed = parseFloat(document.getElementById('speedInput').value) || 1.25;
audio = new Audio(BASE + '/sims/heli/sounds/radio/' + t.audioId + '.mp3?_t=' + Date.now());
audio.preservesPitch = true;
audio.mozPreservesPitch = true;
audio.webkitPreservesPitch = true;
audio.playbackRate = speed;
audio.addEventListener('timeupdate', () => {
if (!audio || !audio.duration) return;
const pct = (audio.currentTime / audio.duration) * 100;
document.getElementById('npBar').style.width = pct + '%';
});
audio.addEventListener('ended', () => {
if (!isPlaying) return;
next();
});
audio.addEventListener('error', () => {
console.warn('Audio-Fehler', t.audioId);
if (isPlaying) setTimeout(next, 300);
});
audio.play().catch(err => console.warn('play failed', err));
updateNowPlaying();
renderList();
}
function next() {
if (idx + 1 >= playlist.length) {
stop();
document.getElementById('npId').textContent = '✓ Playlist fertig';
return;
}
idx++;
playCurrent();
}
function jumpTo(i) {
idx = i;
isPlaying = true;
playCurrent();
}
function play() {
if (!playlist.length) return;
isPlaying = true;
if (audio && audio.paused) {
audio.play();
} else if (idx < 0) {
idx = 0;
playCurrent();
}
}
function pause() {
isPlaying = false;
if (audio) audio.pause();
}
function stop() {
isPlaying = false;
if (audio) { audio.pause(); audio = null; }
document.getElementById('npBar').style.width = '0';
}
function skip() {
if (audio) audio.pause();
next();
}
// === Setup ===
document.getElementById('btnPlay').addEventListener('click', play);
document.getElementById('btnPause').addEventListener('click', pause);
document.getElementById('btnSkip').addEventListener('click', skip);
document.getElementById('btnSpeed1').addEventListener('click', () => { document.getElementById('speedInput').value = '1.0'; if (audio) audio.playbackRate = 1.0; });
document.getElementById('btnSpeed125').addEventListener('click', () => { document.getElementById('speedInput').value = '1.25'; if (audio) audio.playbackRate = 1.25; });
document.getElementById('btnSpeed15').addEventListener('click', () => { document.getElementById('speedInput').value = '1.5'; if (audio) audio.playbackRate = 1.5; });
document.getElementById('speedInput').addEventListener('change', e => {
if (audio) audio.playbackRate = parseFloat(e.target.value) || 1.25;
});
document.getElementById('missionSel').addEventListener('change', async e => {
stop();
currentMission = e.target.value;
idx = -1;
await buildPlaylist(currentMission);
updateNowPlaying();
history.replaceState(null, '', '?mission=' + currentMission);
});
(async function init() {
await loadAudioTexts();
const urlMid = new URLSearchParams(location.search).get('mission') || 'all';
document.getElementById('missionSel').value = urlMid;
currentMission = urlMid;
await buildPlaylist(urlMid);
})();
</script>
</body>
</html>