Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
- /pult als installierbare Web-App (Manifest, Service Worker, Symbole) - Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen ueber die neue Browser-Source /sounds - Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS - Browser-Sources melden, welche Seite sie sind Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70448967f5
commit
8be195d938
29 files changed
+2935
-28
No files matched your search
@@ -27,7 +27,11 @@
|
||||
|
||||
function verbinde() {
|
||||
if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } }
|
||||
var url = '/events' + (params.get('key') ? '?key=' + encodeURIComponent(params.get('key')) : '');
|
||||
// `quelle` sagt dem Server, welche Seite zuhoert. Er zaehlt damit nur -
|
||||
// das Pult will wissen, ob die Sound-Quelle in OBS ueberhaupt offen ist.
|
||||
var quelleName = location.pathname.replace(/^\/+|\.html$/g, '') || 'index';
|
||||
var url = '/events?quelle=' + encodeURIComponent(quelleName) +
|
||||
(params.get('key') ? '&key=' + encodeURIComponent(params.get('key')) : '');
|
||||
quelle = new EventSource(url);
|
||||
letzteNachricht = Date.now();
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "abdulive Pult",
|
||||
"short_name": "Pult",
|
||||
"description": "Soundboard, Szenen, Liegestütze und Vorlesen für den Stream von abdulive_.",
|
||||
"lang": "de",
|
||||
"id": "/pult",
|
||||
"start_url": "/pult",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0d0b14",
|
||||
"theme_color": "#0d0b14",
|
||||
"icons": [
|
||||
{ "src": "/icons/pult-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/pult-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/* Das Pult. Fuers Handy gebaut, nicht fuer OBS: anders als die
|
||||
* Browser-Sources hat es einen deckenden Hintergrund und will angefasst
|
||||
* werden. Farben wie in theme.css, damit Pult und Overlay zusammengehoeren.
|
||||
*
|
||||
* Grundregel fuer alles, was man im Stream mit einem Daumen trifft: eine
|
||||
* Taste ist mindestens 48 px hoch, und was passiert ist, sieht man an der
|
||||
* Taste selbst - nicht nur in einer Meldung am Rand. */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--grund: #0d0b14;
|
||||
--flaeche: #171327;
|
||||
--flaeche-2: #1f1a33;
|
||||
--linie: #2c2642;
|
||||
--linie-stark: #3a3352;
|
||||
--schrift: #ece9f6;
|
||||
--leise: #9b94b3;
|
||||
|
||||
--lila: #a970ff;
|
||||
--lila-tief: #6441a5;
|
||||
--tuerkis: #4fd1c5;
|
||||
--pink: #ff4fa3;
|
||||
--gold: #ffc53d;
|
||||
--gruen: #4ade80;
|
||||
--rot: #ef4444;
|
||||
--blau: #60a5fa;
|
||||
--grau: #8b84a3;
|
||||
|
||||
--rund: 16px;
|
||||
--kopf-h: 56px;
|
||||
--reiter-h: 64px;
|
||||
--oben: env(safe-area-inset-top, 0px);
|
||||
--unten: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
--font: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
||||
--font-zahl: "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { background: var(--grund); }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--grund);
|
||||
color: var(--schrift);
|
||||
font: 16px/1.45 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
a { color: var(--lila); }
|
||||
code {
|
||||
font: .85em "Cascadia Mono", ui-monospace, Consolas, monospace;
|
||||
background: var(--flaeche-2); padding: .1em .35em; border-radius: 6px;
|
||||
}
|
||||
svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2;
|
||||
stroke-linecap: round; stroke-linejoin: round; flex: none; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ------------------------------------------------------------ Kopf */
|
||||
|
||||
.kopf {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
height: calc(var(--kopf-h) + var(--oben));
|
||||
padding: var(--oben) 16px 0;
|
||||
background: rgba(13, 11, 20, .88);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--linie);
|
||||
}
|
||||
.kanal { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.marke { font: 600 17px/1 var(--font-zahl); letter-spacing: -.01em; }
|
||||
.marke b { color: var(--lila); font-weight: 700; }
|
||||
|
||||
.live {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
font: 600 13px/1.2 var(--font-zahl); font-variant-numeric: tabular-nums;
|
||||
background: rgba(239, 68, 68, .16); color: #ffb3b3;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.live i { width: 8px; height: 8px; border-radius: 50%; background: var(--rot);
|
||||
animation: puls 1.6s ease-in-out infinite; flex: none; }
|
||||
.live.aus { background: var(--flaeche-2); color: var(--leise); }
|
||||
.live.aus i { background: var(--grau); animation: none; }
|
||||
|
||||
.kopf-rechts { display: flex; align-items: center; gap: 8px; }
|
||||
.rund {
|
||||
display: grid; place-items: center; width: 40px; height: 40px;
|
||||
border-radius: 50%; border: 1px solid var(--linie); background: transparent;
|
||||
color: var(--leise); cursor: pointer;
|
||||
}
|
||||
.rund[aria-pressed="true"] { color: var(--gold); border-color: rgba(255, 197, 61, .45);
|
||||
background: rgba(255, 197, 61, .1); }
|
||||
.netz { display: grid; place-items: center; width: 24px; height: 40px; }
|
||||
.netz i { width: 10px; height: 10px; border-radius: 50%; background: var(--rot);
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, .15); }
|
||||
.netz.an i { background: var(--gruen); box-shadow: 0 0 0 4px rgba(74, 222, 128, .14); }
|
||||
|
||||
.banner {
|
||||
position: sticky; top: calc(var(--kopf-h) + var(--oben)); z-index: 19;
|
||||
padding: 10px 16px; font-size: 14px;
|
||||
background: #3b1d1d; color: #ffd0d0; border-bottom: 1px solid #5c2a2a;
|
||||
}
|
||||
|
||||
@keyframes puls { 50% { opacity: .35; } }
|
||||
|
||||
/* ------------------------------------------------------------ Inhalt */
|
||||
|
||||
main {
|
||||
padding: 16px 16px calc(var(--reiter-h) + var(--unten) + 88px);
|
||||
max-width: 980px; margin: 0 auto;
|
||||
}
|
||||
.tab { display: none; }
|
||||
.tab.an { display: block; animation: rein .18s ease-out; }
|
||||
@keyframes rein { from { opacity: 0; transform: translateY(4px); } }
|
||||
|
||||
.leiste { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||
.luecke { flex: 1; }
|
||||
|
||||
.abschnitt {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin: 26px 0 10px; font: 600 13px/1 var(--font); letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--leise);
|
||||
}
|
||||
.abschnitt:first-child { margin-top: 4px; }
|
||||
.klein { font-size: 13px; color: var(--leise); margin: 10px 0; }
|
||||
.klein.mitte { text-align: center; }
|
||||
|
||||
.karte {
|
||||
background: var(--flaeche); border: 1px solid var(--linie);
|
||||
border-radius: var(--rund); padding: 16px; margin-bottom: 16px;
|
||||
}
|
||||
.karte .abschnitt { margin-top: 0; }
|
||||
|
||||
.hinweis {
|
||||
border-radius: 12px; padding: 12px 14px; margin-bottom: 14px; font-size: 14px;
|
||||
background: var(--flaeche); border: 1px solid var(--linie);
|
||||
}
|
||||
.hinweis.warn { background: rgba(255, 197, 61, .08); border-color: rgba(255, 197, 61, .4);
|
||||
color: #ffe2a0; }
|
||||
.hinweis.warn a { color: var(--gold); }
|
||||
|
||||
/* ------------------------------------------------------------ Knoepfe */
|
||||
|
||||
.knopf {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
min-height: 44px; padding: 0 16px;
|
||||
font: 600 15px/1 var(--font); color: var(--schrift);
|
||||
background: var(--flaeche-2); border: 1px solid var(--linie-stark);
|
||||
border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none;
|
||||
text-decoration: none; touch-action: manipulation;
|
||||
transition: transform .08s, background .15s, border-color .15s;
|
||||
}
|
||||
.knopf:active { transform: scale(.96); }
|
||||
.knopf:disabled { opacity: .4; cursor: not-allowed; transform: none; }
|
||||
.knopf.primaer { background: var(--lila-tief); border-color: var(--lila); color: #fff; }
|
||||
.knopf.warnung { color: #ffb3b3; border-color: rgba(239, 68, 68, .5); background: rgba(239, 68, 68, .1); }
|
||||
.knopf.gross { min-height: 52px; padding: 0 22px; font-size: 16px; }
|
||||
.knopf.breit { width: 100%; }
|
||||
.knopf[aria-pressed="true"] { background: var(--schrift); color: var(--grund); border-color: var(--schrift); }
|
||||
.knopf.wartet { opacity: .7; pointer-events: none; }
|
||||
.knopf:focus-visible, .kachel:focus-visible, .szene:focus-visible, .rund:focus-visible,
|
||||
.reiter button:focus-visible { outline: 2px solid var(--lila); outline-offset: 2px; }
|
||||
|
||||
/* ------------------------------------------------------------ Soundraster */
|
||||
|
||||
.raster {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@media (min-width: 640px) { .raster { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 12px; } }
|
||||
|
||||
.kachel {
|
||||
--c: var(--lila);
|
||||
position: relative;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
|
||||
aspect-ratio: 1; padding: 10px 8px; min-width: 0;
|
||||
border-radius: 18px; cursor: pointer; overflow: hidden;
|
||||
color: #fff; font: 600 14px/1.2 var(--font); text-align: center;
|
||||
background:
|
||||
radial-gradient(120% 90% at 30% 0%, color-mix(in oklab, var(--c) 42%, transparent), transparent 70%),
|
||||
color-mix(in oklab, var(--c) 14%, var(--flaeche));
|
||||
border: 1px solid color-mix(in oklab, var(--c) 45%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
|
||||
touch-action: manipulation; user-select: none; -webkit-user-select: none;
|
||||
transition: transform .08s ease-out;
|
||||
}
|
||||
.kachel:active { transform: scale(.93); }
|
||||
.kachel .emoji { font-size: 34px; line-height: 1; }
|
||||
.kachel .name {
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; overflow-wrap: anywhere; max-width: 100%;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.kachel.ohne-emoji .name { font-size: 16px; -webkit-line-clamp: 3; }
|
||||
.kachel::after {
|
||||
content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
|
||||
background: color-mix(in oklab, var(--c) 70%, #fff); opacity: 0;
|
||||
}
|
||||
.kachel.knall::after { animation: knall .6s ease-out; }
|
||||
@keyframes knall { 0% { opacity: .55; } 100% { opacity: 0; } }
|
||||
.kachel.knall { animation: wippen .35s ease-out; }
|
||||
@keyframes wippen { 40% { transform: scale(1.04); } }
|
||||
|
||||
.raster.bearbeiten .kachel { animation: zittern .5s ease-in-out infinite alternate; }
|
||||
.raster.bearbeiten .kachel:nth-child(2n) { animation-delay: -.25s; }
|
||||
@keyframes zittern { from { transform: rotate(-.8deg); } to { transform: rotate(.8deg); } }
|
||||
.raster.bearbeiten .kachel::before {
|
||||
content: "✎"; position: absolute; top: 6px; right: 8px; font-size: 14px; opacity: .85;
|
||||
}
|
||||
|
||||
.kachel[data-farbe="lila"], .farbe[data-farbe="lila"] { --c: var(--lila); }
|
||||
.kachel[data-farbe="tuerkis"], .farbe[data-farbe="tuerkis"] { --c: var(--tuerkis); }
|
||||
.kachel[data-farbe="pink"], .farbe[data-farbe="pink"] { --c: var(--pink); }
|
||||
.kachel[data-farbe="gold"], .farbe[data-farbe="gold"] { --c: var(--gold); }
|
||||
.kachel[data-farbe="gruen"], .farbe[data-farbe="gruen"] { --c: var(--gruen); }
|
||||
.kachel[data-farbe="rot"], .farbe[data-farbe="rot"] { --c: var(--rot); }
|
||||
.kachel[data-farbe="blau"], .farbe[data-farbe="blau"] { --c: var(--blau); }
|
||||
.kachel[data-farbe="grau"], .farbe[data-farbe="grau"] { --c: var(--grau); }
|
||||
|
||||
.kachel.laedt { opacity: .55; pointer-events: none; }
|
||||
.kachel.laedt .name::after { content: " …"; }
|
||||
|
||||
.leer { text-align: center; padding: 32px 12px; color: var(--leise); }
|
||||
.leer h2 { color: var(--schrift); font-size: 20px; margin: 18px 0 6px; }
|
||||
.leer p { max-width: 34ch; margin: 0 auto 20px; }
|
||||
.leer-bild { display: inline-grid; grid-template-columns: repeat(3, 34px); gap: 6px; }
|
||||
.leer-bild span { aspect-ratio: 1; border-radius: 9px; border: 1.5px dashed var(--linie-stark); }
|
||||
.leer-bild span:nth-child(2) { border-color: var(--lila); background: rgba(169, 112, 255, .12); }
|
||||
.leer-bild span:nth-child(6) { border-color: var(--tuerkis); background: rgba(79, 209, 197, .1); }
|
||||
|
||||
/* ------------------------------------------------------------ Szenen */
|
||||
|
||||
.szenen { display: grid; gap: 8px; }
|
||||
@media (min-width: 640px) { .szenen { grid-template-columns: repeat(2, 1fr); } }
|
||||
.szene {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
min-height: 64px; padding: 0 18px; width: 100%;
|
||||
font: 600 17px/1.2 var(--font); color: var(--schrift); text-align: left;
|
||||
background: var(--flaeche); border: 1px solid var(--linie-stark); border-radius: var(--rund);
|
||||
cursor: pointer; touch-action: manipulation; transition: transform .08s, background .15s;
|
||||
}
|
||||
.szene:active { transform: scale(.98); }
|
||||
.szene .szene-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.szene .marke-an {
|
||||
font: 700 11px/1 var(--font); letter-spacing: .1em; text-transform: uppercase;
|
||||
padding: 5px 8px; border-radius: 6px; background: rgba(255, 255, 255, .16); display: none;
|
||||
}
|
||||
.szene.aktiv { background: var(--lila-tief); border-color: var(--lila); }
|
||||
.szene.aktiv .marke-an { display: inline-block; }
|
||||
.szene.wartet { opacity: .6; }
|
||||
|
||||
.knopf.clip {
|
||||
width: 100%; min-height: 64px; font-size: 17px; border-radius: var(--rund);
|
||||
background: rgba(255, 79, 163, .14); border-color: rgba(255, 79, 163, .55); color: #ffd1e8;
|
||||
}
|
||||
.knopf.clip svg { width: 24px; height: 24px; }
|
||||
|
||||
.ton { display: grid; gap: 8px; }
|
||||
.ton-zeile {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
min-height: 56px; padding: 0 14px 0 16px;
|
||||
font: 500 15px/1.2 var(--font); color: var(--schrift); text-align: left;
|
||||
background: var(--flaeche); border: 1px solid var(--linie); border-radius: 14px; cursor: pointer;
|
||||
}
|
||||
.ton-zeile .ton-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ton-zeile .ton-stand {
|
||||
font: 600 12px/1 var(--font); letter-spacing: .06em; text-transform: uppercase;
|
||||
padding: 7px 10px; border-radius: 8px; background: rgba(74, 222, 128, .14); color: var(--gruen);
|
||||
}
|
||||
.ton-zeile.stumm { border-color: rgba(239, 68, 68, .45); }
|
||||
.ton-zeile.stumm .ton-stand { background: rgba(239, 68, 68, .16); color: #ffb3b3; }
|
||||
.ton-zeile.stumm svg { color: #ffb3b3; }
|
||||
|
||||
.leer-karte { text-align: center; padding: 28px 18px; }
|
||||
.leer-karte h2 { font-size: 18px; margin: 0 0 8px; }
|
||||
.leer-karte p { color: var(--leise); margin: 0; font-size: 14px; }
|
||||
|
||||
/* ------------------------------------------------------------ Stream */
|
||||
|
||||
.lz-zahlen { display: flex; gap: 28px; margin: 4px 0 16px; }
|
||||
.lz-zahlen div { display: flex; flex-direction: column; }
|
||||
.lz-zahlen b { font: 700 52px/1 var(--font-zahl); font-variant-numeric: tabular-nums; }
|
||||
.lz-zahlen span { color: var(--leise); font-size: 13px; margin-top: 4px; }
|
||||
.lz-zahlen .gemacht b { color: var(--gruen); }
|
||||
.lz-knoepfe { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.lz-knoepfe .knopf { min-height: 56px; font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.zaehler {
|
||||
display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px;
|
||||
border-radius: 999px; background: var(--pink); color: #fff; font-size: 12px; letter-spacing: 0;
|
||||
}
|
||||
.wartend { display: grid; gap: 10px; margin-bottom: 12px; }
|
||||
.wartend .leer-zeile { color: var(--leise); font-size: 14px; }
|
||||
.nachricht { background: var(--flaeche-2); border-radius: 12px; padding: 12px; }
|
||||
.nachricht .wer { font-weight: 600; color: var(--tuerkis); font-size: 14px; }
|
||||
.nachricht .was { margin: 4px 0 10px; overflow-wrap: anywhere; }
|
||||
.nachricht .reihe { display: flex; gap: 8px; }
|
||||
.nachricht .reihe .knopf { flex: 1; }
|
||||
|
||||
.knopf.panik {
|
||||
width: 100%; min-height: 64px; margin-top: 8px; font-size: 18px; border-radius: var(--rund);
|
||||
background: var(--rot); border-color: var(--rot); color: #fff;
|
||||
}
|
||||
.knopf.panik svg { width: 24px; height: 24px; }
|
||||
|
||||
/* ------------------------------------------------------------ Test */
|
||||
|
||||
.gruppe { margin-bottom: 4px; }
|
||||
.gruppe .knoepfe { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
|
||||
.gruppe .knopf { min-height: 48px; font-weight: 500; font-size: 14px; padding: 0 10px; }
|
||||
|
||||
/* ------------------------------------------------------------ Reiter */
|
||||
|
||||
.reiter {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||
height: calc(var(--reiter-h) + var(--unten));
|
||||
padding-bottom: var(--unten);
|
||||
background: rgba(18, 14, 29, .94);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-top: 1px solid var(--linie);
|
||||
}
|
||||
.reiter button {
|
||||
position: relative;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
||||
background: none; border: 0; color: var(--leise); cursor: pointer;
|
||||
font: 500 12px/1 var(--font); touch-action: manipulation;
|
||||
}
|
||||
.reiter button svg { width: 24px; height: 24px; }
|
||||
.reiter button.an { color: var(--schrift); }
|
||||
.reiter button.an svg { color: var(--lila); }
|
||||
.reiter .punkt { position: absolute; top: 10px; left: calc(50% + 10px); width: 9px; height: 9px;
|
||||
border-radius: 50%; background: var(--pink); border: 2px solid var(--grund); }
|
||||
|
||||
/* ------------------------------------------------------------ Meldung */
|
||||
|
||||
.meldung {
|
||||
position: fixed; left: 50%; z-index: 40;
|
||||
bottom: calc(var(--reiter-h) + var(--unten) + 14px);
|
||||
transform: translate(-50%, 12px);
|
||||
max-width: min(92vw, 460px); padding: 12px 16px; border-radius: 12px;
|
||||
background: #241f35; border: 1px solid var(--linie-stark); color: var(--schrift);
|
||||
font-size: 15px; box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
|
||||
opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s;
|
||||
}
|
||||
.meldung.an { opacity: 1; transform: translate(-50%, 0); }
|
||||
.meldung.ok { border-color: rgba(74, 222, 128, .5); }
|
||||
.meldung.fehler { border-color: rgba(255, 197, 61, .6); color: #ffe2a0; }
|
||||
|
||||
/* ------------------------------------------------------------ Blatt */
|
||||
|
||||
.blatt {
|
||||
width: 100%; max-width: 560px; margin: auto auto 0; padding: 0;
|
||||
border: 1px solid var(--linie-stark); border-bottom: 0; border-radius: 22px 22px 0 0;
|
||||
background: var(--flaeche); color: var(--schrift);
|
||||
}
|
||||
@media (min-width: 640px) { .blatt { margin: auto; border-radius: 22px; border-bottom: 1px solid var(--linie-stark); } }
|
||||
.blatt::backdrop { background: rgba(5, 4, 9, .65); backdrop-filter: blur(2px); }
|
||||
.blatt[open] { animation: hoch .22s ease-out; }
|
||||
@keyframes hoch { from { transform: translateY(40px); opacity: 0; } }
|
||||
.blatt form { padding: 10px 18px calc(18px + var(--unten)); display: grid; gap: 16px; }
|
||||
.griff { width: 40px; height: 4px; border-radius: 4px; background: var(--linie-stark); margin: 0 auto; }
|
||||
.blatt-kopf { display: flex; gap: 10px; }
|
||||
.blatt input:not([type=range]) {
|
||||
font: inherit; color: var(--schrift); background: var(--flaeche-2);
|
||||
border: 1px solid var(--linie-stark); border-radius: 12px; min-height: 52px; padding: 0 14px;
|
||||
}
|
||||
.blatt input:focus { outline: 2px solid var(--lila); outline-offset: 0; }
|
||||
.emoji-feld { width: 64px; text-align: center; font-size: 26px !important; padding: 0 !important; }
|
||||
.name-feld { flex: 1; min-width: 0; font-size: 17px !important; }
|
||||
.schnell { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
|
||||
.schnell button {
|
||||
flex: none; width: 42px; height: 42px; border-radius: 10px; font-size: 22px;
|
||||
background: var(--flaeche-2); border: 1px solid var(--linie); cursor: pointer;
|
||||
}
|
||||
.feld { display: grid; gap: 10px; }
|
||||
.feld-name { font-size: 13px; color: var(--leise); display: flex; justify-content: space-between; }
|
||||
.feld-name b { color: var(--schrift); font-variant-numeric: tabular-nums; }
|
||||
.farben { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.farbe {
|
||||
--c: var(--lila);
|
||||
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
|
||||
background: var(--c); border: 3px solid var(--flaeche); box-shadow: 0 0 0 1px var(--linie-stark);
|
||||
}
|
||||
.farbe[aria-checked="true"] { box-shadow: 0 0 0 2px var(--schrift); }
|
||||
input[type=range] { width: 100%; accent-color: var(--lila); height: 32px; }
|
||||
.blatt-reihe { display: flex; gap: 8px; align-items: center; }
|
||||
.blatt-reihe #b-hoeren { flex: 1; }
|
||||
|
||||
/* ------------------------------------------------------------ Abgemeldet */
|
||||
|
||||
.abgemeldet {
|
||||
position: fixed; inset: 0; z-index: 50; display: grid; place-items: center;
|
||||
background: rgba(13, 11, 20, .96); text-align: center; padding: 24px;
|
||||
}
|
||||
.abgemeldet p { color: var(--leise); max-width: 32ch; margin: 8px auto 22px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0d0b14">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Pult">
|
||||
<title>Pult · abdulive</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/icons/pult-192.png" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/pult.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="kopf">
|
||||
<div class="kanal">
|
||||
<span class="marke">abdu<b>live</b></span>
|
||||
<span id="live" class="live aus"><i></i><span id="live-text">offline</span></span>
|
||||
</div>
|
||||
<div class="kopf-rechts">
|
||||
<button id="wach" class="rund" type="button" aria-pressed="false" title="Bildschirm anlassen">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v2M12 19v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M3 12h2M19 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
</button>
|
||||
<span id="netz" class="netz" title="Verbindung zum Hub"><i></i></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
|
||||
<main>
|
||||
<!-- ======================================================== Sounds -->
|
||||
<section id="tab-sounds" class="tab an" aria-labelledby="nav-sounds">
|
||||
<div class="leiste">
|
||||
<button id="stopp" class="knopf warnung" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
Stopp
|
||||
</button>
|
||||
<span class="luecke"></span>
|
||||
<button id="bearbeiten" class="knopf" type="button" aria-pressed="false">Bearbeiten</button>
|
||||
<label class="knopf primaer" for="datei">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Sound
|
||||
</label>
|
||||
<input id="datei" type="file" accept="audio/*,.mp3,.wav,.ogg,.m4a,.webm" multiple hidden>
|
||||
</div>
|
||||
<div id="quelle-fehlt" class="hinweis warn" hidden>
|
||||
In OBS ist keine Sound-Quelle verbunden – die Zuschauer hören nichts. Browser-Source
|
||||
<code>/sounds</code> anlegen, die Adresse steht auf der <a href="/">Übersicht</a>.
|
||||
</div>
|
||||
<div id="raster" class="raster" aria-live="polite"></div>
|
||||
<div id="sounds-leer" class="leer" hidden>
|
||||
<div class="leer-bild" aria-hidden="true">
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<h2>Noch keine Sounds</h2>
|
||||
<p>MP3, WAV, OGG oder M4A vom Handy hochladen. Jeder Sound wird eine Taste;
|
||||
ein Druck spielt ihn im Stream.</p>
|
||||
<label class="knopf primaer gross" for="datei">Ersten Sound hinzufügen</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================================================== Szenen -->
|
||||
<section id="tab-szenen" class="tab" aria-labelledby="nav-szenen">
|
||||
<div id="obs-fehlt" class="karte leer-karte" hidden>
|
||||
<h2 id="obs-fehlt-titel">Stream-PC nicht verbunden</h2>
|
||||
<p id="obs-fehlt-text"></p>
|
||||
</div>
|
||||
<div id="obs-da" hidden>
|
||||
<h3 class="abschnitt">Szenen</h3>
|
||||
<div id="szenen" class="szenen"></div>
|
||||
|
||||
<h3 class="abschnitt">Clip</h3>
|
||||
<button id="clip" class="knopf clip" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h3l2-3h6l2 3h3v12H4z"/><circle cx="12" cy="13" r="3.5"/></svg>
|
||||
<span>Clip speichern</span>
|
||||
</button>
|
||||
<p id="clip-hinweis" class="klein"></p>
|
||||
|
||||
<h3 class="abschnitt">Ton</h3>
|
||||
<div id="ton" class="ton"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================================================== Stream -->
|
||||
<section id="tab-stream" class="tab" aria-labelledby="nav-stream">
|
||||
<div id="lz-karte" class="karte" hidden>
|
||||
<h3 class="abschnitt">Liegestütze</h3>
|
||||
<div class="lz-zahlen">
|
||||
<div><b id="lz-offen">0</b><span>offen</span></div>
|
||||
<div class="gemacht"><b id="lz-gemacht">0</b><span>gemacht</span></div>
|
||||
</div>
|
||||
<div class="lz-knoepfe">
|
||||
<button class="knopf" data-um="-10" type="button">−10</button>
|
||||
<button class="knopf" data-um="-5" type="button">−5</button>
|
||||
<button class="knopf" data-um="-1" type="button">−1</button>
|
||||
<button class="knopf" data-um="1" type="button">+1</button>
|
||||
<button class="knopf" data-um="5" type="button">+5</button>
|
||||
<button class="knopf" data-um="10" type="button">+10</button>
|
||||
</div>
|
||||
<p class="klein">Minus zählt als gemacht. Korrekturen ohne Zählung auf der <a href="/">Übersicht</a>.</p>
|
||||
</div>
|
||||
|
||||
<div id="tts-karte" class="karte" hidden>
|
||||
<h3 class="abschnitt">Vorlesen <span id="tts-anzahl" class="zaehler" hidden></span></h3>
|
||||
<div id="wartend" class="wartend"></div>
|
||||
<button id="alle-ablehnen" class="knopf" type="button">Alle ablehnen</button>
|
||||
</div>
|
||||
|
||||
<button id="still" class="knopf panik" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM16 9l5 6M21 9l-5 6"/></svg>
|
||||
Sofort still
|
||||
</button>
|
||||
<p class="klein mitte">Bricht Vorlesen und alle Sounds ab und leert die Warteschlange.</p>
|
||||
</section>
|
||||
|
||||
<!-- ======================================================== Test -->
|
||||
<section id="tab-test" class="tab" aria-labelledby="nav-test">
|
||||
<div id="test-live" class="hinweis warn" hidden>Der Stream läuft. Was du hier auslöst, sehen die Zuschauer.</div>
|
||||
<p class="klein">Erfundene Ereignisse, nur für die Browser-Sources. Nichts davon landet
|
||||
in der Datenbank, auf Discord oder bei Twitch.</p>
|
||||
<div id="test-gruppen"></div>
|
||||
<h3 class="abschnitt">Dauerbetrieb</h3>
|
||||
<button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button>
|
||||
<p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="meldung" class="meldung" role="status" aria-live="polite"></div>
|
||||
|
||||
<nav class="reiter" aria-label="Bereiche">
|
||||
<button id="nav-sounds" data-tab="sounds" class="an" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="2"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2"/></svg>
|
||||
Sounds
|
||||
</button>
|
||||
<button id="nav-szenen" data-tab="szenen" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="13" rx="2"/><path d="M8 21h8"/></svg>
|
||||
Szenen
|
||||
</button>
|
||||
<button id="nav-stream" data-tab="stream" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
|
||||
Stream
|
||||
<i id="nav-tts" class="punkt" hidden></i>
|
||||
</button>
|
||||
<button id="nav-test" data-tab="test" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/></svg>
|
||||
Test
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<dialog id="blatt" class="blatt">
|
||||
<form method="dialog" id="blatt-form">
|
||||
<div class="griff" aria-hidden="true"></div>
|
||||
<div class="blatt-kopf">
|
||||
<input id="b-emoji" class="emoji-feld" maxlength="10" placeholder="🔊" aria-label="Emoji" autocomplete="off">
|
||||
<input id="b-name" class="name-feld" maxlength="40" placeholder="Name" aria-label="Name" autocomplete="off">
|
||||
</div>
|
||||
<div id="b-schnell" class="schnell" aria-label="Emoji-Vorschläge"></div>
|
||||
<div class="feld">
|
||||
<span class="feld-name">Farbe</span>
|
||||
<div id="b-farben" class="farben" role="radiogroup" aria-label="Farbe"></div>
|
||||
</div>
|
||||
<label class="feld">
|
||||
<span class="feld-name">Lautstärke <b id="b-laut-zahl">80 %</b></span>
|
||||
<input id="b-laut" type="range" min="0" max="100" step="5" value="80">
|
||||
</label>
|
||||
<div class="blatt-reihe">
|
||||
<button type="button" id="b-hoeren" class="knopf">Am Handy anhören</button>
|
||||
<button type="button" id="b-vor" class="knopf" aria-label="Nach vorn">◀</button>
|
||||
<button type="button" id="b-zurueck" class="knopf" aria-label="Nach hinten">▶</button>
|
||||
</div>
|
||||
<div class="blatt-reihe">
|
||||
<button type="button" id="b-loeschen" class="knopf warnung">Löschen</button>
|
||||
<span class="luecke"></span>
|
||||
<button type="button" id="b-abbrechen" class="knopf">Abbrechen</button>
|
||||
<button type="submit" id="b-speichern" class="knopf primaer">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="abgemeldet" class="abgemeldet" hidden>
|
||||
<div>
|
||||
<h2>Abgemeldet</h2>
|
||||
<p>Die Anmeldung ist abgelaufen oder das Passwort wurde geändert.</p>
|
||||
<a class="knopf primaer gross" href="/pult">Neu anmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pult.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,638 @@
|
||||
/* Das Pult: Soundboard, Szenen, Liegestuetze, Vorlesen - am Handy.
|
||||
*
|
||||
* Zwei Wege zum Hub:
|
||||
* - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei
|
||||
* offene Pulte und die Uebersichtsseite nicht auseinanderlaufen;
|
||||
* - einfache POSTs fuer jeden Druck. Jede Antwort sagt, ob es geklappt
|
||||
* hat, und genau das steht danach an der Taste.
|
||||
*
|
||||
* Fremder Text (Soundnamen, Szenen, Chat) kommt nur ueber textContent ins
|
||||
* DOM, nie ueber innerHTML. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'];
|
||||
var SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉'];
|
||||
|
||||
var zustand = {
|
||||
sounds: [],
|
||||
obs: { pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false },
|
||||
live: false,
|
||||
bearbeiten: false,
|
||||
ttsAn: false,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------ Hilfen
|
||||
|
||||
function el(tag, klasse, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (klasse) e.className = klasse;
|
||||
if (text !== undefined) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function tippen() {
|
||||
if (navigator.vibrate) { try { navigator.vibrate(12); } catch (e) { /* egal */ } }
|
||||
}
|
||||
|
||||
var meldungTimer;
|
||||
function melden(text, art) {
|
||||
var m = $('meldung');
|
||||
if (!text) return;
|
||||
m.textContent = text;
|
||||
m.className = 'meldung an' + (art ? ' ' + art : '');
|
||||
clearTimeout(meldungTimer);
|
||||
meldungTimer = setTimeout(function () { m.className = 'meldung'; }, art === 'fehler' ? 4500 : 2200);
|
||||
}
|
||||
|
||||
/** POST mit Formular, Antwort als JSON. Wirft nie; Fehler stehen in `ok`. */
|
||||
function rufen(pfad, felder, roh) {
|
||||
var opts = { method: 'POST', credentials: 'same-origin' };
|
||||
if (roh) {
|
||||
opts.body = roh;
|
||||
} else {
|
||||
opts.headers = { 'content-type': 'application/x-www-form-urlencoded' };
|
||||
opts.body = new URLSearchParams(felder || {});
|
||||
}
|
||||
return fetch(pfad, opts).then(function (res) {
|
||||
return res.json().catch(function () { return { text: 'Fehler ' + res.status }; })
|
||||
.then(function (body) {
|
||||
if (res.status === 401) abgemeldet();
|
||||
body.ok = res.ok && body.ok !== false;
|
||||
return body;
|
||||
});
|
||||
}, function () {
|
||||
return { ok: false, text: 'Keine Verbindung zum Hub.' };
|
||||
});
|
||||
}
|
||||
|
||||
function holen(pfad) {
|
||||
return fetch(pfad, { credentials: 'same-origin' }).then(function (res) {
|
||||
if (res.status === 401) { abgemeldet(); throw new Error('abgemeldet'); }
|
||||
if (!res.ok) throw new Error('Fehler ' + res.status);
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
function abgemeldet() { $('abgemeldet').hidden = false; }
|
||||
|
||||
/** Taste waehrend eines Rufs sperren, damit ein Doppeltipp nicht doppelt schaltet. */
|
||||
function mitWarten(knopf, versprechen) {
|
||||
knopf.classList.add('wartet');
|
||||
return versprechen.then(function (r) { knopf.classList.remove('wartet'); return r; });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Reiter
|
||||
|
||||
function zeigeReiter(name) {
|
||||
var reiter = document.querySelectorAll('.reiter button');
|
||||
for (var i = 0; i < reiter.length; i++) {
|
||||
reiter[i].classList.toggle('an', reiter[i].dataset.tab === name);
|
||||
}
|
||||
var tabs = document.querySelectorAll('.tab');
|
||||
for (var j = 0; j < tabs.length; j++) tabs[j].classList.toggle('an', tabs[j].id === 'tab-' + name);
|
||||
try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ }
|
||||
if (name === 'stream') ladeWartend();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
document.querySelector('.reiter').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-tab]');
|
||||
if (b) { tippen(); zeigeReiter(b.dataset.tab); }
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Kopf
|
||||
|
||||
var laufzeitTimer;
|
||||
function zeigeStream(s) {
|
||||
zustand.live = !!s.live;
|
||||
var live = $('live');
|
||||
live.classList.toggle('aus', !s.live);
|
||||
clearInterval(laufzeitTimer);
|
||||
var schreiben = function () {
|
||||
if (!s.live) { $('live-text').textContent = 'offline'; return; }
|
||||
var teile = ['LIVE'];
|
||||
if (s.startedAt) {
|
||||
var min = Math.max(0, Math.floor((Date.now() - Date.parse(s.startedAt)) / 60000));
|
||||
teile.push(Math.floor(min / 60) + ':' + String(min % 60).padStart(2, '0'));
|
||||
}
|
||||
teile.push((s.viewers || 0).toLocaleString('de-DE') + ' 👁');
|
||||
$('live-text').textContent = teile.join(' · ');
|
||||
};
|
||||
schreiben();
|
||||
if (s.live) laufzeitTimer = setInterval(schreiben, 30000);
|
||||
$('test-live').hidden = !s.live;
|
||||
var gesperrt = document.querySelectorAll('#test-gruppen [data-offline]');
|
||||
for (var i = 0; i < gesperrt.length; i++) gesperrt[i].disabled = !!s.live;
|
||||
var demo = $('demo');
|
||||
if (demo.dataset.art === 'demo-an') demo.disabled = !!s.live;
|
||||
}
|
||||
|
||||
// Bildschirm anlassen. Ein Pult, das nach 30 s schwarz wird, ist mitten
|
||||
// im Stream keins. Der Wunsch wird gemerkt; das Handy gibt die Sperre bei
|
||||
// jedem Wegwischen frei, also wird sie beim Zurueckkommen neu geholt.
|
||||
var sperre = null;
|
||||
var wachGewuenscht = false;
|
||||
try { wachGewuenscht = localStorage.getItem('pult-wach') === '1'; } catch (e) { /* privat */ }
|
||||
function wachHolen() {
|
||||
if (!wachGewuenscht || !('wakeLock' in navigator) || document.visibilityState !== 'visible') {
|
||||
$('wach').setAttribute('aria-pressed', String(!!sperre));
|
||||
return;
|
||||
}
|
||||
navigator.wakeLock.request('screen').then(function (s) {
|
||||
sperre = s;
|
||||
$('wach').setAttribute('aria-pressed', 'true');
|
||||
s.addEventListener('release', function () {
|
||||
sperre = null;
|
||||
$('wach').setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
}, function () { $('wach').setAttribute('aria-pressed', 'false'); });
|
||||
}
|
||||
$('wach').addEventListener('click', function () {
|
||||
tippen();
|
||||
if (!('wakeLock' in navigator)) { melden('Dieser Browser kann den Bildschirm nicht wach halten.', 'fehler'); return; }
|
||||
wachGewuenscht = !sperre;
|
||||
try { localStorage.setItem('pult-wach', wachGewuenscht ? '1' : '0'); } catch (e) { /* privat */ }
|
||||
if (sperre) { sperre.release(); sperre = null; melden('Bildschirm darf wieder ausgehen.'); }
|
||||
else { wachHolen(); melden('Bildschirm bleibt an.', 'ok'); }
|
||||
});
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') { wachHolen(); ladeSounds(); }
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Sounds
|
||||
|
||||
function ladeSounds() {
|
||||
return holen('/soundboard/liste').then(zeigeSounds, function () { /* Meldung kommt woanders */ });
|
||||
}
|
||||
|
||||
function zeigeSounds(body) {
|
||||
if (body.sounds) zustand.sounds = body.sounds;
|
||||
if (typeof body.quellen === 'number') $('quelle-fehlt').hidden = body.quellen > 0;
|
||||
var raster = $('raster');
|
||||
raster.textContent = '';
|
||||
raster.classList.toggle('bearbeiten', zustand.bearbeiten);
|
||||
$('sounds-leer').hidden = zustand.sounds.length > 0;
|
||||
$('bearbeiten').hidden = zustand.sounds.length === 0;
|
||||
if (!zustand.sounds.length && zustand.bearbeiten) setzeBearbeiten(false);
|
||||
|
||||
zustand.sounds.forEach(function (s) {
|
||||
var k = el('button', 'kachel' + (s.emoji ? '' : ' ohne-emoji'));
|
||||
k.type = 'button';
|
||||
k.dataset.id = s.id;
|
||||
k.dataset.farbe = FARBEN.indexOf(s.farbe) !== -1 ? s.farbe : 'lila';
|
||||
if (s.emoji) k.appendChild(el('span', 'emoji', s.emoji));
|
||||
k.appendChild(el('span', 'name', s.name));
|
||||
k.setAttribute('aria-label', (zustand.bearbeiten ? 'Bearbeiten: ' : 'Abspielen: ') + s.name);
|
||||
raster.appendChild(k);
|
||||
});
|
||||
}
|
||||
|
||||
function knall(id) {
|
||||
var k = document.querySelector('.kachel[data-id="' + CSS.escape(id) + '"]');
|
||||
if (!k) return;
|
||||
k.classList.remove('knall');
|
||||
void k.offsetWidth; // Animation neu starten, auch beim Doppeltipp
|
||||
k.classList.add('knall');
|
||||
}
|
||||
|
||||
$('raster').addEventListener('click', function (e) {
|
||||
var k = e.target.closest('.kachel');
|
||||
if (!k) return;
|
||||
tippen();
|
||||
var id = k.dataset.id;
|
||||
if (zustand.bearbeiten) { oeffneBlatt(id); return; }
|
||||
knall(id);
|
||||
rufen('/soundboard/spielen', { id: id }).then(function (r) {
|
||||
if (!r.ok) { melden(r.text, 'fehler'); return; }
|
||||
$('quelle-fehlt').hidden = r.quellen > 0;
|
||||
if (!r.quellen) melden(r.text, 'fehler');
|
||||
});
|
||||
});
|
||||
|
||||
$('stopp').addEventListener('click', function () {
|
||||
tippen();
|
||||
rufen('/soundboard/stopp').then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); });
|
||||
});
|
||||
|
||||
function setzeBearbeiten(an) {
|
||||
zustand.bearbeiten = an;
|
||||
$('bearbeiten').setAttribute('aria-pressed', String(an));
|
||||
$('bearbeiten').textContent = an ? 'Fertig' : 'Bearbeiten';
|
||||
$('raster').classList.toggle('bearbeiten', an);
|
||||
var kacheln = document.querySelectorAll('.kachel');
|
||||
for (var i = 0; i < kacheln.length; i++) {
|
||||
var s = zustand.sounds[i];
|
||||
if (s) kacheln[i].setAttribute('aria-label', (an ? 'Bearbeiten: ' : 'Abspielen: ') + s.name);
|
||||
}
|
||||
}
|
||||
$('bearbeiten').addEventListener('click', function () { tippen(); setzeBearbeiten(!zustand.bearbeiten); });
|
||||
|
||||
// Hochladen: roh im Koerper, eine Datei nach der anderen. Parallel waere
|
||||
// schneller, aber dann kommt die Reihenfolge durcheinander.
|
||||
$('datei').addEventListener('change', function (e) {
|
||||
var dateien = Array.prototype.slice.call(e.target.files || []);
|
||||
e.target.value = '';
|
||||
var weiter = Promise.resolve();
|
||||
dateien.forEach(function (d, i) {
|
||||
weiter = weiter.then(function () {
|
||||
melden((dateien.length > 1 ? (i + 1) + '/' + dateien.length + ' · ' : '') + d.name + ' wird hochgeladen …');
|
||||
return rufen('/soundboard/neu?name=' + encodeURIComponent(d.name), null, d).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
if (r.sounds) zeigeSounds(r);
|
||||
// Der erste Sound geht gleich ins Bearbeiten: ein Emoji macht aus
|
||||
// einer Liste von Dateinamen erst ein Pult.
|
||||
if (r.ok && r.sound && dateien.length === 1) oeffneBlatt(r.sound.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Blatt
|
||||
|
||||
var blatt = $('blatt');
|
||||
var offenId = null;
|
||||
var probe = null;
|
||||
|
||||
(function baueBlatt() {
|
||||
var schnell = $('b-schnell');
|
||||
SCHNELL.forEach(function (em) {
|
||||
var b = el('button', '', em);
|
||||
b.type = 'button';
|
||||
b.addEventListener('click', function () { $('b-emoji').value = em; tippen(); });
|
||||
schnell.appendChild(b);
|
||||
});
|
||||
var farben = $('b-farben');
|
||||
FARBEN.forEach(function (f) {
|
||||
var b = el('button', 'farbe');
|
||||
b.type = 'button';
|
||||
b.dataset.farbe = f;
|
||||
b.setAttribute('role', 'radio');
|
||||
b.setAttribute('aria-label', f);
|
||||
b.addEventListener('click', function () { waehleFarbe(f); tippen(); });
|
||||
farben.appendChild(b);
|
||||
});
|
||||
})();
|
||||
|
||||
function waehleFarbe(f) {
|
||||
var alle = $('b-farben').children;
|
||||
for (var i = 0; i < alle.length; i++) alle[i].setAttribute('aria-checked', String(alle[i].dataset.farbe === f));
|
||||
blatt.dataset.farbe = f;
|
||||
}
|
||||
|
||||
function oeffneBlatt(id) {
|
||||
var s = zustand.sounds.find(function (x) { return x.id === id; });
|
||||
if (!s) return;
|
||||
offenId = id;
|
||||
$('b-name').value = s.name;
|
||||
$('b-emoji').value = s.emoji || '';
|
||||
$('b-laut').value = Math.round((s.laut ?? 0.8) * 100);
|
||||
$('b-laut-zahl').textContent = $('b-laut').value + ' %';
|
||||
waehleFarbe(s.farbe);
|
||||
$('b-loeschen').textContent = 'Löschen';
|
||||
$('b-loeschen').dataset.sicher = '';
|
||||
if (typeof blatt.showModal === 'function') blatt.showModal(); else blatt.setAttribute('open', '');
|
||||
}
|
||||
|
||||
function schliesseBlatt() {
|
||||
if (probe) { probe.pause(); probe = null; }
|
||||
offenId = null;
|
||||
if (blatt.open) blatt.close();
|
||||
}
|
||||
|
||||
$('b-laut').addEventListener('input', function () {
|
||||
$('b-laut-zahl').textContent = $('b-laut').value + ' %';
|
||||
if (probe) probe.volume = Number($('b-laut').value) / 100;
|
||||
});
|
||||
|
||||
$('b-hoeren').addEventListener('click', function () {
|
||||
if (!offenId) return;
|
||||
if (probe) { probe.pause(); probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; return; }
|
||||
probe = new Audio('/soundboard/datei/' + encodeURIComponent(offenId));
|
||||
probe.volume = Number($('b-laut').value) / 100;
|
||||
$('b-hoeren').textContent = 'Anhören stoppen';
|
||||
probe.onended = function () { probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; };
|
||||
probe.play().catch(function () { melden('Abspielen am Handy ging nicht.', 'fehler'); });
|
||||
});
|
||||
|
||||
function verschieben(um) {
|
||||
if (!offenId) return;
|
||||
tippen();
|
||||
rufen('/soundboard/verschieben', { id: offenId, um: String(um) }).then(function (r) {
|
||||
if (r.sounds) zeigeSounds(r);
|
||||
});
|
||||
}
|
||||
$('b-vor').addEventListener('click', function () { verschieben(-1); });
|
||||
$('b-zurueck').addEventListener('click', function () { verschieben(1); });
|
||||
|
||||
$('b-loeschen').addEventListener('click', function () {
|
||||
var b = $('b-loeschen');
|
||||
// Zweimal tippen statt confirm(): ein Dialog des Browsers sieht am Handy
|
||||
// aus wie eine Fehlermeldung, und er haelt die Seite an.
|
||||
if (!b.dataset.sicher) { b.dataset.sicher = '1'; b.textContent = 'Wirklich löschen?'; tippen(); return; }
|
||||
var id = offenId;
|
||||
schliesseBlatt();
|
||||
rufen('/soundboard/loeschen', { id: id }).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
if (r.sounds) zeigeSounds(r);
|
||||
});
|
||||
});
|
||||
|
||||
$('b-abbrechen').addEventListener('click', schliesseBlatt);
|
||||
blatt.addEventListener('close', function () { if (probe) { probe.pause(); probe = null; } });
|
||||
blatt.addEventListener('click', function (e) { if (e.target === blatt) schliesseBlatt(); });
|
||||
|
||||
$('blatt-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var id = offenId;
|
||||
if (!id) return;
|
||||
var felder = {
|
||||
id: id,
|
||||
name: $('b-name').value,
|
||||
emoji: $('b-emoji').value,
|
||||
farbe: blatt.dataset.farbe || 'lila',
|
||||
laut: String(Number($('b-laut').value) / 100),
|
||||
};
|
||||
schliesseBlatt();
|
||||
rufen('/soundboard/aendern', felder).then(function (r) {
|
||||
if (!r.ok) melden(r.text, 'fehler');
|
||||
if (r.sounds) zeigeSounds(r);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ OBS
|
||||
|
||||
function zeigeObs(o) {
|
||||
zustand.obs = o;
|
||||
var fehlt = !o.pc || !o.obs;
|
||||
$('obs-fehlt').hidden = !fehlt;
|
||||
$('obs-da').hidden = fehlt;
|
||||
if (!o.pc) {
|
||||
$('obs-fehlt-titel').textContent = 'Stream-PC nicht verbunden';
|
||||
$('obs-fehlt-text').textContent = 'Auf dem Stream-PC muss abdulive ingest laufen und einmal mit ' +
|
||||
'„abdulive fern:verbinden …“ eingerichtet sein. Der Befehl steht auf der Übersicht unter „Stream-PC verbinden“.';
|
||||
} else if (!o.obs) {
|
||||
$('obs-fehlt-titel').textContent = 'OBS ist nicht erreichbar';
|
||||
$('obs-fehlt-text').textContent = 'Der Stream-PC ist da, aber OBS nicht. Läuft OBS, und ist ' +
|
||||
'obs-websocket an (Werkzeuge → WebSocket-Servereinstellungen)?';
|
||||
}
|
||||
if (fehlt) return;
|
||||
|
||||
var szenen = $('szenen');
|
||||
szenen.textContent = '';
|
||||
o.szenen.forEach(function (name) {
|
||||
var b = el('button', 'szene' + (name === o.aktuell ? ' aktiv' : ''));
|
||||
b.type = 'button';
|
||||
b.dataset.szene = name;
|
||||
b.appendChild(el('span', 'szene-name', name));
|
||||
b.appendChild(el('span', 'marke-an', 'Im Bild'));
|
||||
szenen.appendChild(b);
|
||||
});
|
||||
if (!o.szenen.length) szenen.appendChild(el('p', 'klein', 'OBS meldet keine Szenen.'));
|
||||
|
||||
$('clip').disabled = !o.puffer;
|
||||
$('clip-hinweis').textContent = o.puffer
|
||||
? 'Sichert die letzten Sekunden aus dem Wiedergabepuffer, wie !clip im Chat – ohne Abklingzeit.'
|
||||
: 'Der Wiedergabepuffer läuft nicht. In OBS unten rechts „Wiedergabepuffer starten“.';
|
||||
|
||||
var ton = $('ton');
|
||||
ton.textContent = '';
|
||||
o.ton.forEach(function (t) {
|
||||
var b = el('button', 'ton-zeile' + (t.stumm ? ' stumm' : ''));
|
||||
b.type = 'button';
|
||||
b.dataset.quelle = t.name;
|
||||
b.dataset.stumm = t.stumm ? '1' : '0';
|
||||
b.innerHTML = t.stumm
|
||||
? '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM16 9l5 6M21 9l-5 6"/></svg>'
|
||||
: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg>';
|
||||
b.appendChild(el('span', 'ton-name', t.name));
|
||||
b.appendChild(el('span', 'ton-stand', t.stumm ? 'stumm' : 'an'));
|
||||
ton.appendChild(b);
|
||||
});
|
||||
if (!o.ton.length) ton.appendChild(el('p', 'klein', 'Keine Toneingänge gefunden.'));
|
||||
}
|
||||
|
||||
function obsRuf(knopf, felder) {
|
||||
tippen();
|
||||
return mitWarten(knopf, rufen('/pult/obs', felder)).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
$('szenen').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.szene');
|
||||
if (!b || b.classList.contains('aktiv')) return;
|
||||
obsRuf(b, { was: 'szene', szene: b.dataset.szene }).then(function (r) {
|
||||
// Sofort umfaerben, nicht erst auf den naechsten Stand warten. Der
|
||||
// Stand vom PC kommt hinterher und rueckt es gerade, falls noetig.
|
||||
if (r.ok) zeigeObs(Object.assign({}, zustand.obs, { aktuell: b.dataset.szene }));
|
||||
});
|
||||
});
|
||||
$('clip').addEventListener('click', function () { obsRuf($('clip'), { was: 'clip' }); });
|
||||
$('ton').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.ton-zeile');
|
||||
if (!b) return;
|
||||
obsRuf(b, { was: 'stumm', quelle: b.dataset.quelle, stumm: b.dataset.stumm === '1' ? '0' : '1' });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Liegestuetze
|
||||
|
||||
function zeigeLz(st) {
|
||||
if (!st) return;
|
||||
$('lz-karte').hidden = false;
|
||||
if (typeof st.offen === 'number') $('lz-offen').textContent = st.offen.toLocaleString('de-DE');
|
||||
if (typeof st.gemacht === 'number') $('lz-gemacht').textContent = st.gemacht.toLocaleString('de-DE');
|
||||
}
|
||||
document.querySelector('.lz-knoepfe').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-um]');
|
||||
if (!b) return;
|
||||
tippen();
|
||||
mitWarten(b, rufen('/liegestuetze', { was: 'aendern', wert: b.dataset.um })).then(function (r) {
|
||||
zeigeLz(r);
|
||||
if (!r.ok) melden(r.text, 'fehler');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Vorlesen
|
||||
|
||||
function zeigeWartend(liste) {
|
||||
var box = $('wartend');
|
||||
box.textContent = '';
|
||||
$('tts-anzahl').hidden = !liste.length;
|
||||
$('tts-anzahl').textContent = String(liste.length);
|
||||
$('nav-tts').hidden = !liste.length;
|
||||
$('alle-ablehnen').hidden = liste.length < 2;
|
||||
if (!liste.length) { box.appendChild(el('div', 'leer-zeile', 'Nichts wartet auf Freigabe.')); return; }
|
||||
liste.forEach(function (w) {
|
||||
var n = el('div', 'nachricht');
|
||||
n.appendChild(el('div', 'wer', w.name));
|
||||
n.appendChild(el('div', 'was', w.text));
|
||||
var reihe = el('div', 'reihe');
|
||||
var ja = el('button', 'knopf primaer', 'Vorlesen');
|
||||
ja.type = 'button'; ja.dataset.was = 'freigeben'; ja.dataset.id = w.id;
|
||||
var nein = el('button', 'knopf', 'Ablehnen');
|
||||
nein.type = 'button'; nein.dataset.was = 'ablehnen'; nein.dataset.id = w.id;
|
||||
reihe.appendChild(ja); reihe.appendChild(nein);
|
||||
n.appendChild(reihe);
|
||||
box.appendChild(n);
|
||||
});
|
||||
}
|
||||
|
||||
function ladeWartend() {
|
||||
if (!zustand.ttsAn) return;
|
||||
holen('/tts/wartend').then(function (b) { zeigeWartend(b.wartend || []); }, function () { /* still */ });
|
||||
}
|
||||
|
||||
function ttsRuf(was, id) {
|
||||
tippen();
|
||||
return rufen('/tts', { was: was, id: id || '' }).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
if (r.wartend) zeigeWartend(r.wartend);
|
||||
});
|
||||
}
|
||||
$('wartend').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-was]');
|
||||
if (b) ttsRuf(b.dataset.was, b.dataset.id);
|
||||
});
|
||||
$('alle-ablehnen').addEventListener('click', function () { ttsRuf('alle-ablehnen'); });
|
||||
|
||||
// Der Panikschalter. Ohne Vorlesen haelt er wenigstens die Sounds an.
|
||||
$('still').addEventListener('click', function () {
|
||||
tippen();
|
||||
if (zustand.ttsAn) { ttsRuf('still'); return; }
|
||||
rufen('/soundboard/stopp').then(function (r) { melden(r.ok ? 'Alles still.' : r.text, r.ok ? 'ok' : 'fehler'); });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Test
|
||||
|
||||
function baueTest(knoepfe, demoLaeuft) {
|
||||
var gruppen = $('test-gruppen');
|
||||
gruppen.textContent = '';
|
||||
var namen = [];
|
||||
knoepfe.forEach(function (k) { if (namen.indexOf(k.gruppe) === -1) namen.push(k.gruppe); });
|
||||
namen.forEach(function (g) {
|
||||
var box = el('div', 'gruppe');
|
||||
box.appendChild(el('h3', 'abschnitt', g));
|
||||
var reihe = el('div', 'knoepfe');
|
||||
knoepfe.filter(function (k) { return k.gruppe === g; }).forEach(function (k) {
|
||||
var b = el('button', 'knopf', k.label);
|
||||
b.type = 'button';
|
||||
b.dataset.art = k.art;
|
||||
if (k.nurOffline) b.dataset.offline = '1';
|
||||
reihe.appendChild(b);
|
||||
});
|
||||
box.appendChild(reihe);
|
||||
gruppen.appendChild(box);
|
||||
});
|
||||
setzeDemo(demoLaeuft);
|
||||
}
|
||||
|
||||
function setzeDemo(laeuft) {
|
||||
var d = $('demo');
|
||||
d.dataset.art = laeuft ? 'demo-aus' : 'demo-an';
|
||||
d.textContent = laeuft ? 'Vorführung stoppen' : 'Vorführung starten';
|
||||
d.disabled = !laeuft && zustand.live;
|
||||
}
|
||||
|
||||
function testRuf(knopf) {
|
||||
tippen();
|
||||
mitWarten(knopf, rufen('/test', { art: knopf.dataset.art })).then(function (r) {
|
||||
melden(r.text, r.ok ? 'ok' : 'fehler');
|
||||
if (typeof r.demo === 'boolean') setzeDemo(r.demo);
|
||||
});
|
||||
}
|
||||
$('test-gruppen').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-art]');
|
||||
if (b && !b.disabled) testRuf(b);
|
||||
});
|
||||
$('demo').addEventListener('click', function () { testRuf($('demo')); });
|
||||
|
||||
// ------------------------------------------------------------ Strom
|
||||
|
||||
var quelle = null;
|
||||
var zuletzt = Date.now();
|
||||
|
||||
function verbinde() {
|
||||
if (quelle) { try { quelle.close(); } catch (e) { /* zu */ } }
|
||||
quelle = new EventSource('/events?quelle=pult');
|
||||
zuletzt = Date.now();
|
||||
quelle.onopen = function () { setzeNetz(true); };
|
||||
quelle.onerror = function () {
|
||||
setzeNetz(false);
|
||||
// EventSource sagt nicht, ob es an der Anmeldung liegt. Eine kurze
|
||||
// Frage klaert das, sonst stuende "keine Verbindung" fuer immer da.
|
||||
holen('/soundboard/liste').then(function () { /* nur Netz */ }, function () { /* abgemeldet() hat schon */ });
|
||||
};
|
||||
quelle.onmessage = function (e) {
|
||||
zuletzt = Date.now();
|
||||
setzeNetz(true);
|
||||
var ev;
|
||||
try { ev = JSON.parse(e.data); } catch (err) { return; }
|
||||
switch (ev.kind) {
|
||||
case 'state':
|
||||
zeigeStream(ev.stream);
|
||||
if (ev.obs) zeigeObs(ev.obs);
|
||||
if (ev.liegestuetze && !$('lz-karte').hidden) zeigeLz(ev.liegestuetze);
|
||||
break;
|
||||
case 'stream': zeigeStream(ev); break;
|
||||
case 'obs': zeigeObs(ev); break;
|
||||
case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break;
|
||||
case 'sound': knall(ev.id); break;
|
||||
case 'sounds-geaendert': ladeSounds(); break;
|
||||
case 'tts': ladeWartend(); break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setzeNetz(an) {
|
||||
$('netz').classList.toggle('an', an);
|
||||
var banner = $('banner');
|
||||
banner.hidden = an;
|
||||
if (!an) banner.textContent = 'Keine Verbindung zum Hub – verbinde neu …';
|
||||
}
|
||||
|
||||
// Wachhund wie in client.js: der Hub schickt alle 15 s einen Herzschlag.
|
||||
// Am Handy reisst die Leitung beim Wegwischen ab, ohne dass es jemand merkt.
|
||||
setInterval(function () {
|
||||
if (Date.now() - zuletzt > 45000) verbinde();
|
||||
}, 10000);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible' && Date.now() - zuletzt > 20000) verbinde();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Start
|
||||
|
||||
holen('/pult/knoepfe').then(function (b) {
|
||||
baueTest(b.knoepfe || [], !!b.demo);
|
||||
zustand.ttsAn = !!b.tts;
|
||||
$('tts-karte').hidden = !b.tts;
|
||||
if (b.liegestuetze) zeigeLz(b.liegestuetze);
|
||||
ladeWartend();
|
||||
}, function () { /* abgemeldet oder weg - zeigt sich oben */ });
|
||||
ladeSounds();
|
||||
verbinde();
|
||||
wachHolen();
|
||||
|
||||
// Die Liste der Wartenden kommt nicht ueber den Strom (dort steht nur, was
|
||||
// vorgelesen wird). Also nachsehen, solange das Pult offen ist.
|
||||
setInterval(function () {
|
||||
if (document.visibilityState === 'visible') ladeWartend();
|
||||
}, 4000);
|
||||
// Ob die Sound-Quelle in OBS offen ist, aendert sich ohne Ereignis.
|
||||
setInterval(function () {
|
||||
if (document.visibilityState === 'visible') ladeSounds();
|
||||
}, 20000);
|
||||
|
||||
// Ein Reiter in der Adresse (Verknuepfung "Szenen" am App-Symbol) geht vor
|
||||
// dem zuletzt benutzten.
|
||||
var gemerkt = location.hash.slice(1) || null;
|
||||
if (!gemerkt) { try { gemerkt = localStorage.getItem('pult-reiter'); } catch (e) { /* privat */ } }
|
||||
if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function (e) {
|
||||
console.warn('[pult] Service Worker nicht registriert', e);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,109 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Soundboard</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Eine reine Tonquelle. Im Stream ist nichts zu sehen - ein Sound, der
|
||||
* zusaetzlich seinen Namen ins Bild schreibt, nimmt ihm die Pointe.
|
||||
*
|
||||
* Mit ?probe=1 steht unten, was gerade klingt. Damit laesst sich im Browser
|
||||
* pruefen, ob ein Druck am Handy hier ankommt, auch ohne Lautsprecher. */
|
||||
#liste {
|
||||
position: fixed;
|
||||
left: 12px; right: 12px; bottom: 12px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font: 600 16px/1.2 var(--font-zahl);
|
||||
}
|
||||
body.probe #liste { display: flex; }
|
||||
.laeuft {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--tinte-klar);
|
||||
border-left: 4px solid var(--lila);
|
||||
}
|
||||
.laeuft.fehler { border-left-color: var(--rot); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="liste"></div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var liste = document.getElementById('liste');
|
||||
var schluessel = Overlay.param('key', '');
|
||||
/* Lautstaerke der ganzen Quelle. Besser im OBS-Mixer regeln; das hier ist
|
||||
* fuer den Fall, dass alle Sounds gemeinsam zu laut aufgenommen sind. */
|
||||
var gesamt = Math.max(0, Math.min(1, Overlay.param('laut', 1)));
|
||||
/* Mehr gleichzeitig klingt nicht mehr nach Soundboard, sondern nach Unfall.
|
||||
* Der aelteste Sound macht dem neuen Platz. */
|
||||
var maxGleichzeitig = Math.max(1, Overlay.param('max', 4));
|
||||
var laufend = [];
|
||||
|
||||
function zeile(name, fehler) {
|
||||
var d = document.createElement('div');
|
||||
d.className = 'laeuft' + (fehler ? ' fehler' : '');
|
||||
d.textContent = name;
|
||||
liste.appendChild(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
function aufhoeren(eintrag) {
|
||||
var i = laufend.indexOf(eintrag);
|
||||
if (i !== -1) laufend.splice(i, 1);
|
||||
try { eintrag.ton.pause(); } catch (e) { /* egal */ }
|
||||
eintrag.ton.removeAttribute('src');
|
||||
if (eintrag.zeile.parentNode) eintrag.zeile.parentNode.removeChild(eintrag.zeile);
|
||||
}
|
||||
|
||||
function alleStopp() {
|
||||
laufend.slice().forEach(aufhoeren);
|
||||
}
|
||||
|
||||
Overlay.on('sound', function (ev) {
|
||||
while (laufend.length >= maxGleichzeitig) aufhoeren(laufend[0]);
|
||||
|
||||
var ton = new Audio('/soundboard/datei/' + encodeURIComponent(ev.id) +
|
||||
(schluessel ? '?key=' + encodeURIComponent(schluessel) : ''));
|
||||
var laut = typeof ev.laut === 'number' ? ev.laut : 0.8;
|
||||
ton.volume = Math.max(0, Math.min(1, laut * gesamt));
|
||||
var eintrag = { ton: ton, zeile: zeile(ev.name || 'Sound') };
|
||||
laufend.push(eintrag);
|
||||
|
||||
ton.onended = function () { aufhoeren(eintrag); };
|
||||
ton.onerror = function () {
|
||||
console.error('[sounds] nicht abspielbar', ev.name, ton.error && ton.error.code);
|
||||
eintrag.zeile.classList.add('fehler');
|
||||
setTimeout(function () { aufhoeren(eintrag); }, 3000);
|
||||
};
|
||||
// Notbremse: ein Sound, der nie "zu Ende" meldet, belegte sonst fuer
|
||||
// immer einen Platz.
|
||||
setTimeout(function () { aufhoeren(eintrag); }, 5 * 60 * 1000);
|
||||
|
||||
var p = ton.play();
|
||||
// Ein normaler Browser spielt erst nach einem Klick in die Seite. OBS
|
||||
// nicht - wer hier im Browser testet, sieht es an der roten Zeile.
|
||||
if (p && p.catch) p.catch(function (e) {
|
||||
console.error('[sounds] Wiedergabe verweigert', e && e.name);
|
||||
eintrag.zeile.textContent = (ev.name || 'Sound') + ' (erst in die Seite klicken)';
|
||||
eintrag.zeile.classList.add('fehler');
|
||||
setTimeout(function () { aufhoeren(eintrag); }, 3000);
|
||||
});
|
||||
});
|
||||
|
||||
Overlay.on('sound-stopp', alleStopp);
|
||||
// Der Panikschalter gilt auch hier.
|
||||
Overlay.on('still', alleStopp);
|
||||
// Nach dem Neuverbinden nichts nachholen: ein Sound zur falschen Zeit ist
|
||||
// schlimmer als keiner.
|
||||
Overlay.on('state', function () { });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Service Worker fuer das Pult.
|
||||
*
|
||||
* Er macht genau zwei Dinge: er ist da, damit sich das Pult als App
|
||||
* installieren laesst, und er haelt die Huelle des Pults vor, damit es ohne
|
||||
* Netz wenigstens aufgeht und "keine Verbindung" sagen kann statt einer
|
||||
* leeren Fehlerseite des Browsers.
|
||||
*
|
||||
* Immer zuerst das Netz: nach dem Ausrollen soll sofort die neue Fassung
|
||||
* laufen, nicht die aus dem Speicher. Alles, was nicht auf der Liste steht -
|
||||
* Ereignisstrom, Knoepfe, Sounds, die Browser-Sources fuer OBS -, fasst er
|
||||
* nicht an. Ein Pult, das einen Szenenwechsel aus dem Speicher beantwortet,
|
||||
* waere schlimmer als gar keins. */
|
||||
|
||||
var SPEICHER = 'pult-v1';
|
||||
var HUELLE = ['/pult', '/pult.css', '/pult.js', '/manifest.webmanifest',
|
||||
'/icons/pult-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
self.addEventListener('install', function () { self.skipWaiting(); });
|
||||
|
||||
self.addEventListener('activate', function (e) {
|
||||
e.waitUntil(caches.keys().then(function (namen) {
|
||||
return Promise.all(namen.filter(function (n) { return n !== SPEICHER; })
|
||||
.map(function (n) { return caches.delete(n); }));
|
||||
}).then(function () { return self.clients.claim(); }));
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (e) {
|
||||
var url = new URL(e.request.url);
|
||||
if (e.request.method !== 'GET' || url.origin !== self.location.origin) return;
|
||||
if (HUELLE.indexOf(url.pathname) === -1) return;
|
||||
|
||||
e.respondWith(fetch(e.request).then(function (res) {
|
||||
// Nur Gelungenes merken. Die Anmeldeseite (401) gehoert nicht in den
|
||||
// Speicher, sonst steht sie beim naechsten Funkloch statt des Pults da.
|
||||
if (res.ok) {
|
||||
var kopie = res.clone();
|
||||
caches.open(SPEICHER).then(function (c) { c.put(url.pathname, kopie); });
|
||||
}
|
||||
return res;
|
||||
}, function () {
|
||||
return caches.match(url.pathname).then(function (r) {
|
||||
return r || new Response('Keine Verbindung zum Hub.', {
|
||||
status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
Reference in new issue
Block a user