Files
sb/beamer.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 => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[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>