2571 lines
84 KiB
PHP
2571 lines
84 KiB
PHP
<?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 => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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>
|