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>
292 lines
12 KiB
PHP
292 lines
12 KiB
PHP
<?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>
|