Keine Emojis als Symbole — und der Rückfall tat das Gegenteil

Zwei Dinge.

Erstens: Symbole sind jetzt Vektoren, keine Schriftzeichen. Ein Lautsprecher
als Emoji wird auf jeder Plattform anders gezeichnet, trägt fremde Farben,
sitzt schief auf der Grundlinie und lässt sich weder in Strichstärke noch
Farbe anpassen — es sieht nach Platzhalter aus, weil es einer ist. Elf
Stellen umgestellt, die Pfade stehen an einer Stelle beisammen.

Zweitens, und das ist der Grund, warum nichts lief: Nennt der Server keine
eigene Adresse, fiel der Code auf "static=true" zurück — also auf die Datei,
wie sie auf der Platte liegt. Bei einem MKV mit DTS ist das für einen Browser
unlesbar, und er meldet bloß "Format nicht unterstützt". Der Rückfall tat
genau das Gegenteil dessen, was er sollte. Jetzt verlangt er master.m3u8,
also ausdrücklich einen HLS-Strom.

Damit so etwas nicht wieder im Dunkeln liegt, nennt die Fehleranzeige nun
auch, was der Server geantwortet hat und welche Adresse verwendet wurde —
der Zugangsschlüssel darin gekürzt.

Dazu: während des Ladens treten die Transportknöpfe zurück. Der Kreisel lag
vorher genau hinter dem größten von ihnen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 17:43:03 +02:00
co-authored by Claude Opus 5
parent 1db70379d7
commit 66b963f2c5
+102 -20
View File
@@ -278,6 +278,13 @@ img { display: block; }
transition: background .15s ease;
}
.pl-icon:hover { background: rgba(40,40,46,.75); }
/* Eine Vorgabegröße für alle Symbole, damit sie zueinander passen; einzelne
überschreiben sie über `icon(name, size)`. */
.pl-icon svg, .pl-round svg, .tiny svg, .hero-nav svg { width: 18px; height: 18px; }
.pl-round svg { width: 22px; height: 22px; }
.btn svg { width: 15px; height: 15px; vertical-align: -2px; margin-right: 2px; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.tiny, .pl-icon, .pl-round, .hero-nav { display: inline-flex; align-items: center; justify-content: center; }
.pl-center { display: flex; align-items: center; justify-content: center; gap: var(--s-section); }
/* Dunkel statt hell durchscheinend: über einer sonnigen Szene verschwanden
@@ -331,6 +338,11 @@ img { display: block; }
.pl-rate { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: var(--s-snug); }
.pl-rate .pick { width: auto; padding: 6px var(--s-regular); font-size: 13px; }
/* Während geladen wird, treten die Transportknöpfe zurück: sie tun ohnehin
nichts, und der Kreisel lag vorher genau hinter dem größten von ihnen. */
#player.loading .pl-center { opacity: 0; pointer-events: none; }
.pl-center { transition: opacity .2s ease; }
.pl-busy {
position: absolute; display: none; pointer-events: none;
}
@@ -350,6 +362,11 @@ img { display: block; }
.pl-error strong { color: #F08A8D; font-size: 16px; font-weight: 600; }
.pl-error span { color: var(--text-2); font-size: 14px; }
.pl-error .btn { align-self: flex-start; }
.pl-error code {
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px;
color: var(--text-3); background: rgba(0,0,0,.4); padding: var(--s-snug);
border-radius: 6px; word-break: break-all; line-height: 1.5;
}
@media (max-width: 600px) {
.pl-top, .pl-bottom { padding-left: var(--s-loose); padding-right: var(--s-loose); }
@@ -1281,7 +1298,7 @@ function buildHero(items) {
<div class="meta">${bits.join(' · ')}</div>
<div class="summary">${esc(it.Overview || '')}</div>
<div class="actions">
<button class="btn btn-primary">▶ Abspielen</button>
<button class="btn btn-primary">${icon('play', 15)} Abspielen</button>
<button class="btn">Details</button>
</div>`;
body.querySelector('.btn-primary').onclick = () => openSheet(it, true);
@@ -1293,12 +1310,12 @@ function buildHero(items) {
if (items.length > 1) {
const prev = document.createElement('button');
prev.className = 'hero-nav prev'; prev.innerHTML = '‹';
prev.className = 'hero-nav prev'; prev.innerHTML = icon('left', 22);
prev.setAttribute('aria-label', 'Vorheriger Titel');
prev.onclick = () => step(-1);
const next = document.createElement('button');
next.className = 'hero-nav next'; next.innerHTML = '›';
next.className = 'hero-nav next'; next.innerHTML = icon('right', 22);
next.setAttribute('aria-label', 'Nächster Titel');
next.onclick = () => step(1);
@@ -1397,8 +1414,8 @@ function renderRowArrangement() {
<input type="checkbox" ${hidden ? '' : 'checked'}>
<span>${esc(row.title)}</span>
</label>
<button class="tiny" aria-label="Nach oben" ${index === 0 ? 'disabled' : ''}>↑</button>
<button class="tiny" aria-label="Nach unten" ${index === ordered.length - 1 ? 'disabled' : ''}>↓</button>`;
<button class="tiny" aria-label="Nach oben" ${index === 0 ? 'disabled' : ''}>${icon('up', 16)}</button>
<button class="tiny" aria-label="Nach unten" ${index === ordered.length - 1 ? 'disabled' : ''}>${icon('down', 16)}</button>`;
line.querySelector('input').onchange = (e) => {
const next = e.target.checked
@@ -1460,7 +1477,7 @@ function openSheet(item, playNow) {
<div class="meta">${bits.join(' · ')}</div>
<p>${esc(item.Overview || '')}</p>
<div class="btn-row">
${playable ? `<button class="btn btn-primary" id="sheet-play">▶ ${isFolder ? 'Weiterschauen' : 'Abspielen'}</button>` : ''}
${playable ? `<button class="btn btn-primary" id="sheet-play">${icon('play', 15)} ${isFolder ? 'Weiterschauen' : 'Abspielen'}</button>` : ''}
<button class="btn" id="sheet-close">Schließen</button>
</div>
</div>`;
@@ -1471,7 +1488,7 @@ function openSheet(item, playNow) {
const start = async (button) => {
if (button) { button.disabled = true; button.textContent = 'Suche Folge …'; }
const target = isFolder ? await nextEpisode(item) : item;
if (button) { button.disabled = false; button.textContent = isFolder ? '▶ Weiterschauen' : '▶ Abspielen'; }
if (button) { button.disabled = false; button.innerHTML = icon('play', 15) + (isFolder ? ' Weiterschauen' : ' Abspielen'); }
if (!target) {
back.querySelector('.btn-row').insertAdjacentHTML('afterend',
'<p class="hint">Diese Serie hat keine Folgen, die sich abspielen ließen.</p>');
@@ -1535,7 +1552,7 @@ function openPlayer(item) {
<video playsinline></video>
<div class="pl-chrome">
<div class="pl-top">
<button class="pl-icon" id="pl-close" aria-label="Schließen">✕</button>
<button class="pl-icon" id="pl-close" aria-label="Schließen">${icon('close')}</button>
<div class="pl-title">
<strong>${esc(item.SeriesName || item.Name)}</strong>
${item.SeriesName ? `<span>${esc(episodeLabel(item))}</span>` : ''}
@@ -1544,7 +1561,7 @@ function openPlayer(item) {
<div class="pl-center">
<button class="pl-round" id="pl-back" aria-label="Zurückspulen">−${P.skipBackward}</button>
<button class="pl-round pl-play" id="pl-toggle" aria-label="Abspielen">▶</button>
<button class="pl-round pl-play" id="pl-toggle" aria-label="Abspielen">${icon('play', 30)}</button>
<button class="pl-round" id="pl-fwd" aria-label="Vorspulen">+${P.skipForward}</button>
</div>
@@ -1555,11 +1572,11 @@ function openPlayer(item) {
<span id="pl-left">–:--</span>
</div>
<div class="pl-tools">
<button class="pl-icon" id="pl-mute" aria-label="Ton aus">🔊</button>
<button class="pl-icon" id="pl-mute" aria-label="Ton aus">${icon('sound')}</button>
<input type="range" id="pl-vol" min="0" max="1" step="0.05" value="1" aria-label="Lautstärke">
<span class="pl-spacer"></span>
<label class="pl-rate">Tempo <select class="pick" id="pl-rate"></select></label>
<button class="pl-icon" id="pl-full" aria-label="Vollbild">⛶</button>
<button class="pl-icon" id="pl-full" aria-label="Vollbild">${icon('expand')}</button>
</div>
</div>
</div>
@@ -1571,12 +1588,14 @@ function openPlayer(item) {
const chrome = layer.querySelector('.pl-chrome');
const el2 = (id) => layer.querySelector('#' + id);
const fail = (satz, hinweis) => {
const fail = (satz, hinweis, adresse) => {
layer.classList.remove('loading');
el2('pl-busy').classList.remove('on');
layer.querySelector('.pl-error')?.remove();
const box = document.createElement('div');
box.className = 'pl-error';
box.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}
${adresse ? `<code>${esc(adresse.replace(/api_key=[^&]*/, 'api_key=…'))}</code>` : ''}
<button class="btn">Schließen</button>`;
box.querySelector('button').onclick = close;
layer.appendChild(box);
@@ -1602,14 +1621,17 @@ function openPlayer(item) {
v.addEventListener('click', toggle);
const paint = () => {
el2('pl-toggle').textContent = v.paused ? '▶' : '❚❚';
el2('pl-toggle').innerHTML = icon(v.paused ? 'play' : 'pause', 30);
el2('pl-toggle').setAttribute('aria-label', v.paused ? 'Abspielen' : 'Anhalten');
show();
};
v.addEventListener('play', paint);
v.addEventListener('pause', paint);
v.addEventListener('waiting', () => el2('pl-busy').classList.add('on'));
v.addEventListener('playing', () => el2('pl-busy').classList.remove('on'));
v.addEventListener('playing', () => {
el2('pl-busy').classList.remove('on');
layer.classList.remove('loading');
});
// --- Fortschrittsleiste ---
const track = el2('pl-track');
@@ -1653,7 +1675,7 @@ function openPlayer(item) {
vol.oninput = () => { v.volume = +vol.value; v.muted = +vol.value === 0; };
el2('pl-mute').onclick = () => {
v.muted = !v.muted;
el2('pl-mute').textContent = v.muted ? '🔇' : '🔊';
el2('pl-mute').innerHTML = icon(v.muted ? 'muted' : 'sound');
};
const rate = el2('pl-rate');
@@ -1698,6 +1720,7 @@ function openPlayer(item) {
el2('pl-close').onclick = close;
show();
layer.classList.add('loading');
el2('pl-busy').classList.add('on');
startStream(item, v, fail);
}
@@ -1716,11 +1739,14 @@ function episodeLabel(item) {
mitführen. Die Adresse selbst zusammenzubauen wäre eine Vermutung darüber,
was der Server kopieren kann und was er rechnen muss. */
async function startStream(item, v, fail) {
// Erst nach dem Aushandeln bekannt — deshalb hier eine Schachtel, die
// später gefüllt wird.
const diagnose = { text: '', url: '' };
v.addEventListener('error', () => {
const c = v.error?.code;
fail('Der Browser kann diesen Strom nicht abspielen.',
c === 4 ? 'Das Format wird nicht unterstützt — der Server sollte umrechnen, tat es aber nicht.'
: `Fehlercode ${c ?? '?'}. Im Netzwerk-Tab steht, was der Server geantwortet hat.`);
(c === 4 ? 'Das Format wird nicht unterstützt. ' : `Fehlercode ${c ?? '?'}. `)
+ diagnose.text, diagnose.url);
});
let info;
@@ -1733,13 +1759,40 @@ async function startStream(item, v, fail) {
const source = info.MediaSources?.[0];
if (!source) return fail('Der Server nennt keine abspielbare Quelle.');
// **Der Rückfall muss Umrechnung erzwingen, nicht sie umgehen.**
//
// Hier stand `static=true` — das schickt die Datei so, wie sie auf der
// Platte liegt. Bei einem MKV mit DTS ist das für einen Browser unlesbar,
// und er meldet bloß „Format nicht unterstützt". Genau dieser Rückfall war
// der Grund, warum nichts lief: er tat das Gegenteil dessen, was er sollte.
//
// `master.m3u8` verlangt dagegen ausdrücklich einen HLS-Strom. Nennt der
// Server keine eigene Adresse, ist das die richtige Bitte — nicht das
// Original.
const url = source.TranscodingUrl
? new URL(source.TranscodingUrl, S.base).href
: streamURL(`/Videos/${item.Id}/stream`, {
static: 'true', mediaSourceId: source.Id || item.Id,
api_key: S.token, PlaySessionId: info.PlaySessionId || '',
: streamURL(`/Videos/${item.Id}/master.m3u8`, {
mediaSourceId: source.Id || item.Id,
api_key: S.token,
PlaySessionId: info.PlaySessionId || '',
DeviceId: S.deviceId,
VideoCodec: 'h264', AudioCodec: 'aac,mp3',
TranscodingMaxAudioChannels: '2', SegmentContainer: 'ts',
MinSegments: '1', BreakOnNonKeyFrames: 'true',
TranscodingProtocol: 'hls',
});
// Was der Server geantwortet hat — damit im Fehlerfall nicht geraten werden
// muss, sondern dasteht, woran es lag.
const auskunft = [
source.TranscodingUrl ? 'Server nannte eine Adresse' : 'Server nannte keine Adresse (Rückfall auf HLS)',
source.Container ? `Container ${source.Container}` : null,
source.SupportsTranscoding === false ? 'Server sagt: kann nicht umrechnen' : null,
].filter(Boolean).join(' · ');
diagnose.text = auskunft;
diagnose.url = url;
v.addEventListener('loadedmetadata', () => { v.playbackRate = P.rate; v.play(); }, { once: true });
wireProgress(v, { id: info.PlaySessionId, sourceID: source.Id || item.Id, itemID: item.Id });
@@ -1838,6 +1891,35 @@ function streamURL(path, query) {
return u.href;
}
/* Die Symbole der Oberfläche.
----------------------------------------------------------------------------
Als Vektor, nicht als Schriftzeichen. Ein ▶ oder ein 🔊 wird auf jeder
Plattform anders gezeichnet, trägt fremde Farben, sitzt schief auf der
Grundlinie und lässt sich weder in der Strichstärke noch in der Farbe
anpassen — es sieht nach Platzhalter aus, weil es einer ist.
`currentColor` erbt Farbe und Zustand vom Knopf darum; `stroke-width` bleibt
überall gleich, damit die Symbole zueinander passen. */
const ICONS = {
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
pause: '<path d="M9 5.5v13M15 5.5v13"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
sound: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9a4 4 0 010 6"/><path d="M18.5 6.5a7.5 7.5 0 010 11"/>',
muted: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9.5l5 5M21 9.5l-5 5"/>',
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
left: '<path d="M14.5 5.5L8 12l6.5 6.5"/>',
right: '<path d="M9.5 5.5L16 12l-6.5 6.5"/>',
up: '<path d="M12 18.5v-13M6.5 11L12 5.5 17.5 11"/>',
down: '<path d="M12 5.5v13M6.5 13L12 18.5 17.5 13"/>',
};
/// Ein Symbol als Vektor. `size` nur, wo es von der Vorgabe abweicht.
function icon(name, size) {
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
${size ? `style="width:${size}px;height:${size}px"` : ''}>${ICONS[name]}</svg>`;
}
/* ---------- Kleinkram ---------- */
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>