Files
sb/control.php
T

2571 lines
84 KiB
PHP
Raw 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
error_reporting(E_ALL);
ini_set('display_errors', 1);
// control.php — SongBeamer Control (Laptop-Steuerung)
// Lege diesen File neben den Ordner "Songs". Unterordner mit .sng sind ok.
// Läuft mit PHP 8+ (XAMPP/WAMP). Keine Datenbank nötig.
// Polyfills for missing mbstring extension
if (!function_exists('mb_internal_encoding')) {
function mb_internal_encoding($encoding = null) { return true; }
}
if (!function_exists('mb_strtolower')) {
function mb_strtolower($str, $encoding = null) { return strtolower($str); }
}
if (!function_exists('mb_detect_encoding')) {
function mb_detect_encoding($str, $encoding_list = null, $strict = false) {
if (preg_match('//u', $str)) return 'UTF-8';
return 'ISO-8859-1';
}
}
if (!function_exists('mb_convert_encoding')) {
function mb_convert_encoding($str, $to, $from = null) {
if (function_exists('iconv') && $from) {
$from_enc = is_array($from) ? $from[0] : $from;
return @iconv($from_enc, $to . '//IGNORE', $str) ?: $str;
}
return $str;
}
}
mb_internal_encoding('UTF-8');
// constant definition near top
const SONGS_DIR = __DIR__ . DIRECTORY_SEPARATOR . 'files' . DIRECTORY_SEPARATOR . 'songs';
const DIASHOW_DIR = __DIR__ . DIRECTORY_SEPARATOR . 'files' . DIRECTORY_SEPARATOR . 'media' . DIRECTORY_SEPARATOR . 'diashow';
const STATE_FILE = __DIR__ . DIRECTORY_SEPARATOR . 'sync.json';
// Lade Konfiguration
$configFile = __DIR__ . DIRECTORY_SEPARATOR . 'config.php';
$config = file_exists($configFile) ? include($configFile) : [];
$stripWidth = $config['slide_strip_width'] ?? 190;
$stripHeight = $config['slide_strip_height'] ?? 105;
$stripFontSize = $config['slide_strip_font_size'] ?? 0.75;
$baseFontScale = $config['base_font_scale'] ?? 1.0;
$color1 = $config['color1'] ?? '#ffffff';
$color2 = $config['color2'] ?? '#5fa8ff';
$color3 = $config['color3'] ?? '#ffff00';
$color4 = $config['color4'] ?? '#00ff00';
// Migration: sync.txt -> sync.json (einmalig)
$legacyFile = __DIR__ . DIRECTORY_SEPARATOR . 'sync.txt';
if (!file_exists(STATE_FILE) && file_exists($legacyFile)) {
@rename($legacyFile, STATE_FILE);
}
if (!is_dir(SONGS_DIR)) {
@mkdir(SONGS_DIR, 0775, true);
}
if (!is_dir(DIASHOW_DIR)) {
@mkdir(DIASHOW_DIR, 0775, true);
}
// ----- Pfad-Helper (Windows/Linux sicher) -----
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;
}
// ----- Meta aus String extrahieren -----
function parse_meta_from_content($raw)
{
$lines = preg_split("/(\r\n|\r|\n)/", $raw);
$meta = [];
foreach ($lines as $ln) {
$r = rtrim($ln, "\r\n");
if (preg_match('/^#(\w+)=(.*)$/', $r, $m)) {
$meta[$m[1]] = $m[2];
} elseif (trim($r) !== '') {
// Stoppe bei Nicht-Meta-Zeilen
break;
}
}
return $meta;
}
// ----- Alle .sng-Dateien rekursiv auflisten -----
function list_sng_files($root, $q = '')
{
$q = trim(mb_strtolower($q));
$result = [];
$rootReal = norm_path($root);
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $file) {
/** @var SplFileInfo $file */
if (!$file->isFile())
continue;
if (strtolower($file->getExtension()) !== 'sng')
continue;
$absReal = norm_path($file->getRealPath());
if ($absReal === false || !is_path_inside($rootReal, $absReal))
continue;
// Relativen Pfad bestimmen (mit / als Trennzeichen)
$rel = ltrim(substr($absReal, strlen($rootReal)), '/');
$raw = read_file_utf8($absReal);
$lower_raw = mb_strtolower($raw);
$meta = parse_meta_from_content($raw);
$name = $file->getBasename('.sng');
$name_lower = mb_strtolower($name);
$rel_lower = strtolower($rel);
$csid = mb_strtolower($meta['ChurchSongID'] ?? '');
if ($q) {
$has_name = strpos($name_lower, $q) !== false;
$has_rel = strpos($rel_lower, $q) !== false;
$has_csid = strpos($csid, $q) !== false;
$has_content = strpos($lower_raw, $q) !== false;
if (!$has_name && !$has_rel && !$has_csid && !$has_content)
continue;
}
$result[] = [
'name' => $name,
'rel' => $rel,
'mtime' => $file->getMTime(),
'size' => $file->getSize(),
'churchsongid' => $meta['ChurchSongID'] ?? ''
// 'content' wird nicht mehr gesendet, da nicht benötigt
];
}
// Alphabetisch nach Name
usort($result, fn($a, $b) => strcmp(mb_strtolower($a['name']), mb_strtolower($b['name'])));
return $result;
}
// ----- Datei lesen & nach UTF-8 konvertieren -----
function read_file_utf8($path)
{
$bin = @file_get_contents($path);
if ($bin === false)
return '';
// UTF-8 BOM entfernen
$bin = preg_replace("/^\xEF\xBB\xBF/", '', $bin);
// Encoding erkennen/konvertieren
$enc = mb_detect_encoding($bin, ['UTF-8', 'UTF-16LE', 'UTF-16BE', 'Windows-1252', 'ISO-8859-1'], true);
if ($enc && $enc !== 'UTF-8') {
$bin = mb_convert_encoding($bin, 'UTF-8', $enc);
}
return $bin;
}
// ----- .sng Parser -----
// - Ignoriert Zeilen, die mit # beginnen (Meta, z. B. #Title, #LangCount, ...)
// - Abschnitte wie [Verse], [Chorus] etc. werden erkannt
// - Slides = Textblöcke getrennt durch Leerzeilen oder Zeilen nur mit '---'
function parse_sng($path)
{
$raw = read_file_utf8($path);
$lines = preg_split("/(\r\n|\r|\n)/", $raw);
$meta = [];
$sections = [];
$current = null;
$nonMetaAll = []; // Für Fallback, falls keine [Sections]
foreach ($lines as $ln) {
$r = rtrim($ln, "\r\n");
// Meta wie #Title=..., #LangCount=...
if (preg_match('/^#(\w+)=(.*)$/', $r, $m)) {
$meta[$m[1]] = $m[2];
continue;
}
// Abschnittsüberschrift [Verse], [Chorus], ...
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;
// Fallback: keine Section-Tags vorhanden -> ganzen Nicht-Meta-Text als eine Section
if (!$sections) {
$body = trim(implode("\n", $nonMetaAll));
$sections = [['name' => $meta['Title'] ?? basename($path, '.sng'), 'text' => $body]];
}
// Slides erzeugen
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;
// Helper: Lokaler Datei-Leser für Bibel (Bypass für 403 Server Sperre)
if ($action === 'get_bible') {
$file = __DIR__ . '/GerSch.json';
if (file_exists($file)) {
// Caching Headern für Speed
$mtime = filemtime($file);
$etag = md5($mtime . $file);
header("Last-Modified: " . gmdate("D, d M Y H:i:s", $mtime) . " GMT");
header("Etag: $etag");
header("Cache-Control: public, max-age=31536000"); // 1 Jahr Cache
// Prüfen ob Browser Cache hat
if (
(isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= $mtime) ||
(isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag)
) {
header("HTTP/1.1 304 Not Modified");
exit;
}
header('Content-Type: application/json; charset=UTF-8');
readfile($file);
exit;
} else {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Datei GerSch.json nicht gefunden']);
exit;
}
}
if ($action === 'list') {
$q = $_GET['q'] ?? '';
json_out(['ok' => true, 'files' => list_sng_files(SONGS_DIR, $q)]);
}
if ($action === 'song') {
$rel = $_GET['file'] ?? '';
// Pfad bereinigen: .. entfernen, Backslashes zu / normalisieren
$rel = ltrim(str_replace(['..', '\\'], ['', '/'], $rel), '/');
// In OS-Pfad umwandeln
$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 === 'list_diashows') {
$result = [];
if (is_dir(DIASHOW_DIR)) {
$it = new DirectoryIterator(DIASHOW_DIR);
foreach ($it as $fileinfo) {
if (!$fileinfo->isDot() && $fileinfo->isDir()) {
$result[] = [
'name' => $fileinfo->getBasename(),
'rel' => $fileinfo->getBasename(),
'mtime' => $fileinfo->getMTime()
];
}
}
// Alphabetisch sortieren
usort($result, fn($a, $b) => strcmp(mb_strtolower($a['name']), mb_strtolower($b['name'])));
}
json_out(['ok' => true, 'diashows' => $result]);
}
if ($action === 'get_diashow') {
$rel = $_GET['dir'] ?? '';
$rel = ltrim(str_replace(['..', '\\'], ['', '/'], $rel), '/');
$abs = DIASHOW_DIR . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
$absReal = realpath($abs);
if (!$absReal || !is_path_inside(DIASHOW_DIR, $absReal) || !is_dir($absReal)) {
http_response_code(404);
json_out(['ok' => false, 'error' => 'Diashow not found']);
}
$mediaFiles = [];
$it = new DirectoryIterator($absReal);
foreach ($it as $f) {
if (!$f->isDot() && $f->isFile()) {
$ext = strtolower($f->getExtension());
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'mp4', 'webm', 'mov'])) {
$mediaFiles[] = [
'name' => $f->getBasename(),
'ext' => $ext,
'url' => 'files/media/diashow/' . $rel . '/' . rawurlencode($f->getBasename())
];
}
}
}
usort($mediaFiles, fn($a, $b) => strcmp(mb_strtolower($a['name']), mb_strtolower($b['name'])));
json_out(['ok' => true, 'media' => $mediaFiles, 'name' => basename($rel)]);
}
if ($action === 'set_state') {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
if ($data) {
file_put_contents(STATE_FILE, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
json_out(['ok' => true]);
} else {
http_response_code(400);
json_out(['ok' => false, 'error' => 'Invalid data']);
}
}
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 Control</title>
<style>
html {
scrollbar-width: none
}
:root {
--bg: #0b0d10;
--fg: #e8edf2;
--muted: #93a1ad;
--border: #26303a;
--lang1: <?= $color1 ?>;
--lang2: <?= $color2 ?>;
--lang3: <?= $color3 ?>;
--lang4: <?= $color4 ?>;
--strip-width: <?= $stripWidth ?>px;
--strip-height: <?= $stripHeight ?>px;
--strip-font-size: <?= $stripFontSize ?>rem;
--font-scale: <?= $baseFontScale ?>;
}
* {
box-sizing: border-box
}
html,
body {
height: 100%
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial, Helvetica, sans-serif;
}
.header {
display: flex;
gap: .75rem;
align-items: center;
padding: .6rem 1rem;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, var(--panel), transparent)
}
.header h1 {
font-size: 1rem;
margin: 0 0.25rem 0 0;
font-weight: 700;
letter-spacing: .2px
}
.header .grow {
flex: 1
}
.search {
flex: 1;
max-width: 520px
}
.search input {
width: 100%;
padding: .5rem .8rem;
border-radius: .7rem;
border: 1px solid var(--border);
background: #0f1317;
color: var(--fg)
}
.wrap {
display: grid;
grid-template-columns: 200px 300px 1fr;
height: calc(100% - 54px)
}
.sidebar {
border-right: 1px solid var(--border);
padding: .6rem;
overflow: auto
}
.path {
color: var(--muted);
font-size: .85rem;
margin: 6px 2px
}
.list-item {
padding: .4rem .5rem;
border-radius: .5rem;
cursor: pointer;
border: 1px solid transparent
}
.list-item:hover {
background: #12181f;
border-color: var(--border)
}
.list-item.active {
background: #18212b;
border-color: #2d3a48
}
.list-item.focused {
border-color: var(--lang2);
box-shadow: 0 0 0 1px var(--lang2) inset;
}
.list-item small {
display: block;
color: var(--muted);
font-size: .78rem
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.info {
flex-shrink: 0;
padding: .5rem .9rem;
border-bottom: 1px solid var(--border);
display: flex;
gap: .8rem;
align-items: center
}
.info .meta {
color: var(--muted);
font-size: .9rem
}
.viewer {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
background: #000
}
.slide {
width: 92%;
max-width: 1400px;
min-height: 100%;
border-radius: 1.1rem;
padding: 6vh 4vw;
margin: 3vh 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
border: 1px solid var(--border);
background: #000;
box-sizing: border-box;
}
.slide pre {
white-space: pre-wrap;
word-wrap: break-word;
font: inherit;
line-height: 1.25;
margin: 0;
font-size: calc(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;
}
.badge {
display: inline-block;
padding: .12rem .45rem;
border: 1px solid var(--border);
border-radius: .45rem;
color: var(--muted);
font-size: .75rem
}
.badge.active {
background: var(--panel);
color: var(--fg)
}
.lang-select {
display: flex;
gap: .3rem;
align-items: center;
color: var(--muted);
font-size: .9rem
}
.controls {
display: flex;
gap: .4rem
}
.btn {
padding: .42rem .7rem;
border: 1px solid var(--border);
background: #0f141a;
border-radius: .55rem;
color: var(--fg);
cursor: pointer
}
.btn:hover {
background: #131a22
}
.kbd {
border: 1px solid var(--border);
border-bottom-width: 3px;
border-radius: .35rem;
padding: .08rem .35rem;
font-size: .8rem;
color: var(--muted)
}
.footer-hint {
color: var(--muted);
font-size: .85rem;
padding: .4rem .9rem
}
/* Settings Panel */
.settings {
position: fixed;
right: 1rem;
top: 3.2rem;
background: #0e141a;
border: 1px solid var(--border);
border-radius: .7rem;
padding: .7rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, .35);
min-width: 240px;
display: none;
z-index: 3
}
.settings.show {
display: block
}
.settings label {
display: flex;
align-items: center;
justify-content: space-between;
margin: .35rem 0;
font-size: .9rem;
color: var(--muted)
}
.settings input[type="color"] {
width: 42px;
height: 28px;
border: none;
background: transparent;
padding: 0
}
.settings input[type="range"] {
width: 140px
}
/* Neue Styles */
#song-number {
font-size: .75rem;
color: var(--muted);
margin-right: .5rem;
}
.progress-indicators {
display: flex;
gap: .3rem;
align-items: center;
}
.circle {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
border: 1px solid var(--muted);
}
.circle.active {
background: var(--fg);
}
.slide.black {
background: black !important;
}
.slide.black pre {
display: none;
}
/* Bibel Info & Modal */
#bible-icon {
cursor: pointer;
color: var(--muted);
font-size: 1.1rem;
padding: 0 4px;
}
#bible-icon:hover {
color: var(--lang2);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(5px);
}
.modal {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 2rem;
width: 90%;
max-width: 500px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
}
.modal h2 {
margin-top: 0;
font-size: 1.3rem;
}
.progress-area {
margin: 1.5rem 0;
background: #000;
height: 1.5rem;
border-radius: 1rem;
overflow: hidden;
border: 1px solid var(--border);
position: relative;
}
.progress-bar {
height: 100%;
width: 0%;
background: var(--lang2);
transition: width 0.1s linear;
}
.progress-text {
text-align: center;
color: var(--muted);
font-size: 0.9rem;
margin-top: 0.5rem;
font-family: monospace;
}
.debug-log {
background: #000;
color: #0f0;
padding: 1rem;
height: 150px;
overflow-y: auto;
font-family: monospace;
font-size: 0.8rem;
border: 1px solid var(--border);
margin-top: 1rem;
white-space: pre-wrap;
}
.btn-primary {
background: var(--lang2);
color: #000;
font-weight: bold;
border: none;
}
.btn-primary:hover {
background: #4a90e2;
}
.hidden {
display: none !important;
}
/* Slide Strip */
.slide-strip-wrap {
flex-shrink: 0;
border-top: 1px solid var(--border);
background: #06080a;
overflow: hidden;
}
.slide-strip {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: .6rem;
padding: .8rem;
overflow-y: auto;
max-height: 30vh;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
align-items: stretch;
}
.strip-card {
width: var(--strip-width);
min-height: var(--strip-height);
border-radius: .45rem;
border: 2px solid var(--border);
background: #000;
cursor: pointer;
padding: .6rem;
font-size: var(--strip-font-size);
color: var(--muted);
overflow: hidden;
transition: border-color .12s, box-shadow .12s;
display: flex;
flex-direction: column;
}
.strip-card:hover { border-color: var(--muted); }
.strip-card.active {
border-color: var(--lang2);
box-shadow: 0 0 0 1px var(--lang2);
color: var(--fg);
}
.strip-num {
font-size: .48rem;
color: var(--muted);
opacity: .65;
margin-bottom: .15rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.strip-text {
flex: 1;
overflow: hidden;
line-height: 1.25;
word-break: break-word;
}
.footer-hint {
flex-shrink: 0;
}
/* History Sidebar */
.history-bar {
border-right: 1px solid var(--border);
background: #080a0d;
overflow-y: auto;
padding: .4rem;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.history-title {
font-size: .65rem;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--muted);
padding: .3rem .2rem;
margin-bottom: .3rem;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.history-item {
display: flex;
align-items: center;
gap: .3rem;
padding: .3rem .25rem;
border-radius: .35rem;
cursor: pointer;
font-size: .68rem;
color: var(--muted);
border: 1px solid transparent;
transition: all .12s;
overflow: hidden;
}
.history-item:hover {
background: #12181f;
border-color: var(--border);
color: var(--fg);
}
.history-icon { flex-shrink: 0; font-size: .8rem; }
.history-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
line-height: 1.2;
}
/* Main Tabs */
.main-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
background: #0a0d11;
flex-shrink: 0;
}
.tab-btn {
padding: .45rem 1.1rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font-size: .82rem;
cursor: pointer;
transition: all .15s;
font-family: inherit;
}
.tab-btn:hover { color: var(--fg); background: #0f1317; }
.tab-btn.active { color: var(--fg); border-bottom-color: var(--lang2); }
.tab-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Frame Panel */
.frame-panel {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem 1.2rem;
gap: 1rem;
overflow-y: auto;
}
.frame-url-bar {
display: flex;
gap: .5rem;
align-items: center;
}
.frame-url-bar input {
flex: 1;
padding: .5rem .8rem;
border-radius: .5rem;
border: 1px solid var(--border);
background: #0f1317;
color: var(--fg);
font-size: .9rem;
font-family: inherit;
}
.frame-section h3 {
margin: 0 0 .5rem 0;
font-size: .85rem;
color: var(--muted);
display: flex;
align-items: center;
gap: .5rem;
}
.preset-list { display: flex; flex-direction: column; gap: .3rem; }
.preset-item {
display: flex;
align-items: center;
gap: .5rem;
padding: .4rem .6rem;
background: #0a0d11;
border: 1px solid var(--border);
border-radius: .45rem;
font-size: .85rem;
}
.preset-name {
font-weight: 600;
color: var(--fg);
cursor: pointer;
min-width: 80px;
}
.preset-name:hover { color: var(--lang2); }
.preset-url {
flex: 1;
color: var(--muted);
font-size: .72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preset-use, .preset-del { padding: .2rem .45rem !important; font-size: .72rem !important; }
.frame-preview-wrap {
flex: 1;
display: flex;
flex-direction: column;
min-height: 200px;
}
.frame-preview {
flex: 1;
border: 1px solid var(--border);
border-radius: .5rem;
overflow: hidden;
background: #000;
min-height: 250px;
}
.frame-preview iframe { width: 100%; height: 100%; border: none; }
.frame-status { font-size: .78rem; color: var(--muted); padding: .2rem 0; }
.preset-add-form {
display: flex;
gap: .4rem;
margin-top: .3rem;
}
.preset-add-form input {
padding: .3rem .5rem;
border: 1px solid var(--border);
border-radius: .35rem;
background: #0f1317;
color: var(--fg);
font-size: .8rem;
font-family: inherit;
}
.preset-add-form input[type="text"] { width: 120px; }
.preset-add-form input[type="url"] { flex: 1; }
.wrap.no-sidebar { grid-template-columns: 200px 1fr; }
.wrap.no-sidebar .sidebar { display: none; }
</style>
</head>
<body>
<div class="header">
<h1>SongBeamer Control</h1>
<div class="grow search">
<input id="search" type="search" placeholder="Suchen (Titel/Pfad/ChurchSongID/Inhalt) oder '/' für Bibel …"
autocomplete="off" />
<div id="bible-hint"
style="display:none; padding:4px 8px; font-size:0.8rem; color:var(--lang2); font-weight:bold;"></div>
</div>
<div id="bible-icon" title="Bibel-Status & Download" style="cursor:help; display:flex; align-items:center; color:var(--lang2);">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
</div>
<div class="controls">
<button class="btn" id="btn-font-dec" title="Schrift kleiner">A-</button>
<button class="btn" id="btn-font-inc" title="Schrift größer">A+</button>
<a href="settings.php" target="_blank"><button class="btn" id="btn-global-settings" title="Globale Einstellungen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</button></a>
<a href="files/index.php" target="_blank"><button class="btn" id="btn-file-manager" title="File Manager">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:bottom; margin-right:4px;"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>Dateimanager
</button></a>
</div>
</div>
<div class="wrap">
<aside class="history-bar">
<div class="history-title">Historie</div>
<div id="history-list"></div>
</aside>
<aside class="sidebar">
<div id="list"></div>
</aside>
<main class="main" id="main">
<div class="main-tabs">
<button class="tab-btn active" data-tab="songs">🎵 Songs</button>
<button class="tab-btn" data-tab="frame">🌐 Webseite</button>
<button class="tab-btn" data-tab="diashow">🖼️ Diashow</button>
</div>
<div id="songs-content" class="tab-content">
<div class="info">
<div id="song-number" class="badge"></div>
<div class="badge" id="section-badge"></div>
<div class="meta" id="song-meta">Wähle einen Song links aus.</div>
<div class="grow"></div>
<div class="progress-indicators" id="progress"></div>
<div class="controls">
<button class="btn" id="prev">← Zurück</button>
<button class="btn" id="next">Weiter →</button>
</div>
</div>
<div class="viewer" id="viewer">
<div class="slide">
<pre>Keine Auswahl</pre>
</div>
</div>
<div class="slide-strip-wrap"><div id="slide-strip" class="slide-strip"></div></div>
<div class="footer-hint">Tasten: <span class="kbd">←</span>/<span class="kbd">→</span> Navigation, <span class="kbd">Alt+F</span> Suche, <span class="kbd">A±</span> Schriftgröße, <span class="kbd">1-9</span> Folie, <span class="kbd">0</span> Schwarz</div>
</div><!-- /songs-content -->
<div id="frame-content" class="tab-content" style="display:none">
<div class="frame-panel">
<div class="frame-url-bar">
<input id="frame-url-input" type="url" placeholder="https://example.com eingeben…" autocomplete="off" />
<button class="btn btn-primary" id="btn-frame-go">▶ Ausstrahlen</button>
<button class="btn" id="btn-frame-stop">⏹ Stopp</button>
</div>
<div class="frame-section">
<h3>Presets <button class="btn" id="btn-preset-add" style="font-size:.7rem; padding:.15rem .4rem;">+ Neu</button></h3>
<div id="preset-add-form" class="preset-add-form" style="display:none">
<input type="text" id="preset-name-input" placeholder="Name" />
<input type="url" id="preset-url-input" placeholder="https://..." />
<button class="btn btn-primary" id="btn-preset-save" style="font-size:.78rem">Speichern</button>
<button class="btn" id="btn-preset-cancel" style="font-size:.78rem">✕</button>
</div>
<div id="preset-list" class="preset-list"></div>
</div>
<div class="frame-preview-wrap frame-section">
<h3>Vorschau</h3>
<div id="frame-status" class="frame-status">Keine Webseite aktiv</div>
<div class="frame-preview">
<iframe id="frame-preview-iframe" src="about:blank"></iframe>
</div>
</div>
</div>
</div>
<div id="diashow-content" class="tab-content" style="display:none">
<div class="info" style="gap:1rem;">
<div class="meta" id="diashow-meta" style="flex:1;">Wähle links eine Diashow aus dem media Ordner aus.</div>
<div style="display:flex; align-items:center; gap:0.5rem; font-size:0.85rem;">
<label title="Darstellung der Bilder">Skalierung:</label>
<select id="diashow-scaling" style="background:#0f1317; color:var(--fg); border:1px solid var(--border); border-radius:4px; padding:2px 5px;">
<option value="contain">Original (Ränder)</option>
<option value="cover" selected>Zugeschnitten (Voll)</option>
<option value="fill">Verzerrt (Vollbild)</option>
</select>
</div>
<div style="display:flex; align-items:center; gap:0.5rem; font-size:0.85rem;">
<label title="Sekunden pro Bild (0 = Manuell)">Auto-Timer (sek):</label>
<input type="number" id="diashow-timer" value="0" min="0" style="width:60px; background:#0f1317; color:var(--fg); border:1px solid var(--border); border-radius:4px; padding:2px 5px;" />
</div>
<div class="controls">
<button class="btn" id="diashow-prev">← Zurück</button>
<button class="btn" id="diashow-next">Weiter →</button>
</div>
</div>
<div class="viewer" id="diashow-viewer" style="display:flex; flex-wrap:wrap; gap:10px; padding:15px; overflow-y:auto; align-content:flex-start;">
<div style="color:var(--muted); font-size:0.9rem;">Keine Diashow ausgewählt.</div>
</div>
</div>
</main>
</div>
<!-- Bibel Download Modal -->
<div id="bible-modal" class="modal-overlay hidden">
<div class="modal">
<h2>Bibel-Datenbank</h2>
<p>Die Bibel-Datei (GerSch.json, ~8.5MB) muss einmalig geladen werden.</p>
<div id="dl-start-view">
<button id="btn-dl-start" class="btn btn-primary" style="width:100%">Jetzt Laden</button>
</div>
<div id="dl-progress-view" class="hidden">
<div class="progress-area">
<div id="dl-bar" class="progress-bar"></div>
</div>
<div id="dl-text" class="progress-text">0%</div>
<div id="dl-log" class="debug-log"></div>
</div>
<div style="margin-top:1.5rem; text-align:right">
<button id="btn-dl-close" class="btn">Schließen</button>
</div>
</div>
</div>
<script>
// ---------- Kurz-Helper ----------
const qs = s => document.querySelector(s);
const elList = qs('#list');
const elSearch = qs('#search');
const elViewer = qs('#viewer');
const elMeta = qs('#song-meta');
const elBadge = qs('#section-badge');
const elSongNumber = qs('#song-number');
const elProgress = qs('#progress');
const btnPrev = qs('#prev');
const btnNext = qs('#next');
const btnFontInc = qs('#btn-font-inc');
const btnFontDec = qs('#btn-font-dec');
const elBibleHint = qs('#bible-hint');
let state = { files: [], song: null, flatSlides: [], slideIndex: 0, langcount: 1, fontScale: <?= $baseFontScale ?>, activeRel: null, visibleLangs: new Set(), blackScreen: false, hasMarkers: false, colors: { 1: '<?= $color1 ?>', 2: '<?= $color2 ?>', 3: '<?= $color3 ?>', 4: '<?= $color4 ?>' }, customText: null, bibleRef: null, frameUrl: null };
let bibleData = null;
let bibleBooks = [];
let bibleMode = false;
let biblePreview = false;
let currentBibleMatch = null; // { bookIndex, chapter, verseStart, verseEnd, text }
let songPreview = false;
let searchSelectedIndex = -1;
function updateSearchSelection(items) {
items.forEach((it, idx) => {
if (idx === searchSelectedIndex) {
it.classList.add('focused');
it.scrollIntoView({ block: 'nearest' });
} else {
it.classList.remove('focused');
}
});
}
// ---------- Bibel-Daten laden ----------
// ---------- Bibel-Daten-Status ----------
let bibleLoadStatus = 'init'; // init, loading, error, ready
let bibleLoadError = null;
// Modal Elements
const elBibleIcon = qs('#bible-icon');
const elModal = qs('#bible-modal');
const elDlStartView = qs('#dl-start-view');
const elDlProgressView = qs('#dl-progress-view');
const elDlBar = qs('#dl-bar');
const elDlText = qs('#dl-text');
const elDlLog = qs('#dl-log');
const btnDlStart = qs('#btn-dl-start');
const btnDlClose = qs('#btn-dl-close');
// ---------- Bibel Download Manager ----------
function logDl(msg) {
const d = new Date().toLocaleTimeString();
elDlLog.textContent += `[${d}] ${msg}\n`;
elDlLog.scrollTop = elDlLog.scrollHeight;
console.log(`[DL-Manager] ${msg}`);
}
function openBibleModal() {
elModal.classList.remove('hidden');
if (bibleData) {
logDl('Bibel bereits geladen.');
elDlStartView.classList.add('hidden');
elDlProgressView.classList.remove('hidden');
elDlBar.style.width = '100%';
elDlText.textContent = 'Bereit';
} else {
elDlStartView.classList.remove('hidden');
elDlProgressView.classList.add('hidden');
}
}
elBibleIcon.addEventListener('click', openBibleModal);
btnDlClose.addEventListener('click', () => elModal.classList.add('hidden'));
btnDlStart.addEventListener('click', startBibleDownload);
function startBibleDownload() {
elDlStartView.classList.add('hidden');
elDlProgressView.classList.remove('hidden');
elDlBar.style.width = '0%';
elDlText.textContent = '0%';
elDlLog.textContent = ''; // Reset Log
logDl('Starte Download von GerSch.json...');
const xhr = new XMLHttpRequest();
xhr.open('GET', 'GerSch.json', true);
xhr.responseType = 'text'; // Text, damit wir parsen können
// Progress Event
xhr.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
elDlBar.style.width = percent + '%';
elDlText.textContent = `${percent}% (${(e.loaded / 1024 / 1024).toFixed(2)} MB)`;
} else {
elDlText.textContent = 'Lade... ' + (e.loaded / 1024 / 1024).toFixed(2) + ' MB';
}
};
xhr.onload = () => {
if (xhr.status === 200) {
logDl(`Download abgeschlossen. Status: ${xhr.status}. Bytes: ${xhr.response.length}`);
// Parsing
try {
logDl('Parse JSON...');
const start = performance.now();
const json = JSON.parse(xhr.response);
const dur = (performance.now() - start).toFixed(2);
logDl(`JSON geparst in ${dur}ms`);
if (!json.books || !Array.isArray(json.books)) {
throw new Error('Ungültige JSON Struktur');
}
bibleData = json;
bibleBooks = json.books;
logDl(`Erfolg! ${bibleBooks.length} Bücher geladen.`);
elDlBar.style.width = '100%';
elDlText.textContent = 'Fertig';
elBibleHint.style.display = 'none';
bibleLoadStatus = 'ready';
} catch (e) {
logDl('JSON Parse Fehler: ' + e.message);
// Vorschau zeigen
logDl('Vorschau: ' + xhr.response.substring(0, 100));
}
} else {
logDl(`Fehler HTTP ${xhr.status} ${xhr.statusText}`);
}
};
xhr.onerror = () => {
logDl('Netzwerkfehler aufgetreten.');
};
xhr.send();
}
// Hybrid: Versuche leise Fetch beim Start
fetch('GerSch.json', { cache: 'force-cache' }).then(r => {
if (r.ok) return r.json();
}).then(d => {
if (d && d.books) {
bibleData = d;
bibleBooks = d.books;
bibleLoadStatus = 'ready';
console.log("Bibel (Silent Load): OK");
}
}).catch(e => console.log("Silent Load fehlgeschlagen, nutze Button"));
// ---------- Bibel-Mapping ----------
const bookMap = {
// 1. Mose
'1m': 0,
// ... (rest of map is fine, no change needed if we target encompassing range)
'1mos': 0, '1mose': 0, 'gn': 0, 'gen': 0, 'genesis': 0,
// 2. Mose
'2m': 1, '2mos': 1, '2mose': 1, 'ex': 1, 'exodus': 1,
// 3. Mose
'3m': 2, '3mos': 2, '3mose': 2, 'lv': 2, 'lev': 2, 'levitikus': 2,
// 4. Mose
'4m': 3, '4mos': 3, '4mose': 3, 'nm': 3, 'num': 3, 'numeri': 3,
// 5. Mose
'5m': 4, '5mos': 4, '5mose': 4, 'dt': 4, 'deut': 4, 'deuteronomium': 4,
// Josua
'jos': 5, 'josua': 5,
// Richter
'ri': 6, 'richter': 6,
// Rut
'rt': 7, 'rut': 7, 'ruth': 7,
// 1. Samuel
'1s': 8, '1sa': 8, '1sam': 8, '1samuel': 8,
// 2. Samuel
'2s': 9, '2sa': 9, '2sam': 9, '2samuel': 9,
// 1. Könige
'1k': 10, '1kö': 10, '1kön': 10, '1könige': 10,
// 2. Könige
'2k': 11, '2kö': 11, '2kön': 11, '2könige': 11,
// 1. Chronik
'1ch': 12, '1chr': 12, '1chronik': 12,
// 2. Chronik
'2ch': 13, '2chr': 13, '2chronik': 13,
// Esra
'esr': 14, 'esra': 14,
// Nehemia
'neh': 15, 'nehemia': 15,
// Ester
'est': 16, 'ester': 16,
// Hiob
'hi': 17, 'hiob': 17, 'job': 17,
// Psalmen
'ps': 18, 'psalm': 18, 'psalmen': 18,
// Sprüche
'spr': 19, 'sprüche': 19,
// Prediger
'pred': 20, 'prediger': 20, 'koh': 20, 'kohelet': 20,
// Hohelied
'hl': 21, 'hohelied': 21, 'hld': 21,
// Jesaja
'jes': 22, 'jesaja': 22,
// Jeremia
'jer': 23, 'jeremia': 23,
// Klagelieder
'kl': 24, 'kla': 24, 'klagelieder': 24,
// Hesekiel
'hes': 25, 'hesekiel': 25, 'ez': 25, 'ezechiel': 25,
// Daniel
'dan': 26, 'daniel': 26,
// Hosea
'hos': 27, 'hosea': 27,
// Joel
'joel': 28,
// Amos
'am': 29, 'amos': 29,
// Obadja
'ob': 30, 'obadja': 30,
// Jona
'jon': 31, 'jona': 31,
// Micha
'mi': 32, 'micha': 32,
// Nahum
'nah': 33, 'nahum': 33,
// Habakuk
'hab': 34, 'habakuk': 34,
// Zephanja
'zep': 35, 'zeph': 35, 'zephanja': 35,
// Haggai
'hag': 36, 'haggai': 36,
// Sacharja
'sac': 37, 'sach': 37, 'sacharja': 37,
// Maleachi
'mal': 38, 'maleachi': 38,
// Matthäus
'mt': 39, 'mat': 39, 'matt': 39, 'matthäus': 39,
// Markus
'mk': 40, 'mr': 40, 'mark': 40, 'markus': 40,
// Lukas
'lk': 41, 'luk': 41, 'lukas': 41,
// Johannes
'joh': 42, 'johannes': 42,
// Apostelgeschichte
'apg': 43, 'apostelgeschichte': 43, 'act': 43,
// Römer
'röm': 44, 'römer': 44,
// 1. Korinther
'1kor': 45, '1korinther': 45,
// 2. Korinther
'2kor': 46, '2korinther': 46,
// Galater
'gal': 47, 'galater': 47,
// Epheser
'eph': 48, 'epheser': 48,
// Philipper
'phil': 49, 'philipper': 49,
// Kolosser
'kol': 50, 'kolosser': 50,
// 1. Thessalonicher
'1thes': 51, '1thessalonicher': 51,
// 2. Thessalonicher
'2thes': 52, '2thessalonicher': 52,
// 1. Timotheus
'1ti': 53, '1tim': 53, '1timotheus': 53,
// 2. Timotheus
'2ti': 54, '2tim': 54, '2timotheus': 54,
// Titus
'tit': 55, 'titus': 55,
// Philemon
'phm': 56, 'philemon': 56,
// Hebräer
'hebr': 57, 'hebräer': 57,
// Jakobus
'jak': 58, 'jakobus': 58,
// 1. Petrus
'1pet': 59, '1petr': 59, '1petrus': 59,
// 2. Petrus
'2pet': 60, '2petr': 60, '2petrus': 60,
// 1. Johannes
'1joh': 61, '1johannes': 61,
// 2. Johannes
'2joh': 62, '2johannes': 62,
// 3. Johannes
'3joh': 63, '3johannes': 63,
// Judas
'jud': 64, 'judas': 64,
// Offenbarung
'off': 65, 'offenbarung': 65, 'apk': 65
};
const bookNamesDe = [
"1. Mose", "2. Mose", "3. Mose", "4. Mose", "5. Mose",
"Josua", "Richter", "Rut", "1. Samuel", "2. Samuel", "1. Könige", "2. Könige",
"1. Chronik", "2. Chronik", "Esra", "Nehemia", "Ester", "Hiob", "Psalmen", "Sprüche",
"Prediger", "Hohelied", "Jesaja", "Jeremia", "Klagelieder", "Hesekiel", "Daniel",
"Hosea", "Joel", "Amos", "Obadja", "Jona", "Micha", "Nahum", "Habakuk", "Zephanja",
"Haggai", "Sacharja", "Maleachi",
"Matthäus", "Markus", "Lukas", "Johannes", "Apostelgeschichte", "Römer",
"1. Korinther", "2. Korinther", "Galater", "Epheser", "Philipper", "Kolosser",
"1. Thessalonicher", "2. Thessalonicher", "1. Timotheus", "2. Timotheus",
"Titus", "Philemon", "Hebräer", "Jakobus", "1. Petrus", "2. Petrus",
"1. Johannes", "2. Johannes", "3. Johannes", "Judas", "Offenbarung"
];
// ---------- Einstellungen laden ----------
(async function loadPrefs() {
const current = await api('?action=get_state');
if (current.state) {
if (current.state.rel) state.activeRel = current.state.rel;
if (current.state.slideIndex !== undefined) state.slideIndex = current.state.slideIndex;
if (current.state.blackScreen !== undefined) state.blackScreen = current.state.blackScreen;
if (current.state.visibleLangs) state.visibleLangs = new Set(current.state.visibleLangs);
if (current.state.fontScale) state.fontScale = current.state.fontScale;
if (current.state.colors) state.colors = current.state.colors;
if (state.activeRel) await loadSong(state.activeRel, false); // Kein Sync, da initial
// Frame-URL wiederherstellen (nach loadSong, da loadSong frameUrl=null setzt)
if (current.state.frameUrl) {
state.frameUrl = current.state.frameUrl;
switchTab('frame');
const urlInput = qs('#frame-url-input');
if (urlInput) urlInput.value = state.frameUrl;
const previewIframe = qs('#frame-preview-iframe');
if (previewIframe) previewIframe.src = state.frameUrl;
const statusEl = qs('#frame-status');
if (statusEl) statusEl.textContent = '🟢 Aktiv: ' + state.frameUrl;
}
}
setFontScale(state.fontScale, false); // initial, don't sync
})();
function setFontScale(n, sync = true) {
state.fontScale = n;
document.documentElement.style.setProperty('--font-scale', n);
if (sync) syncState();
}
// ---------- API ----------
async function api(path, method = 'GET', body = null) {
const opts = { method, cache: 'no-store' };
if (body) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (!res.ok) throw new Error('API Fehler');
return await res.json();
}
// ---------- Liste laden ----------
async function loadList(q = '', quiet = false) {
const params = q ? '&q=' + encodeURIComponent(q) : '';
const data = await api('?action=list' + params);
// Check if mtimes changed for currently active rel
if (state.activeRel && state.type !== 'diashow') {
const activeFile = data.files.find(f => f.rel === state.activeRel);
const oldFile = (state.files || []).find(f => f.rel === state.activeRel);
if (activeFile && oldFile && activeFile.mtime !== oldFile.mtime) {
// File changed on disk! Reload quietly preserving index
const savedIndex = state.slideIndex;
await loadSong(state.activeRel, true);
state.slideIndex = Math.min(savedIndex, Math.max(0, state.flatSlides.length - 1));
renderSlide();
renderSlideStrip();
renderProgress();
syncState();
}
}
// Check if list changed
const listChanged = !quiet || JSON.stringify(data.files) !== JSON.stringify(state.files);
state.files = data.files || [];
if (listChanged && activeTab === TAB_SONGS) {
renderList();
}
}
// ---------- Liste rendern ----------
function groupByFolder(files) {
const tree = {};
for (const f of files) {
const parts = f.rel.split('/');
const folder = parts.length > 1 ? parts.slice(0, -1).join('/') : '';
if (!tree[folder]) tree[folder] = [];
tree[folder].push(f);
}
return tree;
}
function escapeHtml(s) {
return s.replace(/[&<>\"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
}
function renderList() {
const tree = groupByFolder(state.files);
const frag = document.createDocumentFragment();
const folders = Object.keys(tree).sort((a, b) => a.localeCompare(b));
for (const folder of folders) {
const h = document.createElement('div');
h.className = 'path';
h.textContent = folder || '— Root —';
frag.appendChild(h);
for (const it of tree[folder]) {
const d = document.createElement('div');
d.className = 'list-item' + (state.activeRel === it.rel ? ' active' : '');
d.innerHTML = `<strong>${escapeHtml(it.name)}</strong><small>${it.rel}</small>`;
d.dataset.rel = it.rel;
d.addEventListener('click', () => { loadSong(it.rel, true); });
frag.appendChild(d);
}
}
elList.innerHTML = '';
elList.appendChild(frag);
}
// ---------- Diashows laden ----------
async function loadDiashows(quiet = false) {
const data = await api('?action=list_diashows');
const diashows = data.diashows || [];
if (state.activeRel && state.type === 'diashow') {
const activeDir = diashows.find(d => d.rel === state.activeRel);
const oldDir = (state.diashowsList || []).find(d => d.rel === state.activeRel);
if (activeDir && oldDir && activeDir.mtime !== oldDir.mtime) {
// Dir changed! Reload
const savedIndex = state.slideIndex;
await loadDiashow(state.activeRel, true);
if (state.diashowMedia) {
state.slideIndex = Math.min(savedIndex, Math.max(0, state.diashowMedia.length - 1));
}
renderDiashowViewer();
syncState();
}
}
const listChanged = !quiet || JSON.stringify(diashows) !== JSON.stringify(state.diashowsList);
state.diashowsList = diashows;
if (listChanged && activeTab === TAB_DIASHOW) {
const frag = document.createDocumentFragment();
const h = document.createElement('div');
h.className = 'path';
h.textContent = '— Diashows (Ordner in media/diashow/) —';
frag.appendChild(h);
for (const it of diashows) {
const d = document.createElement('div');
d.className = 'list-item' + (state.activeRel === it.rel && state.type === 'diashow' ? ' active' : '');
d.innerHTML = `<strong>${escapeHtml(it.name)}</strong><small>Ordner</small>`;
d.dataset.rel = it.rel;
d.addEventListener('click', () => { loadDiashow(it.rel); });
frag.appendChild(d);
}
elList.innerHTML = '';
elList.appendChild(frag);
}
}
async function loadDiashow(rel, quiet = false) {
state.type = 'diashow';
const data = await api('?action=get_diashow&dir=' + encodeURIComponent(rel));
state.activeRel = rel;
state.diashowMedia = data.media || [];
state.slideIndex = 0;
state.blackScreen = false;
state.frameUrl = null;
state.customText = null;
state.bibleRef = null;
currentBibleMatch = null;
biblePreview = false;
bibleMode = false;
qs('#diashow-meta').textContent = data.name + ' (' + state.diashowMedia.length + ' Dateien)';
// Update sidebar active state
if (!quiet) {
document.querySelectorAll('.list-item').forEach(el => {
el.classList.toggle('active', el.dataset.rel === rel);
});
addToHistory('diashow', data.name, { rel, isDiashow: true });
}
syncState();
}
function renderDiashowViewer() {
const viewer = qs('#diashow-viewer');
if (!viewer) return;
if (!state.diashowMedia || state.diashowMedia.length === 0) {
viewer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Keine unterstützten Bilder/Videos in diesem Ordner.</div>';
return;
}
const frag = document.createDocumentFragment();
state.diashowMedia.forEach((m, i) => {
const card = document.createElement('div');
card.style.width = '120px';
card.style.height = '100px';
card.style.border = '2px solid ' + (i === state.slideIndex ? 'var(--lang2)' : 'var(--border)');
card.style.borderRadius = '5px';
card.style.overflow = 'hidden';
card.style.cursor = 'pointer';
card.style.position = 'relative';
card.style.background = '#000';
let mediaHtml = '';
if (m.ext === 'mp4' || m.ext === 'webm' || m.ext === 'mov') {
mediaHtml = `<video src="${m.url}" style="width:100%; height:100%; object-fit:cover;" muted></video>
<div style="position:absolute; bottom:2px; right:2px; background:rgba(0,0,0,0.7); padding:1px 4px; border-radius:3px; font-size:0.6rem;">▶ Video</div>`;
} else {
mediaHtml = `<img src="${m.url}" style="width:100%; height:100%; object-fit:cover;" />`;
}
card.innerHTML = mediaHtml;
card.title = m.name;
card.addEventListener('click', () => {
state.slideIndex = i;
renderDiashowViewer();
syncState();
});
frag.appendChild(card);
});
viewer.innerHTML = '';
viewer.appendChild(frag);
// Scroll active into view
setTimeout(() => {
if(viewer.children[state.slideIndex]) {
viewer.children[state.slideIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 50);
}
// Diashow Settings Listeners
setTimeout(() => {
qs('#diashow-scaling').addEventListener('change', (e) => {
state.diashowSettings = state.diashowSettings || {};
state.diashowSettings.scaling = e.target.value;
if (state.type === 'diashow') syncState();
});
qs('#diashow-timer').addEventListener('change', (e) => {
state.diashowSettings = state.diashowSettings || {};
state.diashowSettings.timer = parseInt(e.target.value) || 0;
if (state.type === 'diashow') syncState();
});
}, 100);
// ---------- Song laden & darstellen ----------
async function loadSong(rel, sync = true) {
state.type = 'song';
const data = await api('?action=song&file=' + encodeURIComponent(rel));
state.song = data;
let maxLang = parseInt(data.info.langcount) || 1;
let hasMarkersInSong = false;
// Slides flatten
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) {
const matches = slide.text.matchAll(/#(\d+)#/g);
for (const match of matches) {
maxLang = Math.max(maxLang, parseInt(match[1]));
hasMarkersInSong = true;
}
}
state.langcount = Math.min(4, maxLang);
state.hasMarkers = hasMarkersInSong;
state.activeRel = rel;
state.flatSlides = flat;
state.slideIndex = 0;
state.blackScreen = false;
state.customText = null; // Bibel reset
state.bibleRef = null;
state.frameUrl = null; // Frame deaktivieren
currentBibleMatch = null;
biblePreview = false;
bibleMode = false;
songPreview = !sync;
updateMeta();
renderList(); // zum Active-Highlight erneuern
setupLangSelect();
renderSlide();
renderProgress();
renderSlideStrip();
if (sync) {
addToHistory('song', state.song.info.title, { rel });
switchTab(TAB_SONGS);
syncState();
}
}
function setupLangSelect() {
let elLangSelect = qs('.lang-select');
if (!elLangSelect) {
elLangSelect = document.createElement('div');
elLangSelect.className = 'lang-select';
elLangSelect.innerHTML = 'Sprachen: <span id="lang-input"></span> <span id="lang-buttons"></span>';
qs('.header .search').after(elLangSelect);
// Input für Spracheingabe
const langInput = document.createElement('input');
langInput.type = 'text';
langInput.id = 'lang-input-field';
langInput.placeholder = 'z.B. 1+2+3';
langInput.style.padding = '.3rem .5rem';
langInput.style.border = '1px solid var(--border)';
langInput.style.borderRadius = '.3rem';
langInput.style.background = '#0f1317';
langInput.style.color = 'var(--fg)';
qs('#lang-input').appendChild(langInput);
langInput.addEventListener('input', (e) => {
const val = e.target.value.replace(/[^1-4+]/g, '').toLowerCase();
e.target.value = val;
if (val.endsWith('+') || val === '') return;
const langs = val.split('+').map(n => parseInt(n)).filter(n => n >= 1 && n <= state.langcount);
state.visibleLangs = new Set(langs);
updateLangButtons();
renderSlide();
syncState();
});
}
if (state.langcount > 1) {
elLangSelect.style.display = 'flex';
let buttonsHtml = '';
for (let i = 1; i <= state.langcount; i++) {
buttonsHtml += `<button class="badge lang-btn" data-lang="${i}">${i}</button>`;
}
qs('#lang-buttons').innerHTML = buttonsHtml;
// Events für Lang-Buttons
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.addEventListener('click', e => {
const l = parseInt(e.target.dataset.lang);
if (state.visibleLangs.has(l)) state.visibleLangs.delete(l);
else state.visibleLangs.add(l);
updateLangInput();
updateLangButtons();
renderSlide();
syncState();
});
});
if (state.visibleLangs.size === 0) state.visibleLangs = new Set([...Array(state.langcount)].map((_, i) => i + 1));
updateLangInput();
updateLangButtons();
} else {
elLangSelect.style.display = 'none';
}
}
function updateLangInput() {
const input = qs('#lang-input-field');
if (state.visibleLangs.size > 0) {
input.value = Array.from(state.visibleLangs).sort().join('+');
} else {
input.value = '';
}
}
function updateLangButtons() {
document.querySelectorAll('.lang-btn').forEach(btn => {
const l = parseInt(btn.dataset.lang);
if (state.visibleLangs.has(l)) btn.classList.add('active');
else btn.classList.remove('active');
});
}
function updateMeta() {
if (!state.song) { elMeta.textContent = 'Wähle einen Song links aus.'; elBadge.textContent = ''; elSongNumber.textContent = ''; return; }
const i = state.song.info;
const parts = [];
if (i.title) parts.push(i.title);
if (i.author) parts.push('· ' + i.author);
if (i.copyright) parts.push('· ' + i.copyright);
elMeta.textContent = parts.join(' ');
elSongNumber.textContent = i.churchsongid ? `#${i.churchsongid}` : '';
}
function renderSlide() {
if (state.blackScreen && !biblePreview) { // Allow preview through black screen
elViewer.innerHTML = '<div class="slide black"><pre></pre></div>';
elBadge.textContent = 'Schwarz';
return;
}
// Frame URL aktiv
if (state.frameUrl) {
elViewer.innerHTML = '<div class="slide"><pre><div class="section-header" style="margin-bottom:2vh">🌐 Webseite wird ausgestrahlt</div><div style="color:var(--lang2)">' + escapeHtml(state.frameUrl) + '</div></pre></div>';
elBadge.textContent = 'Webseite';
elMeta.textContent = state.frameUrl;
return;
}
// Bibel Custom Text
if (state.customText) {
let html = '';
if (state.bibleRef) {
html += `<div class="section-header" style="margin-bottom:2vh">${escapeHtml(state.bibleRef)}</div>`;
}
html += `<div>${escapeHtml(state.customText)}</div>`;
elViewer.innerHTML = `<div class="slide"><pre>${html}</pre></div>`;
elBadge.textContent = 'Bibel';
elMeta.textContent = state.bibleRef ? state.bibleRef + (biblePreview ? ' (VORSCHAU)' : '') : 'Bibeltext';
return;
}
if (!state.flatSlides.length) { elViewer.innerHTML = '<div class="slide"><pre>Keine Folien</pre></div>'; elBadge.textContent = ''; return; }
const idx = Math.max(0, Math.min(state.slideIndex, state.flatSlides.length - 1));
const s = state.flatSlides[idx];
elBadge.textContent = s.section || '';
const lines = s.text.split(/\n/);
let html = '';
const mod = state.langcount;
const useColors = (mod > 1 && state.visibleLangs.size > 1);
let langIndex = 0;
const useMarkers = state.hasMarkers;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let isHeader = false;
if (i === 0) {
// Prüfen, ob erste Zeile eine Überschrift ist: Vers, Refrain, Strophe, optional mit Zahl
if (/^\s*(Vers|Refrain|Strophe)(\s+\d+)?\s*$/i.test(line)) {
isHeader = true;
}
}
if (isHeader) {
html += `<div class="section-header">${escapeHtml(line)}</div>`;
langIndex = 0; // Überschrift zurücksetzen
} else {
let this_lang;
let 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)) {
if (useColors) {
const cls = `lang${this_lang}`;
html += `<div class="${cls}">${escapeHtml(l)}</div>`;
} else {
html += `<div>${escapeHtml(l)}</div>`;
}
}
}
}
elViewer.innerHTML = `<div class="slide"><pre>${html}</pre></div>`;
renderProgress();
}
function renderProgress() {
elProgress.innerHTML = '';
if (!state.flatSlides.length || state.blackScreen) return;
for (let i = 0; i < state.flatSlides.length; i++) {
const circ = document.createElement('div');
circ.className = 'circle' + (i === state.slideIndex ? ' active' : '');
elProgress.appendChild(circ);
}
}
// ---------- Foliengalerie (Slide Strip) ----------
function renderSlideStrip() {
const strip = qs('#slide-strip');
if (!strip) return;
if (!state.flatSlides.length) { strip.innerHTML = ''; return; }
const frag = document.createDocumentFragment();
state.flatSlides.forEach((s, i) => {
const card = document.createElement('div');
card.className = 'strip-card' + (i === state.slideIndex ? ' active' : '');
const num = document.createElement('div');
num.className = 'strip-num';
num.textContent = (i + 1) + (s.section ? ' · ' + s.section : '');
card.appendChild(num);
const text = document.createElement('div');
text.className = 'strip-text';
text.textContent = s.text.replace(/#\d+#\s*/g, '').trim().substring(0, 120);
card.appendChild(text);
card.addEventListener('click', () => {
state.slideIndex = i;
state.blackScreen = false;
state.customText = null;
state.bibleRef = null;
currentBibleMatch = null;
bibleMode = false;
biblePreview = false;
renderSlide();
renderSlideStrip();
renderProgress();
syncState();
});
frag.appendChild(card);
});
strip.innerHTML = '';
strip.appendChild(frag);
// Aktive Karte sichtbar scrollen
setTimeout(() => {
const active = strip.querySelector('.strip-card.active');
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}, 40);
}
// ---------- Navigation ----------
function next() {
state.blackScreen = false;
if (activeTab === TAB_DIASHOW) {
state.type = 'diashow';
if (state.diashowMedia && state.diashowMedia.length > 0) {
state.slideIndex = Math.min(state.slideIndex + 1, state.diashowMedia.length - 1);
renderDiashowViewer();
syncState();
}
return;
}
// If we are on TAB_SONGS
state.type = 'song';
if (state.customText && currentBibleMatch) {
navigateBible(1);
return;
}
if (state.slideIndex < state.flatSlides.length - 1) { state.slideIndex++; renderSlide(); renderSlideStrip(); syncState(); }
}
function prev() {
state.blackScreen = false;
if (activeTab === TAB_DIASHOW) {
state.type = 'diashow';
if (state.diashowMedia && state.diashowMedia.length > 0) {
state.slideIndex = Math.max(state.slideIndex - 1, 0);
renderDiashowViewer();
syncState();
}
return;
}
state.type = 'song';
if (state.customText && currentBibleMatch) {
navigateBible(-1);
return;
}
if (state.slideIndex > 0) { state.slideIndex--; renderSlide(); renderSlideStrip(); syncState(); }
}
let qDown = false;
document.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 'q') qDown = true;
// Q + 1 / Q + 2 Shortcuts für Bibel
if (qDown && (e.key === '1' || e.key === '2')) {
e.preventDefault();
if (state.customText && currentBibleMatch) {
const dir = (e.key === '1') ? -1 : 1;
navigateBible(dir);
}
return;
}
if (qDown) return;
// Alt+F oder / → Suchfeld fokussieren
if ((e.altKey && e.key.toLowerCase() === 'f') || (e.key === '/' && document.activeElement.tagName !== 'INPUT')) {
e.preventDefault();
elSearch.focus();
elSearch.select();
return;
}
const isInput = document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA';
if (isInput && document.activeElement === elSearch) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const items = Array.from(elList.querySelectorAll('.list-item'));
if (items.length > 0) {
if (e.key === 'ArrowDown') {
searchSelectedIndex = Math.min(items.length - 1, searchSelectedIndex + 1);
} else {
searchSelectedIndex = Math.max(0, searchSelectedIndex - 1);
}
updateSearchSelection(items);
}
return;
}
}
// Wenn wir in einem Eingabefeld tippen, blockiere globale Shortcuts
if (isInput) {
if (['ArrowRight', 'ArrowLeft', '+', '-', '=', 'Enter'].includes(e.key) || (e.key >= '0' && e.key <= '9')) {
if (e.key !== 'Enter') return;
}
}
if (e.key === 'ArrowRight' || e.key === '+' || e.key === '=') { next(); }
else if (e.key === 'ArrowLeft' || e.key === '-') { prev(); }
else if (e.key >= '1' && e.key <= '9') {
if (!bibleMode) { // Nur wenn nicht im Suche-Modus kollidiert
const idx = parseInt(e.key) - 1;
if (idx < state.flatSlides.length) {
state.slideIndex = idx;
state.blackScreen = false;
renderSlide();
renderSlideStrip();
syncState();
}
}
}
else if (e.key === '0') {
state.blackScreen = !state.blackScreen;
renderSlide();
syncState();
}
else if (e.key === 'Enter') {
if (bibleMode && currentBibleMatch) {
e.preventDefault();
if (!biblePreview) {
// Erstes Enter → Vorschau lokal (nicht auf Beamer)
biblePreview = true;
updateBibleView(true);
} else {
// Zweites Enter → Live auf Beamer
biblePreview = false;
updateBibleView(false);
elSearch.value = '';
elBibleHint.style.display = 'none';
bibleMode = false;
elSearch.blur();
}
} else if (!bibleMode) {
if (document.activeElement === elSearch) {
// Song Preview Logic from search bar
const items = Array.from(elList.querySelectorAll('.list-item'));
const selectedItem = items[searchSelectedIndex] || items[0];
if (selectedItem && selectedItem.dataset.rel) {
if (!songPreview) {
// Erstes Enter -> Vorschau
loadSong(selectedItem.dataset.rel, false);
} else {
// Zweites Enter -> Live!
syncState();
songPreview = false;
elSearch.value = '';
elSearch.blur();
searchSelectedIndex = -1;
}
}
} else if (state.flatSlides.length > 0) {
// Normaler Slide-Modus: Enter = Nächste Folie
e.preventDefault();
next();
}
}
}
});
document.addEventListener('keyup', (e) => {
if (e.key.toLowerCase() === 'q') qDown = false;
});
btnPrev.addEventListener('click', prev);
btnNext.addEventListener('click', next);
function navigateBible(dir) {
if (!currentBibleMatch) return;
const b = bibleData.books[currentBibleMatch.bookIndex];
if (!b) return;
// Aktuelles Kapitel finden
// Hinweis: currentBibleMatch ist statisch? Nein, wir updaten es.
let cIndex = b.chapters.findIndex(ch => ch.chapter === currentBibleMatch.chapter);
if (cIndex === -1) return;
const c = b.chapters[cIndex];
let nextV = currentBibleMatch.verseStart + dir;
let vObj = c.verses.find(v => v.verse === nextV);
if (!vObj) {
// Vers nicht im aktuellen Kapitel gefunden -> Kapitelwechsel
if (dir > 0) {
// Nächstes Kapitel
const nextC = b.chapters[cIndex + 1];
if (nextC && nextC.verses.length > 0) {
vObj = nextC.verses[0]; // Erster Vers
currentBibleMatch.chapter = nextC.chapter;
nextV = vObj.verse;
}
} else {
// Vorheriges Kapitel
const prevC = b.chapters[cIndex - 1];
if (prevC && prevC.verses.length > 0) {
vObj = prevC.verses[prevC.verses.length - 1]; // Letzter Vers
currentBibleMatch.chapter = prevC.chapter;
nextV = vObj.verse;
}
}
}
if (vObj) {
currentBibleMatch.verseStart = nextV;
currentBibleMatch.verseEnd = nextV;
currentBibleMatch.text = vObj.text;
biblePreview = false; // Direkt Live
updateBibleView(false);
}
}
function updateBibleView(isPreview) {
if (!currentBibleMatch) return;
state.customText = currentBibleMatch.text;
state.frameUrl = null; // Frame deaktivieren
switchTab(TAB_SONGS);
const bookName = bookNamesDe[currentBibleMatch.bookIndex] || bibleData.books[currentBibleMatch.bookIndex].name;
state.bibleRef = `${bookName} ${currentBibleMatch.chapter},${currentBibleMatch.verseStart}`;
// Lokale Anzeige update
biblePreview = isPreview;
renderSlide();
// Sync nur wenn nicht Preview
if (!isPreview) {
state.blackScreen = false; // aufwecken
addToHistory('bible', state.bibleRef, { customText: state.customText, bibleRef: state.bibleRef });
syncState();
}
}
// ---------- Schriftgröße ----------
btnFontInc.addEventListener('click', () => { state.fontScale = Math.min(2.0, state.fontScale + 0.05); setFontScale(state.fontScale); });
btnFontDec.addEventListener('click', () => { state.fontScale = Math.max(0.3, state.fontScale - 0.05); setFontScale(state.fontScale); });
// ---------- Suche live ----------
elSearch.addEventListener('input', (e) => {
searchSelectedIndex = -1;
const val = e.target.value;
if (val.startsWith('/')) {
bibleMode = true;
handleBibleSearch(val.substring(1));
// elList.innerHTML = ''; // Liste nicht mehr leeren
} else {
bibleMode = false;
elBibleHint.style.display = 'none';
if (activeTab !== TAB_SONGS) switchTab(TAB_SONGS);
loadList(val);
}
});
// ---------- Bibelstellensuche: Prefix-Matching ----------
function findBibleBook(q) {
// 1. Exakter Treffer
if (bookMap.hasOwnProperty(q)) return [bookMap[q]];
// 2. Prefix-Treffer über alle Schlüssel (dedupliziert nach Buchindex)
const seen = new Set();
for (const [key, idx] of Object.entries(bookMap)) {
if (key.startsWith(q)) seen.add(idx);
}
return [...seen];
}
function handleBibleSearch(q) {
if (bibleLoadStatus !== 'ready') {
elBibleHint.style.display = 'block';
elBibleHint.style.color = 'var(--lang2)';
if (bibleLoadStatus === 'loading') {
elBibleHint.textContent = 'Bibel wird geladen... (bitte warten)';
} else if (bibleLoadStatus === 'error') {
elBibleHint.textContent = `Fehler beim Laden: ${bibleLoadError}`;
elBibleHint.style.color = 'red';
} else {
elBibleHint.textContent = 'Bibel-Status unbekannt.';
}
return;
}
// Reset Style
elBibleHint.style.color = 'var(--lang2)';
q = q.trim().toLowerCase();
// Regex: Optionaler Punkt & Leerzeichen nach Nummern, um "1. Petrus" als "1petrus" zu behandeln
const m = q.match(/^([1-5]?\.?\s*[a-zäöüß]+)\s*(.*)$/);
if (!m) {
elBibleHint.style.display = 'none';
currentBibleMatch = null;
return;
}
// Entferne Punkte und Leerzeichen aus dem Buch-Präfix
const buchRaw = m[1].replace(/[\.\s]/g, '');
const refRaw = m[2];
const bookMatches = findBibleBook(buchRaw);
if (bookMatches.length === 0) {
elBibleHint.textContent = `Buch "${buchRaw}" nicht erkannt`;
elBibleHint.style.display = 'block';
currentBibleMatch = null;
return;
}
if (bookMatches.length > 1) {
const names = bookMatches.slice(0, 5).map(i => bookNamesDe[i]).join(', ');
elBibleHint.textContent = `Meinst du: ${names}${bookMatches.length > 5 ? ' ...' : ''}`;
elBibleHint.style.display = 'block';
currentBibleMatch = null;
return;
}
const bookIdx = bookMatches[0];
const bookName = bookNamesDe[bookIdx];
// Wenn keine Referenz, zeige nur Buch an
if (!refRaw) {
elBibleHint.textContent = `${bookName}`;
elBibleHint.style.display = 'block';
currentBibleMatch = null;
return;
}
// Referenz parsen: [Kapitel] , [Vers]
const rm = refRaw.match(/^(\d+)[\.,:](\d+)$/);
if (rm) {
const chap = parseInt(rm[1]);
const vrs = parseInt(rm[2]);
// Vers suchen
const bObj = bibleData.books[bookIdx];
if (!bObj) {
console.error('Buch Index invalid in Daten', bookIdx);
return;
}
// Kapitel finden
const cObj = bObj.chapters.find(c => c.chapter === chap);
if (cObj) {
// Vers finden
const vObj = cObj.verses.find(v => v.verse === vrs);
if (vObj) {
const txt = vObj.text;
elBibleHint.textContent = `${bookName} ${chap},${vrs}`;
elBibleHint.style.display = 'block';
// Match speichern für Enter
currentBibleMatch = {
bookIndex: bookIdx,
chapter: chap,
verseStart: vrs,
verseEnd: vrs,
text: txt
};
return;
} else {
elBibleHint.textContent = `${bookName} ${chap},${vrs} (Vers nicht gefunden)`;
elBibleHint.style.display = 'block';
}
} else {
elBibleHint.textContent = `${bookName} ${chap} (Kapitel nicht gefunden)`;
elBibleHint.style.display = 'block';
}
} else {
// Unvollständig
elBibleHint.textContent = `${bookName} ${refRaw}`;
elBibleHint.style.display = 'block';
}
currentBibleMatch = null;
}
// ---------- State syncen ----------
async function syncState() {
const syncData = {
type: state.type || 'song', // song, diashow
rel: state.activeRel,
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
};
if (state.type === 'diashow') {
syncData.diashowMedia = state.diashowMedia;
syncData.diashowSettings = state.diashowSettings;
}
await api('?action=set_state', 'POST', syncData);
}
// ---------- Tab System ----------
const TAB_SONGS = 'songs';
const TAB_FRAME = 'frame';
const TAB_DIASHOW = 'diashow';
let activeTab = TAB_SONGS;
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
const sc = qs('#songs-content');
const fc = qs('#frame-content');
const dc = qs('#diashow-content');
if (sc) sc.style.display = tab === TAB_SONGS ? 'flex' : 'none';
if (fc) fc.style.display = tab === TAB_FRAME ? 'flex' : 'none';
if (dc) dc.style.display = tab === TAB_DIASHOW ? 'flex' : 'none';
qs('.wrap').classList.toggle('no-sidebar', tab === TAB_FRAME);
// Sidebar updaten
if (tab === TAB_SONGS) {
loadList(qs('#search').value);
} else if (tab === TAB_DIASHOW) {
loadDiashows();
}
// Beim Wechsel zu Songs/Diashow: Frame stoppen
if (tab !== TAB_FRAME && state.frameUrl) {
state.frameUrl = null;
const previewIframe = qs('#frame-preview-iframe');
if (previewIframe) previewIframe.src = 'about:blank';
const statusEl = qs('#frame-status');
if (statusEl) statusEl.textContent = 'Keine Webseite aktiv';
if (tab === TAB_SONGS) renderSlide();
syncState();
}
}
// ---------- Broadcast History ----------
const HISTORY_KEY = 'sb_broadcast_history';
const MAX_HISTORY = 50;
function getHistory() {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; }
}
function addToHistory(type, label, data) {
if (!label) return;
const history = getHistory();
if (history.length > 0 && history[0].type === type && history[0].label === label) return;
history.unshift({ type, label, data, time: Date.now() });
if (history.length > MAX_HISTORY) history.length = MAX_HISTORY;
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
renderHistory();
}
function renderHistory() {
const el = qs('#history-list');
if (!el) return;
const history = getHistory();
el.innerHTML = '';
for (const item of history) {
const d = document.createElement('div');
d.className = 'history-item';
const icon = item.type === 'song' ? '🎵' : item.type === 'bible' ? '📖' : item.type === 'diashow' ? '🖼️' : '🌐';
d.innerHTML = `<span class="history-icon">${icon}</span><span class="history-label">${escapeHtml(item.label)}</span>`;
d.title = item.label;
d.addEventListener('click', () => rebroadcast(item));
el.appendChild(d);
}
}
function rebroadcast(item) {
if (item.type === 'song') {
switchTab(TAB_SONGS);
loadSong(item.data.rel, true);
} else if (item.type === 'bible') {
state.customText = item.data.customText;
state.bibleRef = item.data.bibleRef;
state.frameUrl = null;
state.blackScreen = false;
currentBibleMatch = null;
switchTab(TAB_SONGS);
renderSlide();
addToHistory('bible', item.data.bibleRef, item.data);
syncState();
} else if (item.type === 'frame') {
switchTab(TAB_FRAME);
broadcastFrame(item.data.url);
} else if (item.type === 'diashow') {
switchTab(TAB_DIASHOW);
loadDiashow(item.data.rel);
}
}
// ---------- Frame Broadcasting ----------
function broadcastFrame(url) {
if (!url) return;
state.frameUrl = url;
state.customText = null;
state.bibleRef = null;
state.blackScreen = false;
currentBibleMatch = null;
bibleMode = false;
biblePreview = false;
const previewIframe = qs('#frame-preview-iframe');
if (previewIframe) previewIframe.src = url;
const urlInput = qs('#frame-url-input');
if (urlInput) urlInput.value = url;
const statusEl = qs('#frame-status');
if (statusEl) statusEl.textContent = '🟢 Aktiv: ' + url;
addToHistory('frame', url, { url });
renderSlide();
syncState();
}
function stopFrame() {
state.frameUrl = null;
const previewIframe = qs('#frame-preview-iframe');
if (previewIframe) previewIframe.src = 'about:blank';
const statusEl = qs('#frame-status');
if (statusEl) statusEl.textContent = 'Keine Webseite aktiv';
renderSlide();
syncState();
}
// ---------- Frame Presets ----------
const PRESETS_KEY = 'sb_frame_presets';
function getPresets() {
try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '[]'); } catch { return []; }
}
function savePreset(name, url) {
const presets = getPresets();
presets.push({ name, url });
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
renderPresets();
}
function deletePreset(index) {
const presets = getPresets();
presets.splice(index, 1);
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
renderPresets();
}
function renderPresets() {
const el = qs('#preset-list');
if (!el) return;
const presets = getPresets();
el.innerHTML = '';
if (presets.length === 0) {
el.innerHTML = '<div style="color:var(--muted); font-size:.78rem; padding:.3rem;">Keine Presets gespeichert</div>';
return;
}
for (let i = 0; i < presets.length; i++) {
const p = presets[i];
const d = document.createElement('div');
d.className = 'preset-item';
d.innerHTML = `<span class="preset-name">${escapeHtml(p.name)}</span><span class="preset-url">${escapeHtml(p.url)}</span><button class="btn preset-use" title="Ausstrahlen">▶</button><button class="btn preset-del" title="Löschen">✕</button>`;
d.querySelector('.preset-use').addEventListener('click', () => broadcastFrame(p.url));
d.querySelector('.preset-del').addEventListener('click', () => { if (confirm('Preset "' + p.name + '" löschen?')) deletePreset(i); });
d.querySelector('.preset-name').addEventListener('click', () => broadcastFrame(p.url));
el.appendChild(d);
}
}
// ---------- Start ----------
loadList('').catch(err => {
elList.innerHTML = '<div class="path">Fehler beim Laden. Ist PHP aktiv?</div>';
console.error(err);
});
// ---------- Auto Update Poller ----------
function startAutoUpdate() {
setInterval(async () => {
try {
if (activeTab === TAB_SONGS) {
await loadList(qs('#search').value, true);
} else if (activeTab === TAB_DIASHOW) {
await loadDiashows(true);
}
} catch(e) {}
}, 2000);
}
startAutoUpdate();
// ---------- Init: Tabs, History, Presets ----------
renderHistory();
renderPresets();
document.querySelectorAll('.tab-btn').forEach(b => {
b.addEventListener('click', () => switchTab(b.dataset.tab));
});
qs('#btn-frame-go').addEventListener('click', () => {
const url = qs('#frame-url-input').value.trim();
if (url) broadcastFrame(url);
});
qs('#btn-frame-stop').addEventListener('click', stopFrame);
qs('#frame-url-input').addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
const url = e.target.value.trim();
if (url) broadcastFrame(url);
}
});
qs('#btn-preset-add').addEventListener('click', () => {
const form = qs('#preset-add-form');
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
if (form.style.display === 'flex') {
qs('#preset-url-input').value = qs('#frame-url-input').value || '';
qs('#preset-name-input').focus();
}
});
qs('#btn-preset-save').addEventListener('click', () => {
const name = qs('#preset-name-input').value.trim();
const url = qs('#preset-url-input').value.trim();
if (name && url) {
savePreset(name, url);
qs('#preset-add-form').style.display = 'none';
qs('#preset-name-input').value = '';
qs('#preset-url-input').value = '';
}
});
qs('#btn-preset-cancel').addEventListener('click', () => {
qs('#preset-add-form').style.display = 'none';
});
qs('#preset-name-input').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); qs('#btn-preset-save').click(); }
});
qs('#preset-url-input').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); qs('#btn-preset-save').click(); }
});
</script>
</body>
</html>