diff --git a/Flaro/Resources/Localizable.xcstrings b/Flaro/Resources/Localizable.xcstrings index b48e8c9..233662f 100644 --- a/Flaro/Resources/Localizable.xcstrings +++ b/Flaro/Resources/Localizable.xcstrings @@ -1738,7 +1738,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Staffel %lld · Episode %lld" + "value" : "Staffel %lld · Folge %lld" } }, "en" : { @@ -4101,7 +4101,6 @@ } }, "player.nextEpisode" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Flaro/Views/Player/PlayerView.swift b/Flaro/Views/Player/PlayerView.swift index 8992e70..71cc860 100644 --- a/Flaro/Views/Player/PlayerView.swift +++ b/Flaro/Views/Player/PlayerView.swift @@ -731,12 +731,28 @@ struct PlayerView: View { /// Überspringen-Knopf und „Nächste Folge". @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 { VStack { Spacer() HStack { 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 { if let next = model.nextEpisodeRequest() { playback.play(next) diff --git a/Packages/FlaroKit/Tests/FlaroServerKitTests/MarkenTests.swift b/Packages/FlaroKit/Tests/FlaroServerKitTests/MarkenTests.swift new file mode 100644 index 0000000..c5d2d15 --- /dev/null +++ b/Packages/FlaroKit/Tests/FlaroServerKitTests/MarkenTests.swift @@ -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) + } +} diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 30556ba..ce390f0 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -3,7 +3,7 @@ import { stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, markenHolen, type Stromauskunft, type Spurauskunft, type Folge, type Marke, } from './api'; -import { dauer, knopf, meldung, hinweisZeigen } from './util'; +import { dauer, knopf, meldung, hinweisZeigen, folgenname } from './util'; import { serienton, serientext, spurFuer } from './sprachwahl'; import { 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. 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.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 // 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. */ let vorige: Folge | null = null; let naechste: Folge | null = null; @@ -629,15 +645,13 @@ export function spieler(anfrage: Spieleranfrage): void { unten.className = 'pl-unten'; 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 - // 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); + // 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. Jetzt liegt er als eigene Schicht über dem Bild, + // rechts unten, und bleibt sichtbar, solange die Marke läuft. + bedienung.append(zeitleiste, unten); // **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal // je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu @@ -688,7 +702,7 @@ export function spieler(anfrage: Spieleranfrage): void { spieler({ dateiId: datei.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, laenge: datei.durationSeconds, tonspur: ton, @@ -769,9 +783,37 @@ export function spieler(anfrage: Spieleranfrage): void { ueberspringen.classList.toggle('hidden', !drin); 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; - 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 { + await springen(Math.max(0, (anfrage.laenge ?? 0) - 1)); + schliessen(); } function umschalten(): void { diff --git a/web/app/src/style.css b/web/app/src/style.css index 671fec7..77c1589 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -922,10 +922,18 @@ textarea.feld { * * 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. */ +/* **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 { - align-self: flex-end; - margin: 0 0 2px auto; - display: block; + position: absolute; + right: var(--s-section); + bottom: calc(var(--s-section) * 3.5); + z-index: 3; + box-shadow: 0 2px 12px rgba(0, 0, 0, .6); } .pl-unten { diff --git a/web/app/src/util.ts b/web/app/src/util.ts index 531e607..302d828 100644 --- a/web/app/src/util.ts +++ b/web/app/src/util.ts @@ -147,3 +147,27 @@ export function hinweisZeigen(text: string, art: 'gut' | 'fehler' = 'gut'): void }, 200); }, 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'; +} diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 31a9d07..8a5208c 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -7,6 +7,7 @@ import { ApiFehler, } from '../api'; import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen, + folgenname, } from '../util'; import { icons, icon } from '../icons'; 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 { const bereich = document.createElement('section'); serieKennung = serie.id; + serienname = serie.title; const alle = await folgen(serie.id); // **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. */ let serieKennung: string | undefined; + /** Und wie sie heisst — für die Zeile über dem Spieler. */ + let serienname: string | undefined; function folgenblock(folge: Folge): HTMLElement { const block = document.createElement('div'); @@ -1137,7 +1141,10 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem block.appendChild(welche); } 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, + title: folgenname(serienname, folge.season, folge.episode, folge.title), publishAt: folge.publishAt ?? null } as Film, folge.state, datei, undefined, serieKennung)); }); diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts index 827c366..94fad34 100644 --- a/web/app/src/views/home.ts +++ b/web/app/src/views/home.ts @@ -2,7 +2,7 @@ import { inhalt, weiterschauen, naechsteFolgen, empfehlungen, saisonal, type Bibliothek, type Angefangen, } from '../api'; -import { leer, fehlerzeile, meldung } from '../util'; +import { leer, fehlerzeile, meldung, folgenname } from '../util'; import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card'; import { hero, alsHero, angefangenAlsHero, type Heroeintrag } from '../hero'; import { spieler } from '../spieler'; @@ -85,7 +85,9 @@ export function startseite( spieler({ dateiId: e.fileId, 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, laenge: e.durationSeconds ?? null, danach: () => void aufbauen(),