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:
co-authored by
Claude Opus 5
parent
acba683c94
commit
8cf2118723
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user