Der Aufmacher bleibt stehen, und Folgen bekommen ein Bild
Drei Sachen am Karussell. Die Vorschlaege wechselten bei jedem Start, obwohl es dafuer laengst ein Zeitfenster von sechs Stunden gab. Der Grund: das Los hing an der Position in der Liste, nicht am Titel. Kam einer dazu, verschwand einer, oder aenderte sich ein Fortschritt, bekamen alle dahinter ein anderes Los — und „Weiterschauen\" waechst und schrumpft staendig. Jetzt haengt das Los an der Kennung; wer dazukommt, stoert niemanden. Als eigener Typ mit elf Tests, weil „es wechselt zu oft\" kein Befund ist, den man debuggen kann. Folgen zeigten kein Bild. Die Kette Folge → Serie gab es zwar, aber sie hoerte beim ersten Glied auf, das sich **zusammensetzen** liess — und ob es das Bild dahinter gibt, stellt sich erst beim Abruf heraus. Die Reihenfolge wandert deshalb ins Laden: Folge, Staffel, Serie, der Reihe nach, bis eine Adresse antwortet. Und auf dem Mac gibt es jetzt Pfeile links und rechts, sobald der Zeiger ueber dem Aufmacher liegt, sowie Punkte, die unter dem Zeiger wachsen und auf die man springen kann. Nur dort: auf dem iPad und dem iPhone wischt man ohnehin, und zwei Knoepfe staenden nur im Bild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4b084bc926
commit
2a2afa8573
@@ -77,9 +77,9 @@ struct PlayableDetailView: View {
|
||||
// hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war
|
||||
// davon ein Streifen aus der Mitte. Derselbe Fehler war im
|
||||
// Aufmacher schon einmal behoben worden, nur hier nicht.
|
||||
artworkURL: session.backend.flatMap {
|
||||
HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact)
|
||||
},
|
||||
artworkURLs: session.backend.map {
|
||||
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
|
||||
} ?? [],
|
||||
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
|
||||
) {
|
||||
Button {
|
||||
|
||||
@@ -61,9 +61,9 @@ struct SeriesDetailView: View {
|
||||
// hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war
|
||||
// davon ein Streifen aus der Mitte. Derselbe Fehler war im
|
||||
// Aufmacher schon einmal behoben worden, nur hier nicht.
|
||||
artworkURL: session.backend.flatMap {
|
||||
HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact)
|
||||
},
|
||||
artworkURLs: session.backend.map {
|
||||
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
|
||||
} ?? [],
|
||||
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
|
||||
) {
|
||||
DownloadAllButton(container: item)
|
||||
|
||||
@@ -36,11 +36,39 @@ enum HeroArtwork {
|
||||
/// vom Bild bleibt wenig übrig. Ein Plakat ist von vornherein hochkant;
|
||||
/// es passt in den Rahmen, ohne dass etwas weggeschnitten werden muss.
|
||||
static func backdropURL(for item: MediaItem, backend: any MediaBackend, compact: Bool) -> URL? {
|
||||
backdropURLs(for: item, backend: backend, compact: compact).first
|
||||
}
|
||||
|
||||
/// Alle Kandidaten, vom besten zum schlechtesten.
|
||||
///
|
||||
/// **Warum mehrere und nicht einer.** Eine Folge hat selten ein eigenes
|
||||
/// Querformatbild, die Staffel manchmal, die Serie fast immer. Bisher
|
||||
/// wurde die erste Adresse genommen, die sich **zusammensetzen** liess —
|
||||
/// und ob es das Bild dahinter gibt, stellt sich erst beim Abruf heraus.
|
||||
/// Gab es keines, blieb die Fläche grau, obwohl eine Ebene höher eines
|
||||
/// gelegen hätte. Genau so sahen Folgen im Aufmacher aus.
|
||||
///
|
||||
/// Die Reihenfolge steht hier, ausprobiert wird sie beim Laden.
|
||||
static func backdropURLs(
|
||||
for item: MediaItem,
|
||||
backend: any MediaBackend,
|
||||
compact: Bool
|
||||
) -> [URL] {
|
||||
let size = size(compact: compact)
|
||||
let path = compact
|
||||
? (item.posterArtworkPath ?? item.art)
|
||||
: (item.art ?? item.seriesArt ?? item.thumb)
|
||||
return backend.artworkURL(path: path, width: size.width, height: size.height)
|
||||
let pfade: [String?] = compact
|
||||
// Hochkant: das Plakat der Folge, dann das der Staffel, dann das
|
||||
// der Serie. Ein Hintergrundbild kommt hier zuletzt — es ist
|
||||
// quer und verliert hochkant die Hälfte.
|
||||
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
||||
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
||||
// das der Serie — und erst danach das Vorschaubild der Folge.
|
||||
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
||||
|
||||
var gesehen = Set<String>()
|
||||
return pfade
|
||||
.compactMap(\.self)
|
||||
.filter { gesehen.insert($0).inserted }
|
||||
.compactMap { backend.artworkURL(path: $0, width: size.width, height: size.height) }
|
||||
}
|
||||
|
||||
/// Der freigestellte Schriftzug — ohne Bild-Transcoder.
|
||||
|
||||
@@ -59,6 +59,10 @@ struct HeroCarousel: View {
|
||||
/// 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
|
||||
/// Ob der Zeiger über dem Aufmacher liegt — dann erscheinen die Pfeile.
|
||||
@State private var isPointerInside = false
|
||||
/// Ob er über den Punkten liegt — dann werden sie gross genug zum Treffen.
|
||||
@State private var isHoveringDots = false
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@@ -151,6 +155,12 @@ struct HeroCarousel: View {
|
||||
// eine Geste, die schon bei null Punkten greift, das Scrollen
|
||||
// schluckt.
|
||||
.simultaneousGesture(TapGesture().onEnded { isAdvancing = false })
|
||||
// **Nur auf dem Mac.** Dort gibt es einen Zeiger, und Wischen ist
|
||||
// mit dem Trackpad zwar möglich, aber nicht naheliegend — man
|
||||
// sieht dem Aufmacher nicht an, dass er weitergeht. Auf dem iPad
|
||||
// und dem iPhone wischt man ohnehin, und zwei Knöpfe ständen nur
|
||||
// im Bild.
|
||||
.overlay { if isMac, items.count > 1 { arrows } }
|
||||
|
||||
if items.count > 1 { indicator }
|
||||
}
|
||||
@@ -166,18 +176,100 @@ struct HeroCarousel: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var isMac: Bool { FlixyPlatform.isMac }
|
||||
|
||||
/// Die beiden Pfeile links und rechts.
|
||||
///
|
||||
/// Zurückhaltend: sie sollen im Bild liegen, ohne es zu beherrschen.
|
||||
/// Deshalb erscheinen sie erst, wenn der Zeiger über dem Aufmacher ist —
|
||||
/// vorher gibt es nichts zu bedienen, und ein Knopf, der dauerhaft über
|
||||
/// einem Filmplakat klebt, ist ein Fremdkörper.
|
||||
private var arrows: some View {
|
||||
HStack {
|
||||
arrow(symbol: "chevron.left", direction: -1)
|
||||
Spacer()
|
||||
arrow(symbol: "chevron.right", direction: 1)
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.regular)
|
||||
.opacity(isPointerInside ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.2), value: isPointerInside)
|
||||
.onContinuousHover { phase in
|
||||
if case .active = phase { isPointerInside = true } else { isPointerInside = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func arrow(symbol: String, direction: Int) -> some View {
|
||||
Button {
|
||||
isAdvancing = false
|
||||
step(by: direction)
|
||||
} label: {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(FlixySpacing.regular)
|
||||
.background(.black.opacity(0.45), in: .circle)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(direction > 0 ? "hero.next" : "hero.previous"))
|
||||
}
|
||||
|
||||
/// Die Punkte darunter — auf dem Mac auch ein Bedienelement.
|
||||
///
|
||||
/// **Warum sie wachsen.** Sechs Punkte hoch trifft niemand, und drei
|
||||
/// Punkte breit schon gar nicht. Unter dem Zeiger werden sie deshalb
|
||||
/// grösser, sobald er in ihrer Nähe ist — dieselbe Bewegung, mit der das
|
||||
/// iPad seine Fensterknöpfe unter dem Finger vergrössert. Dass etwas
|
||||
/// wächst, wenn man sich nähert, heisst: hier kann man etwas tun.
|
||||
///
|
||||
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
|
||||
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
|
||||
private var indicator: some View {
|
||||
HStack(spacing: FlixySpacing.snug) {
|
||||
HStack(spacing: isEnlarged ? FlixySpacing.regular : FlixySpacing.snug) {
|
||||
ForEach(items.indices, id: \.self) { position in
|
||||
Capsule()
|
||||
.fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4))
|
||||
.frame(width: position == currentIndex ? 18 : 6, height: 6)
|
||||
.frame(
|
||||
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
|
||||
height: isEnlarged ? 12 : 6
|
||||
)
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
guard isMac else { return }
|
||||
isAdvancing = false
|
||||
jump(to: position)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
|
||||
// beim Annähern etwas bewirken, nicht erst beim Treffen.
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
.contentShape(.rect)
|
||||
.onContinuousHover { phase in
|
||||
guard isMac else { return }
|
||||
if case .active = phase { isHoveringDots = true } else { isHoveringDots = false }
|
||||
}
|
||||
.animation(.snappy(duration: 0.25), value: currentIndex)
|
||||
.animation(.snappy(duration: 0.2), value: isEnlarged)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var isEnlarged: Bool { isMac && isHoveringDots }
|
||||
|
||||
/// Springt unmittelbar zu einem Titel.
|
||||
private func jump(to position: Int) {
|
||||
guard position != currentIndex, items.indices.contains(position) else { return }
|
||||
isMovingForward = position > currentIndex
|
||||
fadingID = shownID
|
||||
shownID = items[position].id
|
||||
cover = 0
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.55)) {
|
||||
cover = 1
|
||||
} completion: {
|
||||
fadingID = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Einen Titel weiter oder zurück.
|
||||
private func step(by direction: Int) {
|
||||
guard items.count > 1 else { return }
|
||||
|
||||
@@ -16,9 +16,9 @@ struct HeroSection: View {
|
||||
title: item.rowTitle,
|
||||
metadata: metadata,
|
||||
summary: item.summary,
|
||||
artworkURL: session.backend.flatMap {
|
||||
HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact)
|
||||
},
|
||||
artworkURLs: session.backend.map {
|
||||
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
|
||||
} ?? [],
|
||||
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
|
||||
// Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass
|
||||
// Schriftzug und Beschreibung übereinanderlagen.
|
||||
|
||||
@@ -150,84 +150,21 @@ struct HomeView: View {
|
||||
|
||||
// **Gemischt, aber nicht bei jedem Blick.**
|
||||
//
|
||||
// Vorher standen hier schlicht die ersten fünf der Liste. Das ist
|
||||
// deterministisch, und deshalb zeigte der Aufmacher monatelang
|
||||
// denselben Film — er war nun einmal der erste in „Weiterschauen".
|
||||
//
|
||||
// Bei jedem Laden zu würfeln wäre das andere Extrem: man tippt auf
|
||||
// etwas, geht zurück, und es ist weg. Deshalb ein Zufall mit
|
||||
// Zeitfenster — innerhalb von sechs Stunden dieselbe Auswahl, danach
|
||||
// eine neue. Wer die App dreimal am Abend öffnet, findet sich zurecht;
|
||||
// wer sie am nächsten Morgen öffnet, sieht etwas anderes.
|
||||
var generator = TimeWindowGenerator(hours: 6)
|
||||
|
||||
// Wie gemischt wird, steht in `HeroSelection` — einem reinen Typ mit
|
||||
// Tests. Der Grund dafür ist ein Fehler, den man hier nie gefunden
|
||||
// hätte: das Los hing an der **Position** in der Liste, nicht am
|
||||
// Titel. Kam einer dazu oder änderte sich ein Fortschritt, bekamen
|
||||
// alle dahinter ein anderes Los — und der Aufmacher war nach jedem
|
||||
// Start ein anderer, obwohl das Zeitfenster dasselbe war.
|
||||
switch layout.heroSource {
|
||||
case .continueFirst:
|
||||
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
||||
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
||||
let continuing = unique.filter { $0.watchProgress != nil }
|
||||
let rest = unique.filter { $0.watchProgress == nil }
|
||||
let ordered = Self.drawn(continuing, using: &generator)
|
||||
+ Self.drawn(rest, using: &generator)
|
||||
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
|
||||
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
|
||||
return Array(ordered.prefix(layout.heroCount))
|
||||
case .latestOnly:
|
||||
return Array(Self.drawn(unique, using: &generator).prefix(layout.heroCount))
|
||||
}
|
||||
}
|
||||
|
||||
/// Zieht die Titel in eine Reihenfolge — mit ungleichen Losen.
|
||||
///
|
||||
/// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text, und
|
||||
/// ein Titel ohne eines gehört dort eigentlich nicht hin. Ihn ganz
|
||||
/// auszuschließen war aber zu hart: bei einer Mediathek, in der wenige
|
||||
/// Titel ein Hintergrundbild tragen, blieben immer dieselben drei übrig.
|
||||
///
|
||||
/// Also bekommt jeder ein Los, und wer kein Hintergrundbild hat, ein
|
||||
/// schlechteres — sein Wert liegt nur im untersten Fünftel. Solange es
|
||||
/// genug Titel mit Bild gibt, kommt er praktisch nie nach vorn; gibt es
|
||||
/// wenige, hat er seine Chance. Kein Sonderfall, keine Schwelle, ab der
|
||||
/// die Regel kippt.
|
||||
private static func drawn(
|
||||
_ items: [MediaItem],
|
||||
using generator: inout TimeWindowGenerator
|
||||
) -> [MediaItem] {
|
||||
var keyed: [(item: MediaItem, key: Double)] = []
|
||||
keyed.reserveCapacity(items.count)
|
||||
|
||||
for item in items {
|
||||
let hasBackdrop = item.art != nil || item.seriesArt != nil
|
||||
let weight = hasBackdrop ? 1.0 : 0.2
|
||||
keyed.append((item, Double.random(in: 0..<1, using: &generator) * weight))
|
||||
}
|
||||
|
||||
return keyed.sorted { $0.key > $1.key }.map(\.item)
|
||||
}
|
||||
|
||||
/// Ein Zufall, der sich innerhalb eines Zeitfensters gleich verhält.
|
||||
///
|
||||
/// Derselbe Startwert für alle Aufrufe im selben Fenster — damit ist die
|
||||
/// Auswahl stabil, solange man die App benutzt, und trotzdem eine andere,
|
||||
/// wenn man später wiederkommt. Ohne diesen Umweg müsste die gewählte
|
||||
/// Reihe irgendwo gespeichert und wieder eingelesen werden, nur damit sie
|
||||
/// beim Zurückblättern nicht springt.
|
||||
///
|
||||
/// SplitMix64: kurz, ohne Abhängigkeit, und für das Mischen von fünf
|
||||
/// Kacheln mehr als gut genug. Für irgendetwas Sicherheitsrelevantes wäre
|
||||
/// er es nicht — das ist hier auch nicht die Frage.
|
||||
private struct TimeWindowGenerator: RandomNumberGenerator {
|
||||
private var state: UInt64
|
||||
|
||||
init(hours: Double, now: Date = .now) {
|
||||
let window = UInt64(max(0, now.timeIntervalSince1970 / (hours * 3_600)))
|
||||
state = window &* 0x9E37_79B9_7F4A_7C15
|
||||
}
|
||||
|
||||
mutating func next() -> UInt64 {
|
||||
state &+= 0x9E37_79B9_7F4A_7C15
|
||||
var z = state
|
||||
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||
return z ^ (z >> 31)
|
||||
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user