diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 7c8d743..6a99434 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -170,6 +170,13 @@ struct RootView: View { /// ausfahrbares Blatt, eine geteilte Ansicht und eine Reihe oben. Sie /// konnten zusammen weniger als das eine Bauteil und mussten dreimal /// gepflegt werden. + /// Läuft diese App gerade auf einem Mac? + /// + /// Nicht über `#if` zu haben: „Designed for iPad" **ist** der iOS-Bau, + /// unverändert, nur auf anderer Hardware. Erst zur Laufzeit ist der + /// Unterschied sichtbar. + static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp + private var library: some View { TabView(selection: $selection) { Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) { @@ -241,6 +248,10 @@ struct RootView: View { } .tabViewStyle(.sidebarAdaptable) .tabViewCustomization($customization) + // Auf dem Mac würde die Leiste sonst den Inhalt zur Seite schieben + // und selbst am Fensterrand kleben. Auf dem iPad tut das System es + // ohnehin so; der Aufruf schadet dort nicht. + .background { SidebarOverlapRequest().frame(width: 0, height: 0) } // Das Konto sitzt unten in der Seitenleiste, nicht als Eintrag // zwischen den Mediatheken. Dort sucht man es. .tabViewSidebarBottomBar { diff --git a/Flixy/Views/Navigation/SidebarOverlap.swift b/Flixy/Views/Navigation/SidebarOverlap.swift new file mode 100644 index 0000000..8efb9c9 --- /dev/null +++ b/Flixy/Views/Navigation/SidebarOverlap.swift @@ -0,0 +1,68 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// Bittet die Seitenleiste, den Inhalt zu **überlagern** statt ihn zu verdrängen. +/// +/// `UITabBarControllerSidebar.preferredLayout` steht ab Werk auf `automatic`, +/// und das löst sich laut Apples eigener Beschreibung je nach Gerät anders auf: +/// auf iOS zu `overlap` — die Leiste schwebt als eigene Fläche über dem Inhalt — +/// und auf macOS zu `tile`, wo sie den Inhalt zur Seite schiebt und selbst am +/// Fensterrand klebt. Das ist der Unterschied, den man zwischen iPad und Mac +/// sieht, und er kommt nicht aus dieser App. +/// +/// **Unter Mac Catalyst gemessen: es hilft dort nicht.** Der Griff sitzt +/// richtig — zum Beweis hat derselbe Weg die Leiste auf Kommando ganz +/// ausgeblendet —, aber `overlap` wird auf dem Mac stillschweigend verworfen +/// und es bleibt bei `tile`. Der Aufruf bleibt trotzdem stehen: er ist die +/// richtige Bitte, er kostet nichts, und sollte „Designed for iPad" sich näher +/// am iPhone-Bau verhalten als Catalyst, greift er dort. +/// +/// SwiftUI reicht die Eigenschaft nicht durch, also wird der zuständige +/// `UITabBarController` gesucht. Gesucht wird vom eigenen Platz aus nach oben +/// bis zur Wurzel und von dort nach unten — die Leiste liegt in der +/// SwiftUI-Sicht *über* uns, nicht unter uns, und die reine Elternkette führt +/// deshalb an ihr vorbei. +struct SidebarOverlapRequest: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + Probe() + } + + func updateUIViewController(_ controller: UIViewController, context: Context) {} + + /// Ein Controller, der nichts zeigt und nur da ist, um im Baum zu stehen. + private final class Probe: UIViewController { + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + apply() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Ein zweites Mal: beim ersten Anhängen steht der + // Tab-Controller noch nicht zwingend im Baum. + apply() + } + + private func apply() { + guard let controller = Self.tabBarController(from: self) else { return } + guard controller.sidebar.preferredLayout != .overlap else { return } + controller.sidebar.preferredLayout = .overlap + } + + private static func tabBarController(from start: UIViewController) -> UITabBarController? { + var root = start + while let parent = root.parent { root = parent } + return search(root) + } + + private static func search(_ controller: UIViewController) -> UITabBarController? { + if let found = controller as? UITabBarController { return found } + for child in controller.children { + if let found = search(child) { return found } + } + return nil + } + } +} +#endif diff --git a/FlixyUITests/LayoutSnapshots.swift b/FlixyUITests/LayoutSnapshots.swift index 6db53a2..815c076 100644 --- a/FlixyUITests/LayoutSnapshots.swift +++ b/FlixyUITests/LayoutSnapshots.swift @@ -29,6 +29,35 @@ final class LayoutSnapshots: XCTestCase { try? data.write(to: folder.appendingPathComponent("\(name).png")) } + /// Das Umschalten zwischen Seitenleiste und Leiste oben. + /// + /// Beim Umschalten steht die Leiste kurz falsch, bevor sie sich fängt. + /// Fotografiert wird in Serie, weil genau der Zwischenzustand gesucht ist + /// und ein einzelnes Bild danach nichts zeigt. + func testSidebarToggle() throws { + let app = XCUIApplication() + app.launchArguments = ["-FlixyStubServer", "http://127.0.0.1:8096"] + app.launch() + + XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertTrue(app.staticTexts["Weiterschauen"].waitForExistence(timeout: 20)) + Thread.sleep(forTimeInterval: 2) + shoot(app, "10-seitenleiste") + + // Der Knopf oben rechts in der Leiste schaltet um. Er trägt keine + // übersetzte Beschriftung, also über seine Stellung gesucht. + let toggle = app.buttons.element(boundBy: 1) + XCTAssertTrue(toggle.exists) + toggle.tap() + + for step in 0..<8 { + shoot(app, String(format: "11-umschalten-%d", step)) + Thread.sleep(forTimeInterval: 0.1) + } + Thread.sleep(forTimeInterval: 2) + shoot(app, "12-oben") + } + func testHomeAndDetail() throws { let app = XCUIApplication() app.launchArguments = ["-FlixyStubServer", "http://127.0.0.1:8096"] diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift index e360f90..4eb85fd 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift @@ -27,7 +27,16 @@ public actor ArtworkLoader { public static let shared = ArtworkLoader() private let session: URLSession - private let cache = NSCache() + /// Der Speicher liegt **außerhalb** des Actors, damit er sich ohne + /// `await` lesen lässt. + /// + /// Das klingt nach einer Kleinigkeit und ist der ganze Punkt: ein `await` + /// kostet immer mindestens einen Durchlauf, und genau der war beim + /// Überblenden des Aufmachers zu sehen. Das neue Bild erschien im ersten + /// Bild noch ohne Schriftzug, im zweiten mit — der Schriftzug sprang also + /// getrennt herein, obwohl er längst da war. Wer das Bild schon hat, soll + /// es im selben Augenblick zeigen können. + private let cache = ImageMemoryCache() /// Läuft eine Anfrage schon, wird sie geteilt statt ein zweites Mal /// gestellt. Ohne das lädt eine Reihe, die dasselbe Plakat zweimal zeigt, /// es auch zweimal. @@ -49,8 +58,15 @@ public actor ArtworkLoader { cache.countLimit = 400 } + /// Das Bild, wenn es schon im Arbeitsspeicher liegt — ohne Warten. + /// + /// `nonisolated`, damit eine Ansicht es beim Zeichnen aufrufen kann. + public nonisolated static func cachedImage(for url: URL) -> PlatformImage? { + shared.cache.image(for: url) + } + public func image(for url: URL) async -> PlatformImage? { - if let cached = cache.object(forKey: url as NSURL) { return cached } + if let cached = cache.image(for: url) { return cached } if let running = inFlight[url] { return await running.value } let task = Task { [session] in @@ -65,16 +81,33 @@ public actor ArtworkLoader { let image = await task.value inFlight[url] = nil - if let image { cache.setObject(image, forKey: url as NSURL) } + if let image { cache.store(image, for: url) } return image } - /// Das Bild, wenn es schon im Arbeitsspeicher liegt. - /// - /// Damit eine Kachel, die zum zweiten Mal erscheint, nicht erst kurz grau - /// aufblitzt — das ist beim Scrollen der sichtbarste Unterschied. - public func cached(_ url: URL) -> PlatformImage? { - cache.object(forKey: url as NSURL) +} + +/// Ein Bildspeicher, der sich von überall lesen lässt. +/// +/// `NSCache` ist laut Apples Dokumentation von sich aus für den Zugriff aus +/// mehreren Strängen gebaut — der Compiler weiß das nur nicht. Deshalb hier +/// von Hand versichert statt den Speicher in einen Actor zu sperren, was ihn +/// genau um die Eigenschaft brächte, für die er hier steht: ohne Warten +/// lesbar zu sein. +private final class ImageMemoryCache: @unchecked Sendable { + private let storage = NSCache() + + var countLimit: Int { + get { storage.countLimit } + set { storage.countLimit = newValue } + } + + func image(for url: URL) -> PlatformImage? { + storage.object(forKey: url as NSURL) + } + + func store(_ image: PlatformImage, for url: URL) { + storage.setObject(image, forKey: url as NSURL) } } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift index 9b9f812..b392c8a 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift @@ -45,9 +45,21 @@ public struct FlixyArtwork: View { .task(id: url) { await load() } } + /// Das Bild, das jetzt gezeigt werden kann. + /// + /// Der Blick in den Speicher steht **hier**, nicht in `load()`: eine + /// Kachel, die zum zweiten Mal erscheint, zeigt ihr Bild damit schon im + /// ersten Bild und blitzt nicht kurz grau auf. Über ein `await` ginge das + /// nicht — das kostet immer einen Durchlauf. + private var available: PlatformImage? { + if let loaded { return loaded } + guard let url else { return nil } + return ArtworkLoader.cachedImage(for: url) + } + @ViewBuilder private var content: some View { - if let loaded { + if let loaded = available { switch contentMode { case .fill: Image(platform: loaded).resizable().scaledToFill() case .fit: Image(platform: loaded).resizable().scaledToFit() @@ -70,10 +82,9 @@ public struct FlixyArtwork: View { return } - // Liegt es schon im Arbeitsspeicher, gibt es kein Aufblitzen: ohne - // diesen Schritt wäre jede zurückscrollende Kachel kurz grau. - if let cached = await ArtworkLoader.shared.cached(url) { - loaded = cached + // Was schon da ist, zeigt `available` bereits — hier bleibt nur das + // Nachladen. + if ArtworkLoader.cachedImage(for: url) != nil { hasFailed = false return } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 8c3a460..33dcbd6 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -72,7 +72,7 @@ public struct HeroBanner: View { @ViewBuilder private func titleView(scale: CGFloat) -> some View { Group { - if let logo { + if let logo = availableLogo { Image(platform: logo) .resizable() .scaledToFit() @@ -89,14 +89,22 @@ public struct HeroBanner: View { } } .task(id: logoURL) { - guard let logoURL else { - logo = nil - return - } + guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return } logo = await ArtworkLoader.shared.image(for: logoURL) } } + /// Der Schriftzug, wenn er **jetzt** gezeigt werden kann. + /// + /// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten + /// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann + /// steht bis dahin der gesetzte Titel. + private var availableLogo: PlatformImage? { + if let logo { return logo } + guard let logoURL else { return nil } + return ArtworkLoader.cachedImage(for: logoURL) + } + private func plainTitle(scale: CGFloat) -> some View { Text(title) .font(.system(size: 34 * scale, weight: .bold)) diff --git a/Tools/WindowShot/main.swift b/Tools/WindowShot/main.swift index 11e6fc0..aac1989 100644 --- a/Tools/WindowShot/main.swift +++ b/Tools/WindowShot/main.swift @@ -9,6 +9,9 @@ import Foundation // PyObjC nicht mehr mit, und `screencapture` kennt nur Kennungen, keine Namen. let name = CommandLine.arguments.dropFirst().first ?? "Flixy" +/// Zweites Argument: die Prozesskennung. Nötig, sobald zwei Bauarten +/// desselben Programms gleichzeitig laufen — sonst trifft es die falsche. +let wantedPID = CommandLine.arguments.dropFirst(2).first.flatMap(Int.init) guard let windows = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] else { FileHandle.standardError.write(Data("Fensterliste nicht lesbar\n".utf8)) @@ -19,6 +22,7 @@ let candidates = windows.filter { window in guard let owner = window[kCGWindowOwnerName as String] as? String, owner == name else { return false } // Ebene 0 ist das normale Fenster; alles darüber sind Menüs und Schatten. guard (window[kCGWindowLayer as String] as? Int) == 0 else { return false } + if let wantedPID, (window[kCGWindowOwnerPID as String] as? Int) != wantedPID { return false } return true }