From 6010229b808ee27bcbef6db8f81470f4002f9d69 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 29 Sep 2026 17:09:18 +0200 Subject: [PATCH] Die Pakete wissen jetzt, was ein Fernseher ist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schritt eins zur Apple-TV-Fassung: nichts davon ist sichtbar, aber ohne das lässt sich für tvOS nicht einmal übersetzen. **Der stille Fehler zuerst.** `FlixyPlatform.isMac` hatte einen `#else`-Zweig, der `true` zurückgab — eine Antwort auf eine Frage, die niemand gestellt hatte. Auf tvOS fiel er dort hindurch: kein Übersetzungsfehler, keine Warnung, nur ein Fernseher, der sich für einen Mac hielt. Jetzt steht dort `#error`. Die nächste Plattform bricht den Bau ab und verlangt eine Entscheidung, statt sich eine auszusuchen. Ein Test hält fest, dass nicht beides zugleich gelten darf; er hätte genau diesen Fehler gefangen. **Ein echter Übersetzungsfehler**, und er lag an der Reihenfolge: `UIPasteboard` steht hinter `canImport(UIKit)`, und das ist auf tvOS wahr. Der tvOS-Zweig steht deshalb jetzt davor — ein Fernseher hat ohnehin nichts, wohin man etwas einfügen könnte. **Drei Vermutungen waren falsch**, nachgeschlagen im tvOS-SDK statt geraten: `presentationDetents` gibt es dort seit tvOS 16 (übersetzt, bewirkt nur nichts — jetzt steht es ausdrücklich da), `.topBarTrailing` seit 14.0, und `isMac` hatte acht Fundstellen in fünf Dateien, nicht fünfzig in siebenundzwanzig. **Schrift und Abstände** bekommen einen eigenen Zweig statt durchzufallen. Nicht feste Grössen: Dynamic Type gibt es auf tvOS auch, und dass `body` dort grösser ausfällt, ist der Sinn der Sache. Falsch war nur die Zuordnung der Rollen — die Skala ist anders gespreizt, und eine Beschriftung in `subheadline` unter einem Plakat ist aus drei Metern Zierrat. Der Aufmacher geht auf `.title`, weil UIKit `largeTitle` auf tvOS gar nicht kennt und nirgends steht, was SwiftUI daraus macht. `screenMargin` steht auf 48 und nicht auf 90: die neunzig Punkte des Überscan-Bereichs liefert das System als Sicherheitsrand, und wer sie hier aufschlägt, hat hundertachtzig. **Zwei Dinge, die sich erst beim Bauen zeigten.** `FlixyDownloads` übersetzte nicht — `volumeAvailableCapacityForImportantUsage` gibt es auf tvOS nicht. Das Paket wird dort trotzdem mitverlinkt, damit `PlayerModel` den Koordinator nennen darf, ohne dass zweitausend Zeilen Wiedergabelogik von `#if` durchzogen werden; angelegt wird nie einer. Und `FlixyProbe` liess das ganze Paket scheitern: ein Kommandozeilenwerkzeug mit `getpass` und `Host`, das auf einem Fernseher nie laufen sollte, hielt den Bau für alle anderen auf. Es ist jetzt ausdrücklich macOS-only. Co-Authored-By: Claude Opus 5 --- Packages/FlixyKit/Package.swift | 5 +- .../Sources/FlixyDesign/FlixyFont.swift | 70 +++++++++++++++++ .../Sources/FlixyDesign/PlatformShims.swift | 76 ++++++++++++++++--- .../Engine/DownloadFileStore.swift | 13 ++++ .../FlixyKit/Sources/FlixyProbe/Leiter.swift | 11 +++ .../FlixyKit/Sources/FlixyProbe/main.swift | 11 +++ .../FlixyDesignTests/FlixyPlatformTests.swift | 22 ++++++ 7 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 Packages/FlixyKit/Tests/FlixyDesignTests/FlixyPlatformTests.swift diff --git a/Packages/FlixyKit/Package.swift b/Packages/FlixyKit/Package.swift index 5f01de2..3aff79c 100644 --- a/Packages/FlixyKit/Package.swift +++ b/Packages/FlixyKit/Package.swift @@ -8,8 +8,9 @@ let package = Package( defaultLocalization: "en", // macOS steht hier nur, damit `swift test` die Logik-Tests ohne // Simulator laufen lassen kann — das spart bei jedem Durchlauf Minuten. - // Die App selbst ist iOS-only; das legt project.yml fest. - platforms: [.iOS(.v26), .macOS(.v26)], + // Welche Plattformen die Apps bedienen, legt project.yml fest: iOS für + // Flixy, tvOS für FlixyTV. + platforms: [.iOS(.v26), .macOS(.v26), .tvOS(.v26)], products: [ .library(name: "MediaKit", targets: ["MediaKit"]), .library(name: "PlexKit", targets: ["PlexKit"]), diff --git a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift index 265a9b3..3ea27ff 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift @@ -14,6 +14,13 @@ import SwiftUI /// Das kostet auf dem Mac Dynamic Type. Das ist vertretbar: macOS hat keine /// systemweite Textgrößen-Einstellung, auf die Apps reagieren müssten, und ein /// Fenster lässt sich stattdessen größer ziehen. +/// +/// **Auf dem Fernseher wieder Systemstile, aber andere Rollen.** Dynamic Type +/// gibt es dort auch, und dass `body` grösser ausfällt als am Telefon, ist +/// der Sinn der Sache — drei Meter Abstand. Falsch wäre nur, die iOS-Zuordnung +/// einfach durchfallen zu lassen: die Skala ist dort anders gespreizt, und +/// eine Beschriftung in `subheadline` unter einem Plakat ist aus drei Metern +/// Zierrat. extension Font { #if os(macOS) /// Der Titel im Aufmacher-Bild. @@ -28,6 +35,29 @@ extension Font { public static let flixyBody = Font.system(size: 17) /// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht. public static let flixyCaption = Font.system(size: 12) + #elseif os(tvOS) + /// Der Titel im Aufmacher-Bild. + /// + /// **`.title` und nicht `.largeTitle`.** In SwiftUI gibt es den Stil auf + /// tvOS, in UIKit nicht — `UIFontTextStyleLargeTitle` ist dort als nicht + /// verfügbar markiert. Was SwiftUI daraus macht, steht nirgends, und eine + /// Schrift, deren Grösse niemand nennen kann, gehört nicht in die grösste + /// Zeile der App. + public static let flixyHero = Font.system(.title, design: .default, weight: .bold) + /// Titel auf Detailseiten. + public static let flixyTitle = Font.system(.title2, design: .default, weight: .semibold) + /// Überschrift einer Reihe auf der Startseite. + public static let flixyRowHeader = Font.system(.headline, design: .default, weight: .semibold) + /// Titel unter einem Poster — auf tvOS nur bei Fokus sichtbar. + /// + /// Eine Stufe tiefer als auf iOS, und das ist gewollt: auf tvOS ist + /// `caption` schon gross, und ein Raster aus sechs Spalten mit + /// `subheadline`-Titeln hätte mehr Schrift als Bild. + public static let flixyCardTitle = Font.system(.caption, design: .default, weight: .medium) + /// Fließtext, Beschreibungen. + public static let flixyBody = Font.system(.body) + /// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht. + public static let flixyCaption = Font.system(.caption2) #else /// Der Titel im Aufmacher-Bild. public static let flixyHero = Font.system(.largeTitle, design: .default, weight: .bold) @@ -49,6 +79,22 @@ extension Font { /// Eine Skala statt frei gewählter Zahlen: dadurch stimmen die Ränder zwischen /// Bildschirmen überein, ohne dass man sie einzeln nachmisst. public enum FlixySpacing { + #if os(tvOS) + // Durchweg doppelt: ein Fernseher ist 1920 Punkte breit und wird aus drei + // Metern gelesen. Acht Punkte Abstand sind dort keine Trennung mehr. + public static let tight: CGFloat = 8 + public static let snug: CGFloat = 16 + public static let regular: CGFloat = 24 + public static let loose: CGFloat = 32 + public static let section: CGFloat = 48 + /// Der seitliche Rand des Inhalts — **zusätzlich** zum Sicherheitsrand. + /// + /// **Nicht 90.** Die neunzig Punkte des Überscan-Bereichs liefert das + /// System als Sicherheitsrand; wer sie hier noch einmal aufschlägt, hat + /// hundertachtzig und ein Raster, das in der Bildmitte klebt. Hier steht + /// nur die Rinne innerhalb des sicheren Bereichs. + public static let screenMargin: CGFloat = 48 + #else public static let tight: CGFloat = 4 public static let snug: CGFloat = 8 public static let regular: CGFloat = 12 @@ -56,10 +102,34 @@ public enum FlixySpacing { public static let section: CGFloat = 24 /// Der seitliche Rand des Inhalts zum Bildschirmrand. public static let screenMargin: CGFloat = 16 + #endif +} + +/// Der Überscan-Bereich eines Fernsehers. +/// +/// **Was das ist.** Ein Fernseher zeigt nicht zwingend das ganze Bild: je nach +/// Gerät und Einstellung schneidet er am Rand ab, und was dort steht, sieht +/// niemand. Apple nennt für 1920×1080 einen sicheren Bereich von neunzig +/// Punkten seitlich und sechzig oben wie unten. +/// +/// Ein **Bild** darf darüber hinausgehen — ein Aufmacher soll das sogar. +/// Schrift und Knöpfe nicht. Die Zahl steht hier für die Fälle, in denen der +/// Sicherheitsrand absichtlich übergangen wird und der Text trotzdem drinnen +/// bleiben muss. +public enum FlixySafeArea { + public static let tv = EdgeInsets(top: 60, leading: 90, bottom: 60, trailing: 90) } public enum FlixyRadius { + /// Die Ecke einer Kachel. + /// + /// Auf dem Fernseher grösser: eine Kachel, die sich beim Fokus anhebt, + /// wirkt mit acht Punkten bei 1920 Punkten Breite kantig. + #if os(tvOS) + public static let card: CGFloat = 12 + #else public static let card: CGFloat = 8 + #endif public static let sheet: CGFloat = 16 public static let control: CGFloat = 10 /// Eine schwebende Fläche — die Seitenleiste. So rund wie das System sie diff --git a/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift b/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift index 30765d6..4632fee 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift @@ -5,7 +5,7 @@ import UIKit import AppKit #endif -/// Was auf iPhone und Mac verschieden heißt — an einer Stelle. +/// Was auf iPhone, Mac und Fernseher verschieden heißt — an einer Stelle. /// /// Nicht jede Ansicht soll `#if os(iOS)` tragen. Ein Formular, das auf beiden /// Plattformen dasselbe meint, soll auf beiden gleich aussehen; wo die @@ -25,19 +25,49 @@ public enum FlixyPlatform { /// Wer nur die erste Hälfte prüft, schreibt Code für den Mac, der auf dem /// Mac nicht läuft: übersetzt sauber, ist aber genau dort abgeschaltet, /// wo er gebraucht wird. Deshalb hier beide Wege, an einer Stelle. + /// + /// **Und warum unten `#error` steht.** Der `#else`-Zweig lautete `true` — + /// eine Antwort auf eine Frage, die niemand gestellt hatte. Auf tvOS fiel + /// er deshalb hindurch und meldete einen Mac: kein Übersetzungsfehler, + /// keine Warnung, nur ein Fernseher, der sich für einen Mac hielt. Eine + /// Vorgabe ist an dieser Stelle immer falsch, weil die nächste Plattform + /// nicht danach fragt, ob sie gemeint war. Jetzt bricht der Bau ab und + /// verlangt eine Entscheidung. public static let isMac: Bool = { - #if targetEnvironment(macCatalyst) + #if os(tvOS) + false + #elseif targetEnvironment(macCatalyst) true #elseif os(iOS) ProcessInfo.processInfo.isiOSAppOnMac - #else + #elseif os(macOS) true + #else + #error("FlixyPlatform kennt diese Plattform nicht. Hier eintragen — nicht raten.") + #endif + }() + + /// Läuft die App auf einem Fernseher? + /// + /// Daneben und nicht in einem Aufzählungstyp: über die Frage wird + /// nirgends verzweigt, sie steht überall in einem Fragezeichen-Ausdruck. + /// Ein Aufzählungstyp wäre Zeremonie — und „iPhone oder iPad" liesse sich + /// darin ohnehin nicht beantworten, weil das `UIDevice.current` verlangt + /// und damit den Hauptakteur. + public static let isTV: Bool = { + #if os(tvOS) + true + #elseif os(iOS) || os(macOS) + false + #else + #error("FlixyPlatform kennt diese Plattform nicht. Hier eintragen — nicht raten.") #endif }() } extension View { - /// Eine schmale Titelzeile — auf dem Mac gibt es sie nicht. + /// Eine schmale Titelzeile — auf dem Mac und auf dem Fernseher gibt es + /// sie nicht. Beide fallen in denselben `#else`-Zweig, und beide zu Recht. public func flixyInlineTitle() -> some View { #if os(iOS) navigationBarTitleDisplayMode(.inline) @@ -51,22 +81,37 @@ extension View { /// Auf dem Mac ist ein Sheet ein Fenster: dort zählt keine Rastung, /// sondern eine vernünftige Mindestgröße. public func flixyMediumSheet() -> some View { + #if os(tvOS) + // Auf dem Fernseher ist ein Sheet immer bildfüllend. `presentationDetents` + // übersetzt dort zwar — es gibt sie seit tvOS 16 —, bewirkt aber + // nichts. Und eine Zeile, die nichts bewirkt, liest sich beim nächsten + // Mal wie eine, die etwas bewirkt. + self + #else // **Zur Laufzeit, nicht beim Übersetzen.** Der `#else`-Zweig für // macOS griff nie: auf dem Mac läuft der iOS-Bau, und damit bekam ein // grosses Fenster eine Sheet-Kante in iPhone-Höhe — ein schmaler // Streifen, der unten im Nichts klebt. presentationDetents(FlixyPlatform.isMac ? [.large] : [.medium]) + #endif } /// Dasselbe, aber auf Wunsch bis zur vollen Höhe aufziehbar. public func flixyResizableSheet() -> some View { + #if os(tvOS) + self + #else // Siehe oben. Auf dem Mac gibt es kein „halb aufgezogen": ein Fenster // hat die Höhe, die es hat, und eine Liste soll sie nutzen. presentationDetents(FlixyPlatform.isMac ? [.large] : [.medium, .large]) + #endif } /// Ein Formular, das auf beiden Seiten gleich aussieht. /// + /// Auf tvOS gibt es `formStyle` nicht und es ist auch nicht nötig — dort + /// fällt es in denselben Zweig wie iOS. + /// /// Auf dem Mac setzt SwiftUI sonst den Spalten-Stil: Beschriftungen rechts /// ausgerichtet, keine abgesetzten Abschnitte, alles eng. Der gruppierte /// Stil ist derselbe, den iOS ohnehin nimmt — und der Grund, warum die @@ -82,6 +127,9 @@ extension View { /// Eingabefelder für Adressen und Namen: keine Großschreibung, keine /// Korrektur — auf dem Mac gibt es beides ohnehin nicht als Tastatur. + /// + /// Auf tvOS ebenfalls der `#else`-Zweig: die Bildschirmtastatur dort + /// kennt weder `keyboardType` noch Autokorrektur in dieser Form. public func flixyPlainEntry(isAddress: Bool = false) -> some View { #if os(iOS) self @@ -98,8 +146,9 @@ extension View { /// Keine eigene Fläche hinter der Titelzeile. /// /// Auf der Startseite liegt darunter der Aufmacher — eine weichgezeichnete - /// Leiste verschleierte die Hälfte davon. Auf dem Mac gibt es die Leiste in - /// dieser Form nicht. + /// Leiste verschleierte die Hälfte davon. Auf dem Mac und auf dem + /// Fernseher gibt es die Leiste in dieser Form nicht — beide fallen in + /// den `#else`-Zweig. public func flixyClearToolbarBackground() -> some View { #if os(iOS) self @@ -117,7 +166,16 @@ extension View { /// deswegen nicht mit `#if` beginnen. public enum FlixyClipboard { public static func copy(_ text: String) { - #if canImport(UIKit) + #if os(tvOS) + // Es gibt keine. `UIPasteboard` ist auf tvOS als nicht verfügbar + // markiert — und ein Fernseher hat auch nichts, wohin man etwas + // einfügen könnte. + // + // **Die Reihenfolge ist der ganze Punkt.** `canImport(UIKit)` ist auf + // tvOS wahr; stünde dieser Zweig darunter, wäre es kein stiller + // Fehler, sondern ein lauter. + _ = text + #elseif canImport(UIKit) UIPasteboard.general.string = text #else NSPasteboard.general.clearContents() @@ -129,7 +187,7 @@ public enum FlixyClipboard { extension ToolbarItemPlacement { /// Rechts in der Titelzeile. public static var flixyTrailing: ToolbarItemPlacement { - #if os(iOS) + #if os(iOS) || os(tvOS) .topBarTrailing #else .primaryAction @@ -138,7 +196,7 @@ extension ToolbarItemPlacement { /// Links in der Titelzeile. public static var flixyLeading: ToolbarItemPlacement { - #if os(iOS) + #if os(iOS) || os(tvOS) .topBarLeading #else .navigation diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadFileStore.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadFileStore.swift index 3526e73..2ba1861 100644 --- a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadFileStore.swift +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadFileStore.swift @@ -106,9 +106,22 @@ public struct DownloadFileStore: Sendable { /// /// Vor jedem Download geprüft: ein Download, der das Gerät vollschreibt, /// legt auch alles andere lahm. + /// + /// **Auf tvOS gibt es die Auskunft nicht** — + /// `volumeAvailableCapacityForImportantUsage` ist dort als nicht + /// verfügbar markiert, und ein Apple TV hat auch keinen Speicher, auf den + /// man etwas legt. Das Paket wird dort trotzdem mitverlinkt, damit + /// `PlayerModel` den Koordinator **nennen** darf, ohne dass zweitausend + /// Zeilen Wiedergabelogik von `#if` durchzogen werden. Angelegt wird nie + /// einer; diese Zeile läuft also nie. Null heisst „kein Platz", und das + /// ist die richtige Antwort für ein Gerät, auf dem nichts liegen soll. public func availableCapacity() -> Int64 { + #if os(tvOS) + 0 + #else let values = try? root.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) return values?.volumeAvailableCapacityForImportantUsage ?? 0 + #endif } /// Entfernt Dateien, zu denen es keinen Eintrag mehr gibt. diff --git a/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift b/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift index c9425c1..adcfce5 100644 --- a/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift +++ b/Packages/FlixyKit/Sources/FlixyProbe/Leiter.swift @@ -1,3 +1,13 @@ +// **Nur für den Rechner, auf dem entwickelt wird.** +// +// Das hier ist ein Kommandozeilenwerkzeug: es fragt nach einem Passwort über +// `getpass`, löst Rechnernamen über `Host` auf und druckt in ein Terminal. +// Nichts davon gibt es auf iOS, und auf tvOS erst recht nicht — die App +// hängt auch nicht daran. Ohne diese Bedingung scheitert aber jeder Versuch, +// **das ganze Paket** für eine andere Plattform zu übersetzen, an einem +// Werkzeug, das dort nie laufen sollte. Das kostete eine halbe Stunde +// Fehlersuche an der falschen Stelle. +#if os(macOS) import Foundation import JellyfinKit import MediaKit @@ -523,3 +533,4 @@ func zeigeSpuren(host: String, suche: String) async { print("Zu lesen ist das so: stimmt das Kürzel nicht mit dem Namen überein,") print("lügt die Datei — und dann hilft nur der Name oder das Merken.") } +#endif diff --git a/Packages/FlixyKit/Sources/FlixyProbe/main.swift b/Packages/FlixyKit/Sources/FlixyProbe/main.swift index 84e134f..6585cd8 100644 --- a/Packages/FlixyKit/Sources/FlixyProbe/main.swift +++ b/Packages/FlixyKit/Sources/FlixyProbe/main.swift @@ -1,3 +1,13 @@ +// **Nur für den Rechner, auf dem entwickelt wird.** +// +// Das hier ist ein Kommandozeilenwerkzeug: es fragt nach einem Passwort über +// `getpass`, löst Rechnernamen über `Host` auf und druckt in ein Terminal. +// Nichts davon gibt es auf iOS, und auf tvOS erst recht nicht — die App +// hängt auch nicht daran. Ohne diese Bedingung scheitert aber jeder Versuch, +// **das ganze Paket** für eine andere Plattform zu übersetzen, an einem +// Werkzeug, das dort nie laufen sollte. Das kostete eine halbe Stunde +// Fehlersuche an der falschen Stelle. +#if os(macOS) import Foundation import MediaKit import PlexKit @@ -380,3 +390,4 @@ func herausziehen(_ name: String, aus text: String) -> String? { } return nil } +#endif diff --git a/Packages/FlixyKit/Tests/FlixyDesignTests/FlixyPlatformTests.swift b/Packages/FlixyKit/Tests/FlixyDesignTests/FlixyPlatformTests.swift new file mode 100644 index 0000000..d050d42 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyDesignTests/FlixyPlatformTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import FlixyDesign + +@Suite("Auf welchem Gerät wir laufen") +struct FlixyPlatformTests { + + /// **Der Test, den es vorher nicht gab — und der gefehlt hat.** + /// + /// `isMac` hatte einen `#else`-Zweig, der `true` zurückgab. Auf tvOS fiel + /// er dort hindurch: kein Übersetzungsfehler, keine Warnung, nur ein + /// Fernseher, der sich für einen Mac hielt. Zwei Wahrheitswerte, von denen + /// beide gelten, sind immer ein Fehler — und dieser eine Ausdruck hätte + /// ihn gefangen. + /// + /// Er läuft hier auf macOS und beweist damit nur die Hälfte. Die andere + /// Hälfte beweist er, sobald er auf einem Fernseher läuft — und bis dahin + /// hält er wenigstens fest, dass die Frage gestellt gehört. + @Test("Ein Gerät ist nicht zugleich Mac und Fernseher") + func neverBoth() { + #expect(!(FlixyPlatform.isMac && FlixyPlatform.isTV)) + } +}