Der Aufmacher blendet über, statt zu blättern

Der gespiegelte Ausschnitt hinter der Seitenleiste war nicht das Problem —
JellyTV macht es genauso. Auffällig wurde er erst durch die Bewegung: beim
seitlichen Durchblättern wanderten das echte und das gespiegelte Stück
gegeneinander, und die Naht dazwischen war das auffälligste Ding auf dem
Bildschirm.

Jetzt rutscht das neue Bild 32 Punkte herein und blendet dabei über das alte.
Nichts wandert weit genug, als dass ein Versatz auffiele. Wischen blättert
weiter, nach vorn wie zurück; das nächste Bild wird geholt, solange das
aktuelle noch steht, sonst blendet der Aufmacher auf eine graue Fläche über.

Dazu ein Testziel, das die App mit der Attrappe in einen Zustand fährt und
fotografiert. Der Grund 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. Damit ist „sieht es so aus?" eine Frage, die sich beantworten lässt.

Die Startargument-Anmeldung an der Attrappe steht unter #if DEBUG und führt am
Schlüsselbund vorbei: kein echter Server, kein echtes Konto, kein echter Token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 17:10:03 +02:00
co-authored by Claude Opus 5
parent 7faa16be9c
commit 7155f50bac
5 changed files with 206 additions and 39 deletions
+42
View File
@@ -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,
+82 -33
View File
@@ -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) }
}
}
+57
View File
@@ -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")
}
}
@@ -93,13 +93,14 @@ public struct HeroBanner<Actions: View>: 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
+18
View File
@@ -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