461 lines
16 KiB
PHP
461 lines
16 KiB
PHP
<?php
|
|
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 1);
|
|
|
|
// beamer.php — SongBeamer Beamer v2.0 (Projektor-Anzeige)
|
|
// Läuft mit PHP 8+. Keine Datenbank nötig.
|
|
|
|
const SONGS_DIR = __DIR__ . DIRECTORY_SEPARATOR . 'files' . DIRECTORY_SEPARATOR . 'songs';
|
|
const STATE_FILE = __DIR__ . DIRECTORY_SEPARATOR . 'sync.json';
|
|
|
|
// Migration: sync.txt -> sync.json (einmalig)
|
|
$legacyFile = __DIR__ . DIRECTORY_SEPARATOR . 'sync.txt';
|
|
if (!file_exists(STATE_FILE) && file_exists($legacyFile)) {
|
|
@rename($legacyFile, STATE_FILE);
|
|
}
|
|
|
|
// Songs-Ordner sicherstellen
|
|
if (!is_dir(SONGS_DIR)) { @mkdir(SONGS_DIR, 0775, true); }
|
|
|
|
// ----- Pfad-Helper -----
|
|
function norm_path($p) {
|
|
$r = realpath($p);
|
|
if ($r === false) return false;
|
|
$r = str_replace('\\', '/', $r);
|
|
if (DIRECTORY_SEPARATOR === '\\') $r = strtolower($r);
|
|
return rtrim($r, '/');
|
|
}
|
|
function is_path_inside($base, $path) {
|
|
$b = norm_path($base);
|
|
$p = norm_path($path);
|
|
if ($b === false || $p === false) return false;
|
|
return strpos($p, $b) === 0;
|
|
}
|
|
|
|
// ----- Datei lesen & UTF-8 -----
|
|
function read_file_utf8($path) {
|
|
$bin = @file_get_contents($path);
|
|
if ($bin === false) return '';
|
|
$bin = preg_replace("/^\xEF\xBB\xBF/", '', $bin);
|
|
return $bin;
|
|
}
|
|
|
|
// ----- .sng Parser -----
|
|
function parse_sng($path) {
|
|
$raw = read_file_utf8($path);
|
|
$lines = preg_split("/(\r\n|\r|\n)/", $raw);
|
|
$meta = [];
|
|
$sections = [];
|
|
$current = null;
|
|
$nonMetaAll = [];
|
|
|
|
foreach ($lines as $ln) {
|
|
$r = rtrim($ln, "\r\n");
|
|
if (preg_match('/^#(\w+)=(.*)$/', $r, $m)) { $meta[$m[1]] = $m[2]; continue; }
|
|
if (preg_match('/^\s*\[(.+?)\]\s*$/u', $r, $m)) {
|
|
if ($current) $sections[] = $current;
|
|
$current = ['name' => $m[1], 'text' => ''];
|
|
continue;
|
|
}
|
|
$nonMetaAll[] = $r;
|
|
if ($current) $current['text'] .= ($current['text'] === '' ? '' : "\n") . $r;
|
|
}
|
|
if ($current) $sections[] = $current;
|
|
|
|
if (!$sections) {
|
|
$body = trim(implode("\n", $nonMetaAll));
|
|
$sections = [['name' => $meta['Title'] ?? basename($path, '.sng'), 'text' => $body]];
|
|
}
|
|
foreach ($sections as &$s) {
|
|
$normalized = str_replace("\r", '', $s['text']);
|
|
$slides = preg_split('/\n\s*\n|^\s*---+\s*$/m', trim($normalized));
|
|
$s['slides'] = array_values(array_filter(array_map('trim', $slides), fn($x) => $x !== ''));
|
|
}
|
|
return ['meta' => $meta, 'sections' => $sections];
|
|
}
|
|
|
|
// ----- JSON-Ausgabe -----
|
|
function json_out($data) {
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
// ==================== API Router ====================
|
|
$action = $_GET['action'] ?? null;
|
|
|
|
if ($action === 'song') {
|
|
$rel = $_GET['file'] ?? '';
|
|
$rel = ltrim(str_replace(['..', '\\'], ['', '/'], $rel), '/');
|
|
$abs = SONGS_DIR . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
|
$absReal = realpath($abs);
|
|
if (!$absReal || !is_path_inside(SONGS_DIR, $absReal) || !is_file($absReal) || strtolower(pathinfo($absReal, PATHINFO_EXTENSION)) !== 'sng') {
|
|
http_response_code(404);
|
|
json_out(['ok' => false, 'error' => 'Song not found']);
|
|
}
|
|
$parsed = parse_sng($absReal);
|
|
$info = [
|
|
'title' => $parsed['meta']['Title'] ?? pathinfo($absReal, PATHINFO_FILENAME),
|
|
'langcount' => (int)($parsed['meta']['LangCount'] ?? 1),
|
|
'author' => $parsed['meta']['Author'] ?? ($parsed['meta']['Writer'] ?? ''),
|
|
'copyright' => $parsed['meta']['Copyright'] ?? '',
|
|
'churchsongid' => $parsed['meta']['ChurchSongID'] ?? ''
|
|
];
|
|
json_out(['ok' => true, 'info' => $info, 'sections' => $parsed['sections']]);
|
|
}
|
|
if ($action === 'get_state') {
|
|
if (file_exists(STATE_FILE)) {
|
|
$data = json_decode(file_get_contents(STATE_FILE), true);
|
|
json_out(['ok' => true, 'state' => $data]);
|
|
} else {
|
|
json_out(['ok' => true, 'state' => null]);
|
|
}
|
|
}
|
|
?>
|
|
<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>SongBeamer Beamer</title>
|
|
<style>
|
|
html { scrollbar-width: none }
|
|
:root {
|
|
--bg: #0b0d10; --fg: #e8edf2; --muted: #93a1ad; --border: #26303a;
|
|
--lang1: #ffffff; --lang2: #5fa8ff; --lang3: #ffff00; --lang4: #00ff00;
|
|
--font-scale: 1;
|
|
}
|
|
* { box-sizing: border-box }
|
|
html, body { height: 100%; margin: 0 }
|
|
body { background: var(--bg); color: var(--fg); font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial, sans-serif; cursor: none; }
|
|
.viewer { position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #000; height: 100%; }
|
|
.slide { width: 100%; min-height: 100vh; background: #000; display: flex; align-items: center; justify-content: center; text-align: center; padding: 6vh 4vw; }
|
|
.slide pre { white-space: pre-wrap; word-wrap: break-word; font: inherit; line-height: 1.15; margin: 0; font-size: calc(6.5vw * var(--font-scale)); }
|
|
.lang1 { color: var(--lang1) } .lang2 { color: var(--lang2) } .lang3 { color: var(--lang3) } .lang4 { color: var(--lang4) }
|
|
.section-header { color: var(--fg); font-weight: bold; }
|
|
.slide.black { background: black !important; }
|
|
.slide.black pre { display: none; }
|
|
/* Verbindungsstatus-Indikator */
|
|
#conn-dot {
|
|
position: fixed; top: 10px; right: 10px;
|
|
width: 9px; height: 9px; border-radius: 50%;
|
|
background: #444; transition: background .4s;
|
|
z-index: 10;
|
|
}
|
|
#conn-dot.ok { background: #2ecc40; }
|
|
#conn-dot.err { background: #ff4136; }
|
|
.frame-view { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 5; }
|
|
.frame-view iframe { width: 100%; height: 100%; border: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="conn-dot" title="Verbindung"></div>
|
|
<div class="viewer" id="viewer"><div class="slide"><pre>Keine Auswahl</pre></div></div>
|
|
|
|
<script>
|
|
// ---------- Kurz-Helper ----------
|
|
const qs = s => document.querySelector(s);
|
|
const elViewer = qs('#viewer');
|
|
const elConnDot = qs('#conn-dot');
|
|
|
|
let state = {
|
|
song: null, flatSlides: [], slideIndex: 0, langcount: 1, fontScale: 1,
|
|
visibleLangs: new Set(), blackScreen: false, hasMarkers: false,
|
|
colors: {1:'#ffffff',2:'#5fa8ff',3:'#ffff00',4:'#00ff00'},
|
|
lastRel: null, customText: null, bibleRef: null, frameUrl: null
|
|
};
|
|
|
|
// ---------- HTML-Escape ----------
|
|
function escapeHtml(s) {
|
|
return s.replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
|
|
}
|
|
|
|
// ---------- Song laden ----------
|
|
async function loadSong(rel) {
|
|
if (!rel) return;
|
|
const res = await fetch('?action=song&file=' + encodeURIComponent(rel), {cache:'no-store'});
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
state.song = data;
|
|
let maxLang = parseInt(data.info.langcount) || 1;
|
|
let hasMarkersInSong = false;
|
|
const flat = [];
|
|
for (const sec of data.sections) {
|
|
if (!sec.slides || !sec.slides.length) continue;
|
|
for (const slide of sec.slides) flat.push({ section: sec.name, text: slide });
|
|
}
|
|
for (const slide of flat) {
|
|
for (const m of slide.text.matchAll(/#(\d+)#/g)) {
|
|
maxLang = Math.max(maxLang, parseInt(m[1]));
|
|
hasMarkersInSong = true;
|
|
}
|
|
}
|
|
state.langcount = Math.min(4, maxLang);
|
|
state.hasMarkers = hasMarkersInSong;
|
|
state.flatSlides = flat;
|
|
state.lastRel = rel;
|
|
}
|
|
|
|
// ---------- Folie rendern ----------
|
|
function renderSlide() {
|
|
// Frame-Ansicht entfernen wenn nicht im Frame-Modus
|
|
const oldFrame = elViewer.querySelector('.frame-view');
|
|
|
|
if (state.blackScreen) {
|
|
if (oldFrame) oldFrame.remove();
|
|
elViewer.innerHTML = '<div class="slide black"><pre></pre></div>';
|
|
return;
|
|
}
|
|
|
|
// Frame URL Modus
|
|
if (state.type === 'frame' || state.frameUrl) {
|
|
// Bestehende Slide entfernen
|
|
const slide = elViewer.querySelector('.slide');
|
|
if (slide) slide.remove();
|
|
|
|
if (oldFrame) {
|
|
const iframe = oldFrame.querySelector('iframe');
|
|
if (iframe && iframe.getAttribute('data-url') !== state.frameUrl) {
|
|
iframe.src = state.frameUrl;
|
|
iframe.setAttribute('data-url', state.frameUrl);
|
|
}
|
|
} else {
|
|
const div = document.createElement('div');
|
|
div.className = 'frame-view';
|
|
const iframe = document.createElement('iframe');
|
|
iframe.src = state.frameUrl;
|
|
iframe.setAttribute('data-url', state.frameUrl);
|
|
iframe.setAttribute('allowfullscreen', '');
|
|
div.appendChild(iframe);
|
|
elViewer.innerHTML = '';
|
|
elViewer.appendChild(div);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Kein Frame-Modus - aufräumen
|
|
if (oldFrame) oldFrame.remove();
|
|
|
|
// Diashow Modus
|
|
if (state.type === 'diashow') {
|
|
if (!state.diashowMedia || !state.diashowMedia.length) {
|
|
elViewer.innerHTML = '<div class="slide"><pre>Keine Medien</pre></div>';
|
|
return;
|
|
}
|
|
const idx = Math.max(0, Math.min(state.slideIndex, state.diashowMedia.length - 1));
|
|
const m = state.diashowMedia[idx];
|
|
const settings = state.diashowSettings || { scaling: 'cover', timer: 0 };
|
|
|
|
clearTimeout(window.diashowTimer);
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'diashow-view';
|
|
div.style.width = '100%';
|
|
div.style.height = '100%';
|
|
div.style.background = '#000';
|
|
div.style.display = 'flex';
|
|
div.style.alignItems = 'center';
|
|
div.style.justifyContent = 'center';
|
|
div.style.overflow = 'hidden';
|
|
|
|
let objectFit = 'contain';
|
|
if (settings.scaling === 'cover') objectFit = 'cover';
|
|
if (settings.scaling === 'fill') objectFit = 'fill';
|
|
|
|
if (m.ext === 'mp4' || m.ext === 'webm' || m.ext === 'mov') {
|
|
const vid = document.createElement('video');
|
|
vid.src = m.url;
|
|
vid.style.width = '100%';
|
|
vid.style.height = '100%';
|
|
vid.style.objectFit = objectFit;
|
|
vid.autoplay = true;
|
|
if (settings.timer === 0) {
|
|
vid.loop = true;
|
|
} else {
|
|
vid.onended = () => { advanceDiashow(); };
|
|
}
|
|
div.appendChild(vid);
|
|
} else {
|
|
const img = document.createElement('img');
|
|
img.src = m.url;
|
|
img.style.width = '100%';
|
|
img.style.height = '100%';
|
|
img.style.objectFit = objectFit;
|
|
div.appendChild(img);
|
|
|
|
if (settings.timer > 0) {
|
|
window.diashowTimer = setTimeout(() => {
|
|
advanceDiashow();
|
|
}, settings.timer * 1000);
|
|
}
|
|
}
|
|
|
|
elViewer.innerHTML = '';
|
|
elViewer.appendChild(div);
|
|
return;
|
|
}
|
|
|
|
if (state.customText) {
|
|
let html = '';
|
|
if (state.bibleRef) html += `<div class="section-header" style="margin-bottom:1vh">${escapeHtml(state.bibleRef)}</div>`;
|
|
html += `<div>${escapeHtml(state.customText)}</div>`;
|
|
elViewer.innerHTML = `<div class="slide"><pre>${html}</pre></div>`;
|
|
return;
|
|
}
|
|
if (!state.flatSlides.length) { elViewer.innerHTML = '<div class="slide"><pre>Keine Folien</pre></div>'; return; }
|
|
|
|
const idx = Math.max(0, Math.min(state.slideIndex, state.flatSlides.length - 1));
|
|
const s = state.flatSlides[idx];
|
|
const lines = s.text.split(/\n/);
|
|
let html = '';
|
|
const mod = state.langcount;
|
|
const useColors = (mod > 1 && state.visibleLangs.size > 1);
|
|
const useMarkers = state.hasMarkers;
|
|
let langIndex = 0;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (i === 0 && /^\s*(Vers|Refrain|Strophe)(\s+\d+)?\s*$/i.test(line)) {
|
|
html += `<div class="section-header">${escapeHtml(line)}</div>`;
|
|
langIndex = 0;
|
|
} else {
|
|
let this_lang, l = line;
|
|
const m = line.match(/^\s*#(\d+)#\s*(.*)$/u);
|
|
if (useMarkers) { this_lang = m ? parseInt(m[1]) : 1; if (m) l = m[2]; }
|
|
else { langIndex++; this_lang = ((langIndex - 1) % mod) + 1; }
|
|
if (state.visibleLangs.size === 0 || state.visibleLangs.has(this_lang)) {
|
|
html += useColors
|
|
? `<div class="lang${this_lang}">${escapeHtml(l)}</div>`
|
|
: `<div>${escapeHtml(l)}</div>`;
|
|
}
|
|
}
|
|
}
|
|
elViewer.innerHTML = `<div class="slide"><pre>${html}</pre></div>`;
|
|
}
|
|
|
|
// ---------- State anwenden ----------
|
|
async function applyState(s) {
|
|
if (!s) return;
|
|
let changed = false;
|
|
|
|
if (s.type && s.type !== state.type) { state.type = s.type; changed = true; }
|
|
|
|
if (s.type === 'diashow') {
|
|
if (JSON.stringify(s.diashowMedia) !== JSON.stringify(state.diashowMedia)) {
|
|
state.diashowMedia = s.diashowMedia; changed = true;
|
|
}
|
|
if (JSON.stringify(s.diashowSettings) !== JSON.stringify(state.diashowSettings)) {
|
|
state.diashowSettings = s.diashowSettings; changed = true;
|
|
}
|
|
}
|
|
|
|
if (s.customText !== state.customText) { state.customText = s.customText; changed = true; }
|
|
if (s.bibleRef !== state.bibleRef) { state.bibleRef = s.bibleRef; changed = true; }
|
|
if ((s.frameUrl || null) !== (state.frameUrl || null)) { state.frameUrl = s.frameUrl || null; changed = true; }
|
|
|
|
if (s.rel && s.rel !== state.lastRel) {
|
|
state.lastRel = s.rel;
|
|
if (s.type !== 'diashow') {
|
|
await loadSong(s.rel);
|
|
}
|
|
changed = true;
|
|
}
|
|
if (s.slideIndex !== undefined && s.slideIndex !== state.slideIndex) {
|
|
state.slideIndex = s.slideIndex; changed = true;
|
|
}
|
|
if (s.blackScreen !== undefined && s.blackScreen !== state.blackScreen) {
|
|
state.blackScreen = s.blackScreen; changed = true;
|
|
}
|
|
if (s.visibleLangs && JSON.stringify([...state.visibleLangs].sort()) !== JSON.stringify([...s.visibleLangs].sort())) {
|
|
state.visibleLangs = new Set(s.visibleLangs); changed = true;
|
|
}
|
|
if (s.fontScale && s.fontScale !== state.fontScale) {
|
|
state.fontScale = s.fontScale;
|
|
document.documentElement.style.setProperty('--font-scale', state.fontScale);
|
|
changed = true;
|
|
}
|
|
if (s.colors) {
|
|
for (let i = 1; i <= 4; i++) {
|
|
if (s.colors[i] && s.colors[i] !== state.colors[i]) {
|
|
state.colors[i] = s.colors[i];
|
|
document.documentElement.style.setProperty(`--lang${i}`, state.colors[i]);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
if (changed) renderSlide();
|
|
}
|
|
|
|
function advanceDiashow() {
|
|
if (state.type !== 'diashow' || !state.diashowMedia) return;
|
|
if (state.slideIndex < state.diashowMedia.length - 1) {
|
|
state.slideIndex++;
|
|
renderSlide();
|
|
|
|
// Update server state so control UI is somewhat synced if reloaded
|
|
fetch('?action=set_state', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
type: state.type,
|
|
rel: state.lastRel,
|
|
slideIndex: state.slideIndex,
|
|
blackScreen: state.blackScreen,
|
|
visibleLangs: Array.from(state.visibleLangs || []),
|
|
fontScale: state.fontScale,
|
|
colors: state.colors,
|
|
customText: state.customText,
|
|
bibleRef: state.bibleRef,
|
|
frameUrl: state.frameUrl,
|
|
diashowMedia: state.diashowMedia,
|
|
diashowSettings: state.diashowSettings
|
|
})
|
|
}).catch(e => {});
|
|
}
|
|
}
|
|
|
|
// ---------- Polling ----------
|
|
let pollTimer = null;
|
|
async function pollState() {
|
|
try {
|
|
const res = await fetch('?action=get_state', {cache:'no-store'});
|
|
if (!res.ok) throw new Error();
|
|
const data = await res.json();
|
|
await applyState(data.state);
|
|
elConnDot.className = 'ok';
|
|
} catch {
|
|
elConnDot.className = 'err';
|
|
}
|
|
}
|
|
function startPoll() {
|
|
if (pollTimer) return;
|
|
pollTimer = setInterval(pollState, 300); // Schnelles Polling als SSE Ersatz
|
|
}
|
|
|
|
// ---------- Wake Lock (verhindert Screen-Sleep) ----------
|
|
let wakeLock = null;
|
|
async function requestWakeLock() {
|
|
if ('wakeLock' in navigator) {
|
|
try {
|
|
wakeLock = await navigator.wakeLock.request('screen');
|
|
} catch { /* nicht kritisch */ }
|
|
}
|
|
}
|
|
|
|
// Wake Lock erneuern wenn Tab wieder aktiv wird
|
|
document.addEventListener('visibilitychange', async () => {
|
|
if (!document.hidden) {
|
|
pollState();
|
|
if (!wakeLock || wakeLock.released) await requestWakeLock();
|
|
}
|
|
});
|
|
|
|
// ---------- Start ----------
|
|
requestWakeLock();
|
|
pollState();
|
|
startPoll();
|
|
</script>
|
|
</body>
|
|
</html>
|