Ein Knopf, von dem niemand weiss, dass es ihn gibt, ist keiner

„Intro überspringen" lag im Web in der Bedienleiste und verschwand mit
ihr. Am Fernseher sieht man die Leiste gar nicht — der Knopf war also
da und trotzdem unsichtbar. Jetzt liegt er als eigene Schicht über dem
Bild und bleibt stehen, solange die Marke läuft.

**Im Abspann ist „überspringen" ohnehin die falsche Frage.** Was man
dort will, ist die nächste Folge; ans Ende dieser zu springen bringt
niemanden weiter. Gibt es eine, heisst der Knopf jetzt so — im Web wie
in der App, und in der App nicht mehr erst, wenn alles durchgelaufen
ist. Bei einem Film gibt es nichts danach: dort heisst „Abspann
überspringen" jetzt wirklich Schluss, statt auf ein stehendes Bild zu
spulen und den Spieler offen zu lassen.

Dazu der Titel über dem Spieler. Dort stand bei einer Folge „S1 E6" und
bei einer aus der Detailseite gestarteten sogar nur „Wiedergabe". Auf
einer Kachel ist die Kurzform richtig, dort zählt jeder Buchstabe; über
dem Spieler steht eine Zeile in einer leeren Fläche. Jetzt steht da
„Küstenwache · Staffel 1 · Folge 6 · Der Nebel kommt". In der App hiess
es „Episode" statt „Folge" — das eine Wort, das sonst nirgends steht.

Und drei Prüfungen für die Marken auf dem Weg in die App: aus einer
Serverantwort mit Vorspann und Abspann werden zwei Marken an der
richtigen Stelle. Die Übersetzung war nicht der Grund, warum der Knopf
dort fehlte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-13 15:35:59 +02:00
co-authored by Claude Opus 5
parent 7673c73757
commit afa78f9ee0
8 changed files with 192 additions and 21 deletions
+1 -2
View File
@@ -1738,7 +1738,7 @@
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Staffel %lld · Episode %lld" "value" : "Staffel %lld · Folge %lld"
} }
}, },
"en" : { "en" : {
@@ -4101,7 +4101,6 @@
} }
}, },
"player.nextEpisode" : { "player.nextEpisode" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
+17 -1
View File
@@ -731,12 +731,28 @@ struct PlayerView: View {
/// Überspringen-Knopf und „Nächste Folge". /// Überspringen-Knopf und „Nächste Folge".
@ViewBuilder @ViewBuilder
/// Ob jetzt „Nächste Folge" dransteht.
///
/// Am Ende immer, im Abspann auch — dort ist es die Antwort auf die
/// Frage, die der Abspann stellt. Gibt es keine nächste Folge, bleibt
/// es beim Überspringen.
private func naechsteAnbieten(_ model: PlayerModel) -> Bool {
guard model.nextEpisodeRequest() != nil else { return false }
return model.hasReachedEnd || model.activeMarker?.isCredits == true
}
private func overlayActions(for model: PlayerModel) -> some View { private func overlayActions(for model: PlayerModel) -> some View {
VStack { VStack {
Spacer() Spacer()
HStack { HStack {
Spacer() Spacer()
if model.hasReachedEnd, model.nextEpisodeRequest() != nil { // **Im Abspann ist „überspringen" die falsche Frage.**
//
// Was man dort will, ist die nächste Folge — ans Ende
// dieser zu springen bringt niemanden weiter. Deshalb steht
// derselbe Knopf schon im Abspann, nicht erst, wenn alles
// durchgelaufen ist.
if naechsteAnbieten(model) {
Button { Button {
if let next = model.nextEpisodeRequest() { if let next = model.nextEpisodeRequest() {
playback.play(next) playback.play(next)
@@ -0,0 +1,73 @@
import Foundation
import Testing
import MediaKit
@testable import FlaroServerKit
/// Vorspann und Abspann auf dem Weg vom Server in den Spieler.
///
/// **Der Anlass:** im Web erschien der Überspringen-Knopf, in der App nie.
/// Dazwischen liegt genau diese Übersetzung — und ohne Prüfung liesse sich
/// nicht sagen, ob sie der Grund ist oder etwas dahinter.
@Suite("Flaro: Marken")
struct MarkenTests {
private static let folge = """
{
"id": "folge-1", "season": 1, "episode": 6,
"title": "Bestimmung", "overview": null, "stillImage": null,
"files": [{
"id": "datei-1", "fileName": "F.mkv", "sizeBytes": 1,
"container": "mkv", "durationSeconds": 2700, "streams": []
}],
"state": null, "publishAt": null, "seriesId": "serie-1",
"markers": [
{ "kind": "intro", "startSeconds": 30.5, "endSeconds": 65 },
{ "kind": "credits", "startSeconds": 2500, "endSeconds": 2700 }
]
}
"""
@Test("Eine Folge bringt Vorspann und Abspann mit")
func folgeMitMarken() throws {
let roh = try #require(
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(Self.folge.utf8)))
let titel = roh.neutral()
#expect(titel.markers.count == 2)
let intro = try #require(titel.markers.first { $0.kind == .intro })
#expect(intro.startMilliseconds == 30_500)
#expect(intro.endMilliseconds == 65_000)
#expect(intro.contains(45_000))
let abspann = try #require(titel.markers.first { $0.kind == .credits })
#expect(abspann.startMilliseconds == 2_500_000)
}
/// Ein älterer Server schickt das Feld gar nicht — das ist kein Fehler.
@Test("Ohne Marken bleibt die Liste leer")
func ohneMarken() throws {
let ohne = """
{
"id": "folge-1", "season": 1, "episode": 6,
"title": "Bestimmung", "overview": null, "stillImage": null,
"files": [], "state": null, "publishAt": null, "seriesId": "serie-1"
}
"""
let roh = try #require(
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(ohne.utf8)))
#expect(roh.neutral().markers.isEmpty)
}
/// Ein unbekanntes Wort wirft die Marke nicht weg — sie zählt nur nicht.
@Test("Eine unbekannte Art wird nicht zur falschen")
func unbekannteArt() throws {
let fremd = Self.folge.replacingOccurrences(of: "\"intro\"", with: "\"werbung\"")
let roh = try #require(
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(fremd.utf8)))
let titel = roh.neutral()
#expect(titel.markers.count == 2)
#expect(titel.markers.first?.kind == .unknown)
}
}
+55 -13
View File
@@ -3,7 +3,7 @@ import {
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, markenHolen, stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, markenHolen,
type Stromauskunft, type Spurauskunft, type Folge, type Marke, type Stromauskunft, type Spurauskunft, type Folge, type Marke,
} from './api'; } from './api';
import { dauer, knopf, meldung, hinweisZeigen } from './util'; import { dauer, knopf, meldung, hinweisZeigen, folgenname } from './util';
import { serienton, serientext, spurFuer } from './sprachwahl'; import { serienton, serientext, spurFuer } from './sprachwahl';
import { import {
GROESSE_GROESST, GROESSE_KLEINST, GROESSE_SCHRITT, HINTERGRUENDE, SCHRIFTFARBEN, GROESSE_GROESST, GROESSE_KLEINST, GROESSE_SCHRITT, HINTERGRUENDE, SCHRIFTFARBEN,
@@ -364,7 +364,16 @@ export function spieler(anfrage: Spieleranfrage): void {
// Ein Klick auf die Leiste selbst holt den Film zurück — wie bei Plex. // Ein Klick auf die Leiste selbst holt den Film zurück — wie bei Plex.
kleinleiste.onclick = () => kleinSetzen(false); kleinleiste.onclick = () => kleinSetzen(false);
schicht.append(video, dreher, mitte, kopf, bedienung, meldungsfeld, kleinleiste); // **„Intro überspringen" steht für sich, nicht in der Bedienleiste.**
//
// Dort stand er, und damit verschwand er mit ihr — am Fernseher sieht man
// die Leiste gar nicht, und ein Knopf, von dem niemand weiss, dass es ihn
// gibt, ist keiner. Als eigene Schicht über dem Bild bleibt er sichtbar,
// solange die Marke läuft.
const ueberspringen = document.createElement('button');
ueberspringen.className = 'btn btn-primary pl-ueberspringen hidden';
schicht.append(video, dreher, mitte, kopf, bedienung, ueberspringen, meldungsfeld, kleinleiste);
document.body.appendChild(schicht); document.body.appendChild(schicht);
document.body.classList.add('pl-offen'); document.body.classList.add('pl-offen');
@@ -597,6 +606,13 @@ export function spieler(anfrage: Spieleranfrage): void {
// //
// Jetzt stehen sie dort, wo man hinsieht: links und rechts vom Knopf, den // Jetzt stehen sie dort, wo man hinsieht: links und rechts vom Knopf, den
// man drückt. // man drückt.
/** Wie die Serie heisst — für die Zeile über dem Spieler.
*
* Aus dem übergebenen Namen gewonnen: wer den Spieler startet, kennt sie,
* der Folgenvertrag nennt sie nicht. Steht dort nichts Brauchbares,
* bleibt die Zeile eben ohne Serie. */
const serienname = anfrage.name.includes(' · ') ? anfrage.name.split(' · ')[0] : null;
/** Die Nachbarfolgen, sobald der Server sie genannt hat. */ /** Die Nachbarfolgen, sobald der Server sie genannt hat. */
let vorige: Folge | null = null; let vorige: Folge | null = null;
let naechste: Folge | null = null; let naechste: Folge | null = null;
@@ -629,15 +645,13 @@ export function spieler(anfrage: Spieleranfrage): void {
unten.className = 'pl-unten'; unten.className = 'pl-unten';
unten.append(links, zeitmitte, rechts); unten.append(links, zeitmitte, rechts);
// **„Intro überspringen" über der Leiste, rechtsbündig.** // **„Intro überspringen" steht für sich, nicht in der Bedienleiste.**
// //
// Dort erwartet man ihn — so macht es jeder Spieler —, und dort verdeckt // Dort stand er, und damit verschwand er mit ihr — am Fernseher sieht man
// er weder das Bild noch die Zeitleiste. Er steht im selben Block wie die // die Leiste gar nicht, und ein Knopf, von dem niemand weiss, dass es ihn
// Bedienung und blendet mit ihr ein und aus. // gibt, ist keiner. Jetzt liegt er als eigene Schicht über dem Bild,
const ueberspringen = document.createElement('button'); // rechts unten, und bleibt sichtbar, solange die Marke läuft.
ueberspringen.className = 'btn pl-ueberspringen hidden'; bedienung.append(zeitleiste, unten);
bedienung.append(ueberspringen, zeitleiste, unten);
// **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal // **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu // je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
@@ -688,7 +702,7 @@ export function spieler(anfrage: Spieleranfrage): void {
spieler({ spieler({
dateiId: datei.id, dateiId: datei.id,
titelId: ziel.id, titelId: ziel.id,
name: `S${ziel.season} E${ziel.episode}${ziel.title ? ` · ${ziel.title}` : ''}`, name: folgenname(serienname, ziel.season, ziel.episode, ziel.title),
ab: stelle, ab: stelle,
laenge: datei.durationSeconds, laenge: datei.durationSeconds,
tonspur: ton, tonspur: ton,
@@ -769,9 +783,37 @@ export function spieler(anfrage: Spieleranfrage): void {
ueberspringen.classList.toggle('hidden', !drin); ueberspringen.classList.toggle('hidden', !drin);
if (!drin) return; if (!drin) return;
const text = drin.kind === 'intro' ? 'Intro überspringen' : 'Abspann überspringen'; // **Beim Abspann einer Serie ist „überspringen" die falsche Frage.**
//
// Was man dort will, ist die nächste Folge — ans Ende dieser zu
// springen bringt niemanden weiter. Bei einem Film gibt es nichts
// danach; dort heisst es, ihn zu beenden, und der Server hakt ihn
// beim Melden der Stelle ohnehin ab.
const abspann = drin.kind === 'credits';
const weiter = abspann && naechste?.files[0] != null;
const text = !abspann ? 'Intro überspringen'
: weiter ? 'Nächste Folge'
: 'Abspann überspringen';
if (ueberspringen.textContent !== text) ueberspringen.textContent = text; if (ueberspringen.textContent !== text) ueberspringen.textContent = text;
ueberspringen.onclick = e => { e.stopPropagation(); void springen(drin.endSeconds); };
ueberspringen.onclick = e => {
e.stopPropagation();
if (weiter) { folgeWechseln(naechste); return; }
if (abspann) { void beenden(); return; }
void springen(drin.endSeconds);
};
}
/** Ans Ende springen und zumachen — für den Abspann ohne Nachfolger.
*
* **Nicht bloss springen.** Auf die letzte Sekunde zu spulen hiess: ein
* stehendes Bild und ein Spieler, der offen bleibt. Gemeint ist „fertig",
* und dazu gehört, dass der Server die Stelle als Schluss verbucht — das
* tut `schliessen` beim Melden von selbst. */
async function beenden(): Promise<void> {
await springen(Math.max(0, (anfrage.laenge ?? 0) - 1));
schliessen();
} }
function umschalten(): void { function umschalten(): void {
+11 -3
View File
@@ -922,10 +922,18 @@ textarea.feld {
* *
* Als eigene Zeile im Bedienblock, nicht als Überzug über dem Bild: so * Als eigene Zeile im Bedienblock, nicht als Überzug über dem Bild: so
* blendet er mit der Bedienung ein und aus und verdeckt nie die Leiste. */ * blendet er mit der Bedienung ein und aus und verdeckt nie die Leiste. */
/* **Eigene Schicht, nicht Teil der Bedienung.**
*
* In der Leiste verschwand er mit ihr — und am Fernseher sieht man die
* Leiste gar nicht. Hier liegt er über dem Bild, rechts über der Stelle,
* an der die Leiste erscheinen würde, und bleibt stehen, solange die Marke
* läuft. */
.pl-ueberspringen { .pl-ueberspringen {
align-self: flex-end; position: absolute;
margin: 0 0 2px auto; right: var(--s-section);
display: block; bottom: calc(var(--s-section) * 3.5);
z-index: 3;
box-shadow: 0 2px 12px rgba(0, 0, 0, .6);
} }
.pl-unten { .pl-unten {
+24
View File
@@ -147,3 +147,27 @@ export function hinweisZeigen(text: string, art: 'gut' | 'fehler' = 'gut'): void
}, 200); }, 200);
}, 4000); }, 4000);
} }
/** Wie eine Folge über dem Spieler heisst.
*
* **Ausgeschrieben, nicht abgekürzt.** Auf einer Kachel ist „S1 E6" richtig:
* dort zählt jeder Buchstabe. Über dem Spieler steht eine Zeile in einer
* leeren Fläche — dort gibt es keinen Grund zu sparen, und „Staffel 1 ·
* Folge 6" liest sich, ohne dass man es entziffern muss.
*
* Fehlt die Serie, bleibt sie weg; fehlt der Folgentitel, trägt die Nummer
* die Zeile allein.
*/
export function folgenname(
serie: string | null | undefined,
staffel: number | null | undefined,
folge: number | null | undefined,
titel: string | null | undefined,
): string {
const teile: string[] = [];
if (serie) teile.push(serie);
if (staffel != null) teile.push(`Staffel ${staffel}`);
if (folge != null) teile.push(`Folge ${folge}`);
if (titel) teile.push(titel);
return teile.length > 0 ? teile.join(' · ') : 'Wiedergabe';
}
+7
View File
@@ -7,6 +7,7 @@ import { ApiFehler,
} from '../api'; } from '../api';
import { import {
esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen, esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen,
folgenname,
} from '../util'; } from '../util';
import { icons, icon } from '../icons'; import { icons, icon } from '../icons';
import { kachel, alsKachel, reihe } from '../card'; import { kachel, alsKachel, reihe } from '../card';
@@ -925,6 +926,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
async function staffeln(serie: Serie): Promise<HTMLElement> { async function staffeln(serie: Serie): Promise<HTMLElement> {
const bereich = document.createElement('section'); const bereich = document.createElement('section');
serieKennung = serie.id; serieKennung = serie.id;
serienname = serie.title;
const alle = await folgen(serie.id); const alle = await folgen(serie.id);
// **Eine Staffel zur Zeit, über ein Menü.** // **Eine Staffel zur Zeit, über ein Menü.**
@@ -1080,6 +1082,8 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
/** Zu welcher Serie die Folgen gehören — für die Sprachwahl beim Start. */ /** Zu welcher Serie die Folgen gehören — für die Sprachwahl beim Start. */
let serieKennung: string | undefined; let serieKennung: string | undefined;
/** Und wie sie heisst — für die Zeile über dem Spieler. */
let serienname: string | undefined;
function folgenblock(folge: Folge): HTMLElement { function folgenblock(folge: Folge): HTMLElement {
const block = document.createElement('div'); const block = document.createElement('div');
@@ -1137,7 +1141,10 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
block.appendChild(welche); block.appendChild(welche);
} }
block.appendChild(aktionsreihe( block.appendChild(aktionsreihe(
// **Mit Titel, und ausgeschrieben.** Ohne ihn stand über dem
// Spieler „Wiedergabe" — bei jeder Folge jeder Serie dasselbe Wort.
{ ...({} as Film), id: folge.id, files: folge.files, { ...({} as Film), id: folge.id, files: folge.files,
title: folgenname(serienname, folge.season, folge.episode, folge.title),
publishAt: folge.publishAt ?? null } as Film, publishAt: folge.publishAt ?? null } as Film,
folge.state, datei, undefined, serieKennung)); folge.state, datei, undefined, serieKennung));
}); });
+4 -2
View File
@@ -2,7 +2,7 @@ import {
inhalt, weiterschauen, naechsteFolgen, empfehlungen, saisonal, inhalt, weiterschauen, naechsteFolgen, empfehlungen, saisonal,
type Bibliothek, type Angefangen, type Bibliothek, type Angefangen,
} from '../api'; } from '../api';
import { leer, fehlerzeile, meldung } from '../util'; import { leer, fehlerzeile, meldung, folgenname } from '../util';
import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card'; import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card';
import { hero, alsHero, angefangenAlsHero, type Heroeintrag } from '../hero'; import { hero, alsHero, angefangenAlsHero, type Heroeintrag } from '../hero';
import { spieler } from '../spieler'; import { spieler } from '../spieler';
@@ -85,7 +85,9 @@ export function startseite(
spieler({ spieler({
dateiId: e.fileId, dateiId: e.fileId,
titelId: e.id, titelId: e.id,
name: e.subTitle ?? e.title, name: e.kind === 'episode'
? folgenname(e.title, e.season, e.episode, e.subTitle)
: e.title,
ab: e.state.positionSeconds, ab: e.state.positionSeconds,
laenge: e.durationSeconds ?? null, laenge: e.durationSeconds ?? null,
danach: () => void aufbauen(), danach: () => void aufbauen(),