From 5763d866bf52b7d78205662de8a7688ff7f88d31 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 13 Sep 2026 14:19:06 +0200 Subject: [PATCH] =?UTF-8?q?Miniplayer:=20antippen=20vergr=C3=B6=C3=9Fert?= =?UTF-8?q?=20wieder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Kachel ließ sich nicht mehr öffnen, und das war kein Zufall: das Antippen zeigte erst die Bedienelemente, und die zweite Berührung sollte vergrößern — traf dabei aber den Abspielknopf, der genau in der Mitte lag. Jede weitere Berührung landete wieder dort. Vergrößern war damit überhaupt nicht möglich. Jetzt gehört die Mitte dem Vergrößern, und zwar beim ersten Antippen. Die zwei Knöpfe — Pause und Schließen — sitzen dauerhaft sichtbar in den oberen Ecken, auf einer dunklen Scheibe, damit sie auf hellen Filmbildern nicht verschwinden. Unten bleibt der Fortschrittsbalken. Dass die Kachel „einfach weg" war, konnte ich nicht nachstellen; der eine Fall, den ich reproduziert habe, war mein eigener Attrappenserver mit einer Fortsetzungsstelle jenseits des Dateiendes. Zwei Dinge machen es trotzdem unwahrscheinlicher: die Knöpfe sind auch dann zu sehen, wenn das Bild schwarz bliebe — vorher war eine schwarze Kachel in einer fast schwarzen Oberfläche praktisch unsichtbar —, und die Bildfläche hängt jetzt in einer eigenen Hülle je Ansicht. Beim Wechsel zwischen Kachel und Vollbild gibt es beide kurz gleichzeitig, und eine `UIView` hat nur einen Vorgesetzten: wer sie zuletzt bekommt, gewinnt. Gab SwiftUI sie unmittelbar heraus, konnte das Abräumen der einen der anderen die Bildfläche wegnehmen. Dazu etwas, das mir beim Ausprobieren selbst passiert ist: der Aufmacher wanderte weiter, während ich nach „Abspielen" griff, und ich startete den falschen Film. Er hört jetzt bei der ersten Berührung auf zu wandern. Wer die Hand darauf hat, fährt selbst. Nachgesehen auf iPhone und iPad: verkleinern, antippen, wieder im Vollbild. Co-Authored-By: Claude Opus 5 --- Flixy/Views/Home/HeroCarousel.swift | 16 +++- Flixy/Views/Player/MiniPlayer.swift | 103 ++++++++++++-------------- Flixy/Views/Player/VideoSurface.swift | 31 +++++++- Tools/JellyfinStub/server.py | 6 +- 4 files changed, 95 insertions(+), 61 deletions(-) diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index cc08d71..e8fa92f 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -22,6 +22,14 @@ struct HeroCarousel: View { private static let dwell: Duration = .seconds(7) @State private var index = 0 + /// Ob noch von selbst weitergewandert wird. + /// + /// Sobald jemand die Fläche berührt, hört es auf — und zwar für gut. Wer + /// hier die Hand hat, fährt selbst. Andernfalls rutscht der Aufmacher + /// genau in der Sekunde weiter, in der man nach „Abspielen" greift, und + /// man startet den falschen Film. Das ist mir beim Ausprobieren selbst + /// passiert. + @State private var isAdvancing = true /// Bewegung, die von selbst losgeht, ist für manche Menschen unangenehm /// bis unbrauchbar. Wer sie abgestellt hat, blättert von Hand. @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -40,6 +48,12 @@ struct HeroCarousel: View { // den Knöpfen. Die Höhe kommt aus dem Seitenverhältnis, weil ein // TabView sonst nimmt, was es kriegen kann. .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) + // Gleichzeitig und ohne Mindestweg: fängt jede Berührung ab, ohne + // dem Blättern des TabView oder den Knöpfen darin etwas + // wegzunehmen. + .simultaneousGesture( + DragGesture(minimumDistance: 0).onChanged { _ in isAdvancing = false } + ) if items.count > 1 { indicator } } @@ -64,7 +78,7 @@ struct HeroCarousel: View { } private func advance() async { - guard items.count > 1, !reduceMotion else { return } + guard items.count > 1, !reduceMotion, isAdvancing else { return } try? await Task.sleep(for: Self.dwell) guard !Task.isCancelled else { return } withAnimation(.easeInOut(duration: 0.5)) { diff --git a/Flixy/Views/Player/MiniPlayer.swift b/Flixy/Views/Player/MiniPlayer.swift index c1149d7..b4e0fa9 100644 --- a/Flixy/Views/Player/MiniPlayer.swift +++ b/Flixy/Views/Player/MiniPlayer.swift @@ -26,8 +26,6 @@ struct MiniPlayer: View { /// Verschiebung während des Ziehens, noch nicht eingerastet. @State private var drag: CGSize = .zero - @State private var showsControls = false - @State private var hideTask: Task? private var side: MiniPlayerPlacement.Side { MiniPlayerPlacement.Side(rawValue: storedSide) ?? .trailing @@ -92,9 +90,7 @@ struct MiniPlayer: View { ProgressView().tint(.white) } - if showsControls { - controls.transition(.opacity) - } + corners // Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung — // es gibt kein Ende, auf das er zulaufen könnte. @@ -112,53 +108,59 @@ struct MiniPlayer: View { } .shadow(color: .black.opacity(0.5), radius: 12, y: 4) .contentShape(.rect) - .onTapGesture { - // Antippen zeigt erst die Bedienelemente, dann öffnet es. Ein - // sofortiges Aufklappen bei jeder Berührung wäre lästig, wenn man - // die Kachel nur anfassen will. - if showsControls { - playback.expand() - } else { - reveal() - } - } + // Antippen vergrößert, sofort. Vorher brauchte es zwei Berührungen: + // die erste holte die Bedienelemente hervor, die zweite sollte + // öffnen — traf dabei aber den Abspielknopf, der genau in der Mitte + // lag. Vergrößern war damit überhaupt nicht möglich. + .onTapGesture { playback.expand() } .accessibilityElement(children: .contain) .accessibilityLabel(Text(model.title)) } - private var controls: some View { - ZStack { - Color.black.opacity(0.45) - - Button { - model.togglePlayPause() - reveal() - } label: { - Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") - .font(.title2) - .foregroundStyle(.white) - .padding(FlixySpacing.snug) - .contentShape(.rect) - } - .accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play")) - - VStack { - HStack { - Spacer() - Button { - playback.close() - } label: { - Image(systemName: "xmark") - .font(.caption.weight(.bold)) - .foregroundStyle(.white) - .padding(FlixySpacing.snug) - .contentShape(.rect) - } - .accessibilityLabel(Text("miniPlayer.close")) + /// Die beiden Knöpfe in den oberen Ecken. + /// + /// Dauerhaft sichtbar statt auf Antippen hin. Die Fläche in der Mitte + /// gehört dem Vergrößern — sie ist das, was man an einer solchen Kachel + /// am häufigsten tut, und sie darf nicht mit einem Knopf belegt sein. + /// Unten bleibt frei für den Fortschrittsbalken. + private var corners: some View { + VStack { + HStack { + tileButton( + model.isPlaying ? "pause.fill" : "play.fill", + label: model.isPlaying ? "player.pause" : "player.play" + ) { + model.togglePlayPause() } Spacer() + tileButton("xmark", label: "miniPlayer.close") { + playback.close() + } } + Spacer() } + .padding(FlixySpacing.tight) + } + + /// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt. + /// + /// Die dunkle Scheibe darunter ist nicht Zierde: ein weißes Symbol auf + /// einer hellen Szene verschwindet, und die Kachel zeigt Filmbilder, deren + /// Helligkeit sich jede Sekunde ändert. + private func tileButton( + _ symbol: String, + label: LocalizedStringKey, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(.black.opacity(0.55), in: .circle) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(label)) } /// Größer und kleiner durch Zusammen- und Auseinanderziehen. @@ -168,7 +170,6 @@ struct MiniPlayer: View { private func zoomGesture(in bounds: CGSize) -> some Gesture { MagnifyGesture() .onChanged { value in - hideTask?.cancel() zoom = value.magnification } .onEnded { _ in @@ -191,7 +192,6 @@ struct MiniPlayer: View { ) -> some Gesture { DragGesture() .onChanged { value in - hideTask?.cancel() drag = value.translation } .onEnded { value in @@ -209,17 +209,6 @@ struct MiniPlayer: View { drag = .zero } } - - /// Zeigt die Bedienelemente und blendet sie nach kurzer Zeit wieder aus. - private func reveal() { - withAnimation(.easeInOut(duration: 0.2)) { showsControls = true } - hideTask?.cancel() - hideTask = Task { - try? await Task.sleep(for: .seconds(3)) - guard !Task.isCancelled else { return } - withAnimation(.easeInOut(duration: 0.2)) { showsControls = false } - } - } } extension View { diff --git a/Flixy/Views/Player/VideoSurface.swift b/Flixy/Views/Player/VideoSurface.swift index bbf8e09..51a79ff 100644 --- a/Flixy/Views/Player/VideoSurface.swift +++ b/Flixy/Views/Player/VideoSurface.swift @@ -47,9 +47,36 @@ final class PlayerLayerView: UIView { /// /// Umhängen statt neu bauen: dieselbe `UIView` wandert zwischen Kachel und /// Vollbild hin und her, und die Wiedergabe merkt davon nichts. +/// +/// Sie wird dabei **nicht** unmittelbar zurückgegeben, sondern in eine eigene +/// Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es beide +/// Ansichten kurz gleichzeitig gibt — beim Wechsel zwischen Kachel und +/// Vollbild: eine `UIView` hat nur einen Vorgesetzten, und wer sie zuletzt +/// bekommt, gewinnt. Gab SwiftUI sie unmittelbar heraus, nahm das Abräumen der +/// einen Ansicht der anderen die Bildfläche weg, und übrig blieb eine schwarze +/// Kachel — in einer fast schwarzen Oberfläche also scheinbar gar keine. +/// +/// Mit eigener Hülle gehört jeder Ansicht ihre eigene, und `updateUIView` holt +/// die Fläche zurück, falls sie zwischenzeitlich woanders eingehängt wurde. private struct UIViewContainer: UIViewRepresentable { let view: UIView - func makeUIView(context: Context) -> UIView { view } - func updateUIView(_ uiView: UIView, context: Context) {} + func makeUIView(context: Context) -> UIView { + let host = UIView() + host.backgroundColor = .black + adopt(into: host) + return host + } + + func updateUIView(_ host: UIView, context: Context) { + guard view.superview !== host else { return } + adopt(into: host) + } + + private func adopt(into host: UIView) { + view.translatesAutoresizingMaskIntoConstraints = true + view.frame = host.bounds + view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.addSubview(view) + } } diff --git a/Tools/JellyfinStub/server.py b/Tools/JellyfinStub/server.py index c6707f5..c7b9ab4 100644 --- a/Tools/JellyfinStub/server.py +++ b/Tools/JellyfinStub/server.py @@ -52,7 +52,11 @@ def item(key, name, kind, overview, *, resume=None, series=None): }], } if resume: - d["UserData"]["PlaybackPositionTicks"] = int(d["RunTimeTicks"] * resume) + # Drei Sekunden, absolut: die Beispieldatei ist zehn Sekunden lang. + # Ein Anteil der angegebenen Laufzeit läge weit dahinter, und der + # Player stünde am Ende vor einem schwarzen Bild — was wie ein Fehler + # der App aussieht und keiner ist. + d["UserData"]["PlaybackPositionTicks"] = 3 * 10_000_000 if series: d.update({"SeriesId": series[0], "SeriesName": series[1], "ParentIndexNumber": 1, "IndexNumber": series[2],