Vorspann und Abspann — erkannt, abgelegt, überspringbar

Beides mit derselben Rechnung, nur in einem anderen Fenster: vorn die
ersten fünfzehn Minuten, hinten die letzten zehn. Was `Tonabgleich`
findet, hängt allein daran, welchen Ausschnitt man ihm gibt — eine
zweite Erkennung für den Abspann wäre dieselbe Suche ein zweites Mal.

Je Staffel verglichen, nicht je Serie: Serien wechseln ihren Vorspann
zwischen Staffeln, und über alle hinweg fände man nichts oder das
Falsche. Gefundenes über fünf Minuten wird verworfen — zwei Folgen, die
dieselbe Datei tragen, teilen vierzig, und darüber stünde sonst ein
Überspringen-Knopf.

Der Lauf geht auf Zuruf, nicht bei jedem Scan: er liest von jeder Folge
ein Stück Ton und dauert Minuten. Der Knopf dafür steht auf der
Serienseite, bei den Verwalterknöpfen.

Die Marken gehen im Folgenvertrag mit — als „intro" und „credits", die
Wörter, die die Apps von Jellyfin her ohnehin kennen. In der App zeichnet
sie der vorhandene Überspringen-Knopf, der bisher nie etwas zu tun
hatte; im Web ist er neu, rechtsbündig über der Zeitleiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-12 21:28:36 +02:00
co-authored by Claude Opus 5
parent acba683c94
commit 8cf2118723
17 changed files with 2009 additions and 10 deletions
+26
View File
@@ -113,12 +113,17 @@ export type Serie = {
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
libraryId: string | null;
};
/** Ein überspringbarer Abschnitt einer Folge. */
export type Marke = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number };
export type Folge = {
id: string; season: number; episode: number;
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
state: Stand | null;
/** Zu welcher Serie sie gehört — für die Sprachwahl der Serie. */
seriesId?: string | null;
/** Vorspann und Abspann, sofern der Server sie gefunden hat. */
markers?: Marke[] | null;
/** Wann sie freigegeben ist. Liegt das in der Zukunft, gibt es sie noch nicht. */
publishAt?: string | null;
};
@@ -632,6 +637,27 @@ export const naechsteFolgen = (take = 20) =>
* „das ist eine Folge nicht" (ein Film etwa), eine 204 „es gibt keine
* weitere", und ein Aussetzer im Netz soll keinen Fehlerkasten über einem
* laufenden Film öffnen. */
/** Eine Folge für sich — tolerant, wie die Nachbarn.
*
* Für den Spieler: er bekommt nur eine Kennung und möchte wissen, ob es
* dazu Marken gibt. Eine 404 heisst „kein Folgentitel", und mehr hängt
* nicht daran. */
/** Stösst die Suche nach Vorspann und Abspann einer Serie an.
*
* Antwortet mit 202: der Lauf dauert Minuten — er liest von jeder Folge
* einer Staffel ein Stück Ton —, und darauf wartet niemand. */
export const markenSuchen = (serieId: string, erneut = false) =>
anfrage<void>(
`/series/${serieId}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' });
export async function folgeLesen(id: string): Promise<Folge | null> {
try {
return (await anfrage<Folge | undefined>(`/episodes/${id}`)) ?? null;
} catch {
return null;
}
}
export async function nachbarfolge(
folgeId: string, richtung: 'next' | 'previous',
): Promise<Folge | null> {
+35 -4
View File
@@ -1,7 +1,7 @@
import type HlsTyp from 'hls.js';
import {
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge,
type Stromauskunft, type Spurauskunft, type Folge,
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, folgeLesen,
type Stromauskunft, type Spurauskunft, type Folge, type Marke,
} from './api';
import { dauer, knopf, meldung, hinweisZeigen } from './util';
import { serienton, serientext, spurFuer } from './sprachwahl';
@@ -600,6 +600,8 @@ export function spieler(anfrage: Spieleranfrage): void {
/** Die Nachbarfolgen, sobald der Server sie genannt hat. */
let vorige: Folge | null = null;
let naechste: Folge | null = null;
/** Vorspann und Abspann dieser Folge — leer heisst: keine erkannt. */
let marken: Marke[] = [];
// **Eine Folge weiter, eine zurück.**
//
@@ -627,16 +629,28 @@ export function spieler(anfrage: Spieleranfrage): void {
unten.className = 'pl-unten';
unten.append(links, zeitmitte, rechts);
bedienung.append(zeitleiste, unten);
// **„Intro überspringen" über der Leiste, rechtsbündig.**
//
// Dort erwartet man ihn — so macht es jeder Spieler —, und dort verdeckt
// er weder das Bild noch die Zeitleiste. Er steht im selben Block wie die
// Bedienung und blendet mit ihr ein und aus.
const ueberspringen = document.createElement('button');
ueberspringen.className = 'btn pl-ueberspringen hidden';
bedienung.append(ueberspringen, zeitleiste, unten);
// **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
// zeigen, von dem noch niemand weiss, ob er etwas tut.
void (async () => {
[vorige, naechste] = await Promise.all([
const [davor, danach, diese] = await Promise.all([
nachbarfolge(anfrage.titelId, 'previous'),
nachbarfolge(anfrage.titelId, 'next'),
folgeLesen(anfrage.titelId),
]);
vorige = davor;
naechste = danach;
marken = diese?.markers ?? [];
if (geschlossen) return;
vorigeKnopf.classList.toggle('hidden', vorige?.files[0] == null);
naechsteKnopf.classList.toggle('hidden', naechste?.files[0] == null);
@@ -741,6 +755,23 @@ export function spieler(anfrage: Spieleranfrage): void {
kleinStand.textContent = laenge > 0 ? `${dauer(jetzt)} / ${dauer(laenge)}` : dauer(jetzt);
kleinGefuellt.style.width = `${Math.min(100, anteil * 100)}%`;
markenstand(jetzt);
}
/** Zeigt den Überspringen-Knopf, solange man in einer Marke steht.
*
* **Keine eigene Uhr.** Die Zeit wird ohnehin bei jedem Fortschreiten
* nachgeführt; ein zweiter Zeitgeber dafür liefe neben dem ersten her und
* wäre eine zweite Stelle, an der etwas hängen bleibt. */
function markenstand(jetzt: number): void {
const drin = marken.find(m => jetzt >= m.startSeconds && jetzt < m.endSeconds);
ueberspringen.classList.toggle('hidden', !drin);
if (!drin) return;
const text = drin.kind === 'intro' ? 'Intro überspringen' : 'Abspann überspringen';
if (ueberspringen.textContent !== text) ueberspringen.textContent = text;
ueberspringen.onclick = e => { e.stopPropagation(); void springen(drin.endSeconds); };
}
function umschalten(): void {
+10
View File
@@ -918,6 +918,16 @@ textarea.feld {
* links und rechts übrig bleibt. Mit `space-between` wanderte der
* Abspielknopf, sobald rechts ein Menü dazukam oder links die Lautstärke
* wegfiel. `1fr auto 1fr` hält ihn fest. */
/* „Intro überspringen" — rechtsbündig über der Zeitleiste.
*
* 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. */
.pl-ueberspringen {
align-self: flex-end;
margin: 0 0 2px auto;
display: block;
}
.pl-unten {
display: grid; grid-template-columns: 1fr auto 1fr;
align-items: center; gap: var(--s-regular);
+18 -1
View File
@@ -2,7 +2,7 @@ import { ApiFehler,
film as filmLaden, serie as serieLaden, folgen,
vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, serienstimmen,
type Film, type Serie, type Folge, type Datei, type Stand,
type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand,
type Mitwirkender, type Spur, aehnliche, metadatenAuffrischen, scanstand, markenSuchen,
} from '../api';
import {
esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen,
@@ -476,6 +476,23 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
}));
}
// **Vorspann und Abspann suchen.**
//
// Nur bei einer Serie: erkannt wird ein Vorspann dadurch, dass er sich
// über die Folgen einer Staffel wiederholt. Ein Film hat nichts, womit
// er sich vergleichen liesse.
//
// Auf Zuruf und nicht von selbst: der Lauf liest von jeder Folge ein
// Stück Ton, und das dauert Minuten. Ihn bei jedem Scan mitlaufen zu
// lassen hiesse, jeden Scan um ein Vielfaches zu verlängern.
if (melden && istAdmin() && art === 'serien') {
reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann suchen',
async () => {
await markenSuchen(eintrag.id);
hinweisZeigen('Vorspann und Abspann werden gesucht. Das dauert ein paar Minuten.');
}));
}
// **Bearbeiten — und damit die Bilder — von hier aus erreichbar.**
//
// Den Dialog gab es längst, aber nur am Dreipunktmenü einer Kachel und