diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift index d9ae46e..d45e2d2 100644 --- a/Flixy/Session/FlixySession.swift +++ b/Flixy/Session/FlixySession.swift @@ -203,6 +203,10 @@ final class FlixySession { /// Versucht, mit einem bereits gespeicherten Token weiterzumachen. func restore() async { + #if DEBUG + if await adoptStubServerIfRequested() { return } + #endif + switch storedKind { case .jellyfin: if await restoreJellyfin() { return } @@ -483,6 +487,44 @@ final class FlixySession { /// mehreren Schritten mit eigener Bedienung (Adresse, Code, Rückfall auf /// Passwort) und gehört deshalb in ein eigenes Modell. Hier kommt nur das /// Ergebnis an: ein Konto und ein Weg dorthin. + #if DEBUG + /// Meldet sich an der Attrappe aus `Tools/JellyfinStub` an. + /// + /// Nur in der Entwicklung und nur auf ausdrückliches Kommando: + /// `-FlixyStubServer http://127.0.0.1:8096` als Startargument. Der Weg + /// führt bewusst **am Schlüsselbund vorbei** und schreibt nichts fort — + /// so lässt sich die Oberfläche ansehen, prüfen und fotografieren, ohne + /// dass je ein echter Server, ein echtes Konto oder ein echter Token im + /// Spiel wäre. Genau daran hat es bisher gefehlt: Aussagen über das + /// Aussehen waren Vermutungen, weil niemand hinsehen konnte. + private func adoptStubServerIfRequested() async -> Bool { + guard let raw = defaults.string(forKey: "FlixyStubServer"), + let baseURL = URL(string: raw) + else { return false } + + let account = JellyfinAccount( + userID: "user-1", + userName: "Attrappe", + token: "stub-token", + isAdministrator: true + ) + let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: account.token) + let backend = JellyfinBackend(client: client, account: account, serverName: "Attrappe") + + kind = .jellyfin + jellyfinAccount = account + self.backend = backend + + do { + libraries = try await backend.libraries().filter(\.isSupported) + phase = .ready + } catch { + phase = .failed(message: ErrorText.describe(error)) + } + return true + } + #endif + func adoptJellyfin( client: JellyfinClient, account: JellyfinAccount, diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index 30eab80..3a9e534 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -4,19 +4,21 @@ import MediaKit /// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln. /// -/// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wandert der Aufmacher alle -/// paar Sekunden weiter und lässt sich auch von Hand durchblättern; die Punkte -/// darunter zeigen, wie viele es sind und wo man gerade ist. +/// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wechselt der Aufmacher alle +/// paar Sekunden; die Punkte darunter zeigen, wie viele es sind und wo man +/// gerade ist. /// -/// Das Weiterwandern hört auf, sobald jemand die Fläche berührt. Wer hier die -/// Hand hat, fährt selbst — sonst 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. +/// **Übergeblendet, nicht durchgeblättert.** Vorher lag hier ein einrastender +/// Streifen, der die Titel wie Buchseiten seitwärts durchschob. Das hatte eine +/// Nebenwirkung, die nichts mit dem Blättern zu tun schien: der Aufmacher füllt +/// die Fläche unter der Seitenleiste, indem er seinen eigenen Rand gespiegelt +/// weiterzeichnet. Steht das Bild still, fällt das niemandem auf. Schiebt es +/// sich dagegen seitwärts, wandern das echte und das gespiegelte Stück +/// gegeneinander, und die Naht dazwischen wird zum auffälligsten Ding auf dem +/// Bildschirm. /// -/// Geblättert wird mit einem einrastenden Streifen, nicht mit `TabView`: den -/// Seitenstil gibt es auf dem Mac nicht, und ein TabView im TabView hatte -/// nebenbei die Eigenschaft, beim Wechsel kurz zwei Seiten gleichzeitig zu -/// zeigen. +/// Jetzt rutscht das neue Bild nur ein kleines Stück herein und blendet dabei +/// über das alte. Nichts wandert weit genug, als dass ein Versatz auffiele. struct HeroCarousel: View { let items: [MediaItem] @@ -26,13 +28,28 @@ struct HeroCarousel: View { /// Sekunden ist der Satz zu Ende, bevor man ihn gelesen hat. private static let dwell: Duration = .seconds(7) + /// Wie weit das Bild dabei rutscht. + /// + /// Klein gehalten: es soll eine Richtung andeuten, nicht eine Strecke + /// zurücklegen. Alles darüber sieht wieder nach Blättern aus. + private static let slide: CGFloat = 32 + @State private var shownID: String? - /// Ob noch von selbst weitergewandert wird. + /// Ob noch von selbst weitergewechselt wird. + /// + /// Hört auf, sobald jemand die Fläche berührt. Wer hier die Hand hat, + /// fährt selbst — sonst wechselt der Aufmacher genau in der Sekunde, in + /// der man nach „Abspielen" greift, und man startet den falschen Film. @State private var isAdvancing = true + /// In welche Richtung zuletzt gewechselt wurde. Wer zurückblättert, soll + /// das Bild auch von der anderen Seite hereinkommen sehen. + @State private var isMovingForward = 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 @Environment(\.flixyAccent) private var accent + @Environment(FlixySession.self) private var session private var currentIndex: Int { items.firstIndex { $0.id == shownID } ?? 0 @@ -40,28 +57,35 @@ struct HeroCarousel: View { var body: some View { VStack(spacing: FlixySpacing.regular) { - ScrollView(.horizontal) { - LazyHStack(spacing: 0) { - ForEach(items) { item in - HeroSection(item: item) - .containerRelativeFrame(.horizontal) - .id(item.id) - } + ZStack { + if items.indices.contains(currentIndex) { + HeroSection(item: items[currentIndex]) + .id(items[currentIndex].id) + .transition(.asymmetric( + insertion: .opacity.combined( + with: .offset(x: isMovingForward ? Self.slide : -Self.slide) + ), + removal: .opacity.combined( + with: .offset(x: isMovingForward ? -Self.slide : Self.slide) + ) + )) } - .scrollTargetLayout() } - .scrollTargetBehavior(.paging) - .scrollPosition(id: $shownID) - // `.never`, nicht `.hidden`: wer in den Systemeinstellungen - // „Scrollbalken immer einblenden" gewählt hat, bekam sonst einen - // grauen Streifen quer unter den Aufmacher gelegt. - .scrollIndicators(.never) .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) - // Jede Berührung hält das Weiterwandern an — auch die, die nur ein - // Antippen wird. Gleichzeitig und ohne Mindestweg, damit weder dem - // Blättern noch den Knöpfen etwas weggenommen wird. + // Das Bild wechselt, der Platz nicht: ohne das Beschneiden schöbe + // das hereinrutschende Bild die Seite um seine Strecke breiter. + .clipped() + .animation(.easeInOut(duration: 0.55), value: shownID) + // Wischen blättert weiter, jede Berührung hält das Wechseln an — + // auch die, die nur ein Antippen wird. Gleichzeitig, damit weder + // dem Wischen noch den Knöpfen darunter etwas weggenommen wird. .simultaneousGesture( - DragGesture(minimumDistance: 0).onChanged { _ in isAdvancing = false } + DragGesture(minimumDistance: 0) + .onChanged { _ in isAdvancing = false } + .onEnded { value in + guard abs(value.translation.width) > 50 else { return } + step(by: value.translation.width < 0 ? 1 : -1) + } ) if items.count > 1 { indicator } @@ -86,14 +110,39 @@ struct HeroCarousel: View { .accessibilityHidden(true) } + /// Einen Titel weiter oder zurück. + private func step(by direction: Int) { + guard items.count > 1 else { return } + let next = (currentIndex + direction + items.count) % items.count + isMovingForward = direction > 0 + shownID = items[next].id + } + private func advance() async { guard items.count > 1, !reduceMotion, isAdvancing, shownID != nil else { return } + + // Das nächste Bild schon holen, solange das aktuelle noch steht. Ohne + // das blendet der Aufmacher auf eine graue Fläche über und das Bild + // erscheint erst danach — beim Blättern fiel das nicht auf, beim + // Überblenden sofort. + prefetchNeighbours() + try? await Task.sleep(for: Self.dwell) guard !Task.isCancelled, isAdvancing else { return } + step(by: 1) + } + private func prefetchNeighbours() { + guard let backend = session.backend else { return } let next = (currentIndex + 1) % items.count - withAnimation(.easeInOut(duration: 0.5)) { - shownID = items[next].id - } + let item = items[next] + // Dieselben Maße wie in `HeroSection` — eine andere Größe wäre eine + // andere Adresse und der Zwischenspeicher träfe daneben. + guard let url = backend.artworkURL( + path: item.art ?? item.seriesArt ?? item.thumb, + width: 900, + height: 1200 + ) else { return } + Task { _ = await ArtworkLoader.shared.image(for: url) } } } diff --git a/FlixyUITests/LayoutSnapshots.swift b/FlixyUITests/LayoutSnapshots.swift new file mode 100644 index 0000000..6db53a2 --- /dev/null +++ b/FlixyUITests/LayoutSnapshots.swift @@ -0,0 +1,57 @@ +import XCTest + +/// Fährt die App in die Zustände, über die geredet wird, und legt Bilder davon ab. +/// +/// Der Grund für diese Datei ist ein wiederkehrender Fehler: Aussagen über das +/// Aussehen waren Vermutungen, weil niemand hinsehen konnte. Der Simulator +/// kennt keinen Befehl fürs Antippen, und dieses Xcode bringt kein +/// Simulator-Fenster mehr mit, das sich fernsteuern ließe. Ein UI-Test kann +/// beides — tippen und fotografieren. +/// +/// Die Bilder landen im Dokumentenordner des Testläufers, der vom Rechner aus +/// über `simctl get_app_container` erreichbar ist. Anhänge im Ergebnisbündel +/// wären der vorgesehene Weg, müssten aber erst wieder herausgeschält werden. +/// +/// Die Inhalte kommen aus `Tools/JellyfinStub` — kein echter Server, kein +/// echtes Konto, kein echter Token. +final class LayoutSnapshots: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + /// `XCUIScreen` fotografiert in der Lage des **Geräts**, nicht in der der + /// Oberfläche — im Querformat kommt das Bild dann hochkant und um + /// neunzig Grad gedreht heraus, und jedes Maß daran ist falsch herum. + /// Die App selbst fotografiert richtig herum. + private func shoot(_ app: XCUIApplication, _ name: String) { + let data = app.screenshot().pngRepresentation + let folder = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + try? data.write(to: folder.appendingPathComponent("\(name).png")) + } + + func testHomeAndDetail() throws { + let app = XCUIApplication() + app.launchArguments = ["-FlixyStubServer", "http://127.0.0.1:8096"] + app.launch() + + XCUIDevice.shared.orientation = .landscapeLeft + + // Die Startseite muss stehen, bevor fotografiert wird — sonst ist auf + // dem Bild nur der Ladekreis. + XCTAssertTrue(app.staticTexts["Weiterschauen"].waitForExistence(timeout: 20)) + Thread.sleep(forTimeInterval: 3) + shoot(app, "01-start-oben") + + // Der Aufmacher wechselt nach sieben Sekunden. Kurz davor anfangen + // und in Serie fotografieren: nur so ist der Übergang selbst zu sehen + // und nicht bloß sein Ergebnis. + Thread.sleep(forTimeInterval: 4.2) + for step in 0..<6 { + shoot(app, String(format: "02-wechsel-%d", step)) + Thread.sleep(forTimeInterval: 0.12) + } + + Thread.sleep(forTimeInterval: 2) + shoot(app, "03-danach") + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 34895e2..d3d46cc 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -93,13 +93,14 @@ public struct HeroBanner: View { FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio) // Das Bild läuft über seinen Bereich hinaus — unter die // Seitenleiste und bis an den Fensterrand. Der Effekt spiegelt die - // Ränder und zeichnet sie weich weiter, statt das Bild zu - // verzerren; der Bildinhalt selbst bleibt, wo er ist, und mit ihm - // Titel, Beschreibung und Knöpfe. + // Ränder und zeichnet sie weich weiter; hinter der Leiste steht + // damit ein anderer Ausschnitt desselben Plakats. // - // Ohne das endete der Aufmacher an der Kante der Seitenleiste, und - // beim Weiterwandern schob sich das nächste Bild an einer harten - // Linie ins Bild. + // Das fiel unangenehm auf, solange der Aufmacher seitwärts + // durchblätterte: beim Weiterschieben wanderten der echte und der + // gespiegelte Ausschnitt gegeneinander, und die Naht dazwischen + // war nicht zu übersehen. Nicht der Effekt war falsch, sondern die + // Bewegung — siehe `HeroCarousel`. .backgroundExtensionEffect() .overlay(alignment: .bottom) { // Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in diff --git a/project.yml b/project.yml index 17d864d..3282d4c 100644 --- a/project.yml +++ b/project.yml @@ -239,6 +239,24 @@ targets: Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Medienserver zu finden und Filme direkt von ihm zu laden. + # Nur zum Hinsehen: fährt die App mit der Attrappe in einen bestimmten + # Zustand und hält sie dort an, damit von außen fotografiert werden kann. + # Kein Teil der App, wird nie mitgeliefert. + FlixyUITests: + type: bundle.ui-testing + platform: iOS + sources: + - FlixyUITests + dependencies: + - target: Flixy + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy.uitests + TEST_TARGET_NAME: Flixy + TARGETED_DEVICE_FAMILY: "1,2" + CODE_SIGN_STYLE: Automatic + GENERATE_INFOPLIST_FILE: YES + DownloadWidget: type: app-extension platform: iOS