Ein Kreisel ist kein Pausenknopf

Während der Player auf Daten wartete, stand im mittleren Knopf das
Abspieldreieck und der Ladekreis lag dahinter. Das las sich als
„angehalten": man drückte auf Abspielen, und es geschah nichts — es lief
ja bereits, es kam nur nichts an.

Jetzt sitzt der Kreisel **im** Knopf, dort wo sonst das Symbol steht. Der
freistehende Kreisel in der Bildmitte bleibt für den Fall, dass die
Bedienelemente ausgeblendet sind. Zwei übereinander gibt es nicht mehr.

`togglePlayPause` zählt das Warten jetzt zum Laufen. Vorher führte ein
Tipp in diesem Moment zu `play()` auf etwas, das schon spielte.

Dazu auf dem Mac, wie im Browser auch:

- Ein Klick ins Bild hält an, statt erst die Bedienelemente zu holen. Die
  kommen dort von allein, sobald sich der Zeiger regt.
- Leertaste hält an, Pfeil links und rechts springen.

Mit dem Finger bleibt es beim Alten: ohne Zeiger gibt es keine Bewegung
ohne Berührung, und ein Tipp, der sofort anhält, nähme den einzigen Weg
zu Scrubber, Tonspur und Qualität.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-15 20:33:24 +02:00
co-authored by Claude Opus 5
parent 3af6c3112f
commit 9a4b6b1e06
6 changed files with 245 additions and 39 deletions
+125 -14
View File
@@ -30,6 +30,10 @@ struct PlayerView: View {
/// Welche Richtung gerade als Hinweis eingeblendet ist.
@State private var seekHint: Double?
@State private var hintTask: Task<Void, Never>?
/// 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