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],