diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 78caca0..13824d4 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -3205,6 +3205,23 @@ } } }, + "player.buffering" : { + "comment" : "Sprachausgabe für den Abspielknopf, solange auf Daten gewartet wird", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lädt …" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading…" + } + } + } + }, "player.close" : { "extractionState" : "stale", "localizations" : { diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index c5011ff..69d140b 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -523,9 +523,14 @@ final class PlayerModel { programmeEndsAt = match.endsAt } + /// Anhalten oder weiterlaufen lassen. + /// + /// Das Warten auf Daten zählt hier zum Laufen. Ohne `isBuffering` in + /// dieser Bedingung führte ein Tipp in genau dem Moment zu `play()` auf + /// etwas, das bereits spielte — und sichtbar geschah nichts. func togglePlayPause() { guard let engine else { return } - if isPlaying { engine.pause() } else { engine.play() } + if isPlaying || isBuffering { engine.pause() } else { engine.play() } } func seek(to time: Duration) { diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index bf34867..82c3ea6 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -30,6 +30,10 @@ struct PlayerView: View { /// Welche Richtung gerade als Hinweis eingeblendet ist. @State private var seekHint: Double? @State private var hintTask: Task? + /// Ob die Tastatur bei diesem Bildschirm ankommt. Nur auf dem Mac gesetzt. + @FocusState private var keyboardFocus: Bool + /// Wann der Zeiger zuletzt die Bedienelemente geholt hat. + @State private var lastPointerReveal: Date = .distantPast var body: some View { ZStack { @@ -68,7 +72,12 @@ struct PlayerView: View { case .playing: // Wartet auf Daten: ohne Anzeige sieht das aus wie ein // Standbild, und man weiß nicht, ob noch etwas kommt. - if model.isBuffering { + // + // Nur, solange die Bedienelemente aus sind. Sind sie an, + // steht der Kreisel **im** Abspielknopf — dort, wo sonst + // das Symbol ist. Vorher lag er dahinter und das Dreieck + // davor: es sah pausiert aus, war es aber nicht. + if model.isBuffering && !showsControls { ProgressView() .tint(.white) .scaleEffect(1.4) @@ -109,9 +118,42 @@ struct PlayerView: View { .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible(settings.locksLandscape) .contentShape(.rect) - // Für alles außerhalb der Sprungflächen: Tippen zeigt und verbirgt - // die Bedienelemente wie bisher. - .onTapGesture { toggleControls() } + // Für alles außerhalb der Sprungflächen. + .onTapGesture { primaryTap() } + // **Tastatur und Zeiger — nur auf dem Mac.** + // + // Dort ist beides selbstverständlich: Leertaste hält an, die Pfeile + // springen, und die Bedienelemente kommen von allein, sobald sich der + // Zeiger regt. Auf dem iPhone gibt es keines von beidem, und + // `focusable` legte dort nur einen Fokusrahmen über den Film. + #if targetEnvironment(macCatalyst) + .focusable() + .focusEffectDisabled() + .focused($keyboardFocus) + // Von Hand gesetzt, weil sonst niemand den Fokus hat: der Player + // besteht aus Knöpfen, und solange keiner angeklickt wurde, ginge + // jeder Tastendruck ins Leere. + .onAppear { keyboardFocus = true } + .onKeyPress(.space) { + model.togglePlayPause() + revealControls() + return .handled + } + .onKeyPress(.leftArrow) { + seek(by: -Double(settings.rewindSeconds)) + revealControls() + return .handled + } + .onKeyPress(.rightArrow) { + seek(by: Double(settings.forwardSeconds)) + revealControls() + return .handled + } + .onContinuousHover { phase in + guard case .active = phase else { return } + pointerMoved() + } + #endif .sheet(item: $trackPicker) { kind in TrackPickerSheet(model: model, kind: kind) } @@ -288,10 +330,27 @@ struct PlayerView: View { model.togglePlayPause() scheduleControlsHide() } label: { - Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 52)) + ZStack { + // Solange auf Daten gewartet wird, steht hier der Kreisel + // — kein Dreieck. Ein Dreieck heißt „angehalten, tipp + // mich", und beides stimmte nicht: es lief bereits, und + // der Tipp bewirkte nichts. + if model.isBuffering { + ProgressView() + .tint(.white) + .scaleEffect(1.4) + } else { + Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 52)) + } + } + // Feste Größe, damit die beiden Knöpfe daneben stehen + // bleiben. Sie wanderten bisher bei jedem Wechsel ein Stück, + // weil Dreieck und Doppelbalken verschieden breit sind. + .frame(width: 64, height: 64) } - .accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play")) + .accessibilityLabel(Text(playPauseLabel(for: model))) + .animation(.easeInOut(duration: 0.2), value: model.isBuffering) Button { model.skip(by: .seconds(settings.forwardSeconds)) @@ -304,6 +363,15 @@ struct PlayerView: View { .foregroundStyle(.white) } + /// Was die Sprachausgabe zu dem Knopf sagt. + /// + /// Beim Warten weder „Abspielen" noch „Pause": beides wäre eine Auskunft + /// über einen Zustand, in dem der Player gerade nicht ist. + private func playPauseLabel(for model: PlayerModel) -> LocalizedStringKey { + if model.isBuffering { return "player.buffering" } + return model.isPlaying ? "player.pause" : "player.play" + } + private func scrubber(for model: PlayerModel) -> some View { VStack(spacing: FlixySpacing.tight) { Slider( @@ -499,7 +567,7 @@ struct PlayerView: View { // Das doppelte zuerst: sonst gewinnt das einfache und man käme nie // zum Springen. .onTapGesture(count: 2) { seek(by: seconds) } - .onTapGesture { toggleControls() } + .onTapGesture { primaryTap() } } private func seek(by seconds: Double) { @@ -523,14 +591,57 @@ struct PlayerView: View { // MARK: - Ein- und Ausblenden + /// Was ein einfacher Klick oder Tipp ins Bild auslöst. + /// + /// **Auf dem Mac: anhalten.** Dort liegt ein Zeiger auf dem Bild, und die + /// Bedienelemente kommen von selbst, sobald er sich bewegt — ein Klick + /// muss sie nicht erst holen. Erst aufdecken und dann den Pausenknopf + /// suchen sind zwei Handgriffe für eine Sache. + /// + /// **Mit dem Finger nicht.** Dort gibt es keine Bewegung ohne Berührung: + /// ein Tipp, der sofort anhält, nähme den einzigen Weg zu Scrubber, + /// Tonspur und Qualität. + private func primaryTap() { + #if targetEnvironment(macCatalyst) + model.togglePlayPause() + revealControls() + #else + toggleControls() + #endif + } + private func toggleControls() { - withAnimation(.easeInOut(duration: 0.2)) { showsControls.toggle() } - // Beim Aufdecken nachsehen, was inzwischen läuft — genau dann will - // man es wissen. - if showsControls, model.isLive { - Task { await model.refreshLiveProgrammeIfNeeded() } + if showsControls { + withAnimation(.easeInOut(duration: 0.2)) { showsControls = false } + } else { + revealControls() } - if showsControls { scheduleControlsHide() } + } + + /// Zeigt die Bedienelemente und setzt die Ausblendfrist neu. + private func revealControls() { + if !showsControls { + withAnimation(.easeInOut(duration: 0.2)) { showsControls = true } + // Beim Aufdecken nachsehen, was inzwischen läuft — genau dann + // will man es wissen. + if model.isLive { + Task { await model.refreshLiveProgrammeIfNeeded() } + } + } + scheduleControlsHide() + } + + /// Der Zeiger hat sich bewegt. + /// + /// Gedrosselt, weil die Meldung bei jeder Bewegung kommt — bis zu + /// hundertmal je Sekunde. Die Ausblendfrist jedes Mal neu aufzusetzen + /// hieße, hundertmal je Sekunde eine Aufgabe abzubrechen und eine neue zu + /// starten. Fünfmal reicht vollkommen. + private func pointerMoved() { + let now = Date.now + guard !showsControls || now.timeIntervalSince(lastPointerReveal) > 0.2 else { return } + lastPointerReveal = now + revealControls() } /// Blendet die Bedienelemente nach kurzer Ruhe aus — aber nur, solange diff --git a/web/app/src/style.css b/web/app/src/style.css index 644dcfb..d7cc23a 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -341,19 +341,28 @@ img { display: block; } /* 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; } +/* Während des ersten Ladens haben Vor- und Zurückspulen keine Wirkung — es + gibt noch keine Zeitachse. Der mittlere Knopf bleibt stehen: er trägt + jetzt selbst den Kreisel. */ +#player.loading .pl-center > :not(.pl-play) { opacity: 0; pointer-events: none; } .pl-center { transition: opacity .2s ease; } .pl-busy { position: absolute; display: none; pointer-events: none; } .pl-busy.on { display: block; } -.pl-busy i { +.pl-busy i, .pl-wait { display: block; width: 44px; height: 44px; border-radius: 999px; border: 3px solid rgba(255,255,255,.2); border-top-color: var(--accent); animation: spin .8s linear infinite; } +/* Feste Größe für den mittleren Knopf, damit die beiden daneben stehen + bleiben: der Kreisel ist schmaler als das Symbol, das er vertritt, und + ohne diese Zeile rückten Zurück- und Vorspulen bei jedem Wechsel + zusammen. */ +.pl-round.pl-play { width: 68px; height: 68px; } + .pl-error { position: absolute; max-width: 460px; padding: var(--s-section); background: var(--elevated); border: 1px solid rgba(229,72,77,.35); diff --git a/web/app/src/views/player.ts b/web/app/src/views/player.ts index d550e8c..b635d7a 100644 --- a/web/app/src/views/player.ts +++ b/web/app/src/views/player.ts @@ -76,10 +76,16 @@ export function playerOeffnen(item: Item, beimSchliessen?: () => void): void { /* ---------- Fehler sichtbar machen ---------- */ let hls: HlsTyp | null = null; + /** Ob gerade auf Daten gewartet wird — am Anfang immer. */ + let wartet = true; const scheitern = (satz: string, hinweis?: string, adresse?: string): void => { ebene.classList.remove('loading'); el('pl-busy').classList.remove('on'); + // Sonst dreht sich der Kreisel im Knopf hinter dem Fehlerkasten weiter, + // als käme noch etwas. + wartet = false; + malen(); ebene.querySelector('.pl-error')?.remove(); const kasten = document.createElement('div'); kasten.className = 'pl-error'; @@ -105,7 +111,21 @@ export function playerOeffnen(item: Item, beimSchliessen?: () => void): void { clearTimeout(ausblenden); // Solange etwas steht, bleibt die Bedienung: sie auszublenden, während // niemand spielt, versteckt den einzigen Knopf, der weiterhilft. - if (!v.paused) ausblenden = setTimeout(() => chrome.classList.remove('on'), 3000); + if (!v.paused) { + ausblenden = setTimeout(() => { + chrome.classList.remove('on'); + kreisel(); + }, 3000); + } + kreisel(); + }; + + /** Der freistehende Kreisel — nur, wenn die Bedienung aus ist. + * + * Sonst stünden zwei übereinander: einer in der Mitte des Bildes und einer + * im Abspielknopf, der genau davor sitzt. Genau so sah es vorher aus. */ + const kreisel = (): void => { + el('pl-busy').classList.toggle('on', wartet && !chrome.classList.contains('on')); }; ebene.addEventListener('mousemove', zeigen); ebene.addEventListener('touchstart', zeigen, { passive: true }); @@ -117,16 +137,28 @@ export function playerOeffnen(item: Item, beimSchliessen?: () => void): void { el('pl-fwd').onclick = () => { v.currentTime = Math.min(v.duration || 1e9, v.currentTime + P.skipForward); zeigen(); }; v.addEventListener('click', umschalten); + /** Zeichnet den mittleren Knopf: Kreisel, Dreieck oder Doppelbalken. + * + * Solange auf Daten gewartet wird, steht dort der Kreisel — **kein** + * Dreieck. Ein Dreieck heißt „angehalten, drück mich", und beides stimmte + * nicht: es lief bereits, und der Klick bewirkte nichts. */ const malen = (): void => { - el('pl-toggle').innerHTML = icon(v.paused ? 'play' : 'pause', 52); - el('pl-toggle').setAttribute('aria-label', v.paused ? 'Abspielen' : 'Anhalten'); - zeigen(); + const knopf = el('pl-toggle'); + knopf.innerHTML = wartet ? '' : icon(v.paused ? 'play' : 'pause', 52); + knopf.setAttribute('aria-label', wartet ? 'Lädt' : v.paused ? 'Abspielen' : 'Anhalten'); }; - v.addEventListener('play', malen); - v.addEventListener('pause', malen); - v.addEventListener('waiting', () => el('pl-busy').classList.add('on')); + + const warten = (an: boolean): void => { + wartet = an; + malen(); + kreisel(); + }; + + v.addEventListener('play', () => { malen(); zeigen(); }); + v.addEventListener('pause', () => { warten(false); zeigen(); }); + v.addEventListener('waiting', () => warten(true)); v.addEventListener('playing', () => { - el('pl-busy').classList.remove('on'); + warten(false); ebene.classList.remove('loading'); }); @@ -202,7 +234,7 @@ export function playerOeffnen(item: Item, beimSchliessen?: () => void): void { lauf.stelle = v.currentTime || 0; ebene.querySelector('.pl-error')?.remove(); ebene.classList.add('loading'); - el('pl-busy').classList.add('on'); + warten(true); menueZu(); void strom(); }; @@ -411,7 +443,7 @@ export function playerOeffnen(item: Item, beimSchliessen?: () => void): void { zeigen(); tonMalen(); ebene.classList.add('loading'); - el('pl-busy').classList.add('on'); + warten(true); void strom(); } diff --git a/web/frontend/index.html b/web/frontend/index.html index 3002ea9..7f8f074 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -348,19 +348,26 @@ img { display: block; } /* 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; } +/* Während des ersten Ladens haben Vor- und Zurückspulen keine Wirkung — es + gibt noch keine Zeitachse. Der mittlere Knopf bleibt stehen: er trägt + jetzt selbst den Kreisel. */ +#player.loading .pl-center > :not(.pl-play) { opacity: 0; pointer-events: none; } .pl-center { transition: opacity .2s ease; } .pl-busy { position: absolute; display: none; pointer-events: none; } .pl-busy.on { display: block; } -.pl-busy i { +.pl-busy i, .pl-wait { display: block; width: 44px; height: 44px; border-radius: 999px; border: 3px solid rgba(255,255,255,.2); border-top-color: var(--accent); animation: spin .8s linear infinite; } +/* Feste Größe für den mittleren Knopf, damit die beiden daneben stehen + bleiben: der Kreisel ist schmaler als das Symbol, das er vertritt. */ +.pl-round.pl-play { width: 68px; height: 68px; } + .pl-error { position: absolute; max-width: 460px; padding: var(--s-section); background: var(--elevated); border: 1px solid rgba(229,72,77,.35); @@ -1624,9 +1631,16 @@ function openPlayer(item) { const chrome = layer.querySelector('.pl-chrome'); const el2 = (id) => layer.querySelector('#' + id); + /* Ob gerade auf Daten gewartet wird — am Anfang immer. */ + let wartet = true; + const fail = (satz, hinweis, adresse) => { layer.classList.remove('loading'); el2('pl-busy').classList.remove('on'); + // Sonst dreht sich der Kreisel im Knopf hinter dem Fehlerkasten weiter, + // als käme noch etwas. + wartet = false; + paint(); layer.querySelector('.pl-error')?.remove(); const box = document.createElement('div'); box.className = 'pl-error'; @@ -1644,7 +1658,17 @@ function openPlayer(item) { clearTimeout(hideTimer); // Solange etwas steht, bleibt die Bedienung: sie auszublenden, während // niemand spielt, versteckt den einzigen Knopf, der weiterhilft. - if (!v.paused) hideTimer = setTimeout(() => chrome.classList.remove('on'), 3000); + if (!v.paused) { + hideTimer = setTimeout(() => { chrome.classList.remove('on'); spin(); }, 3000); + } + spin(); + }; + + /* Der freistehende Kreisel — nur, wenn die Bedienung aus ist. Sonst + stünden zwei übereinander: einer in der Mitte des Bildes und einer im + Abspielknopf, der genau davor sitzt. */ + const spin = () => { + el2('pl-busy').classList.toggle('on', wartet && !chrome.classList.contains('on')); }; layer.addEventListener('mousemove', show); layer.addEventListener('touchstart', show, { passive: true }); @@ -1656,16 +1680,24 @@ function openPlayer(item) { el2('pl-fwd').onclick = () => { v.currentTime = Math.min(v.duration || 1e9, v.currentTime + P.skipForward); show(); }; v.addEventListener('click', toggle); + /* Zeichnet den mittleren Knopf: Kreisel, Dreieck oder Doppelbalken. + + Solange auf Daten gewartet wird, steht dort der Kreisel — **kein** + Dreieck. Ein Dreieck heißt „angehalten, drück mich", und beides stimmte + nicht: es lief bereits, und der Klick bewirkte nichts. */ const paint = () => { - el2('pl-toggle').innerHTML = icon(v.paused ? 'play' : 'pause', 52); - el2('pl-toggle').setAttribute('aria-label', v.paused ? 'Abspielen' : 'Anhalten'); - show(); + const knopf = el2('pl-toggle'); + knopf.innerHTML = wartet ? '' : icon(v.paused ? 'play' : 'pause', 52); + knopf.setAttribute('aria-label', wartet ? 'Lädt' : v.paused ? 'Abspielen' : 'Anhalten'); }; - v.addEventListener('play', paint); - v.addEventListener('pause', paint); - v.addEventListener('waiting', () => el2('pl-busy').classList.add('on')); + + const waiting = (an) => { wartet = an; paint(); spin(); }; + + v.addEventListener('play', () => { paint(); show(); }); + v.addEventListener('pause', () => { waiting(false); show(); }); + v.addEventListener('waiting', () => waiting(true)); v.addEventListener('playing', () => { - el2('pl-busy').classList.remove('on'); + waiting(false); layer.classList.remove('loading'); }); @@ -1814,7 +1846,7 @@ function openPlayer(item) { lauf.stelle = v.currentTime || 0; layer.querySelector('.pl-error')?.remove(); layer.classList.add('loading'); - el2('pl-busy').classList.add('on'); + waiting(true); closeMenu(); startStream(item, v, fail, lauf); }; @@ -1890,7 +1922,7 @@ function openPlayer(item) { show(); layer.classList.add('loading'); - el2('pl-busy').classList.add('on'); + waiting(true); startStream(item, v, fail, lauf); }