Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,204 @@
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
import HaloDesign
|
||||
import HaloDownloads
|
||||
import HaloPlayback
|
||||
import JellyfinKit
|
||||
import MediaKit
|
||||
|
||||
@main
|
||||
struct HaloApp: App {
|
||||
#if os(iOS)
|
||||
/// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von
|
||||
/// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen
|
||||
/// eigenen Weg — nur den klassischen App-Delegaten. Auf dem Mac wird eine
|
||||
/// App nicht aus dem Speicher geworfen, während sie lädt; dort gibt es
|
||||
/// weder das Signal noch den Bedarf.
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
#endif
|
||||
|
||||
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
|
||||
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
|
||||
/// Downloads zurücksetzen.
|
||||
@State private var theme = HaloTheme()
|
||||
@State private var session: HaloSession
|
||||
@State private var playback = PlaybackCoordinator()
|
||||
@State private var playbackSettings = PlaybackSettings()
|
||||
@State private var languages = LanguagePreferences()
|
||||
@State private var quickPlay = QuickPlay()
|
||||
@State private var subtitleAppearance = SubtitleAppearance()
|
||||
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
|
||||
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
||||
@State private var network = NetworkConditions()
|
||||
@State private var homeLayout = HomeLayoutSettings()
|
||||
@State private var diagnostics = PlaybackDiagnostics()
|
||||
@State private var downloads: DownloadCoordinator
|
||||
|
||||
private let modelContainer: ModelContainer
|
||||
|
||||
init() {
|
||||
_session = State(initialValue: HaloSession(
|
||||
jellyfinIdentity: JellyfinIdentity.current(
|
||||
client: Branding.appName,
|
||||
version: Branding.version
|
||||
),
|
||||
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
||||
))
|
||||
|
||||
// Scheitert das Anlegen der Datenbank, ist die App nicht sinnvoll zu
|
||||
// benutzen — dann lieber hier abbrechen als später bei jedem Download
|
||||
// stillschweigend nichts tun.
|
||||
let container = try! ModelContainer(for: DownloadItem.self)
|
||||
self.modelContainer = container
|
||||
|
||||
let fileStore = try! DownloadFileStore()
|
||||
_downloads = State(initialValue: DownloadCoordinator(
|
||||
container: container,
|
||||
fileStore: fileStore
|
||||
))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
// Eine Untergrenze, unter der die Oberfläche nicht mehr
|
||||
// aufgeht: schmaler als das hier klappt die Seitenleiste zu
|
||||
// und die Reihen zeigen anderthalb Kacheln.
|
||||
//
|
||||
// Zur Laufzeit gefragt, nicht beim Übersetzen: auf dem Mac
|
||||
// läuft genau dieser iOS-Bau, und `os(macOS)` ist dabei
|
||||
// falsch. Ohne Untergrenze öffnet das Fenster in
|
||||
// Notizzettelgröße, und darin fällt der Aufmacher in sich
|
||||
// zusammen, weil seine Abstände dehnbar sind.
|
||||
.frame(
|
||||
minWidth: HaloPlatform.isMac ? 900 : nil,
|
||||
minHeight: HaloPlatform.isMac ? 620 : nil
|
||||
)
|
||||
// `defaultSize` gibt es hier nicht; die Größe hängt an der
|
||||
// Szene.
|
||||
.task { Self.widenWindow() }
|
||||
.environment(session)
|
||||
.environment(playback)
|
||||
.environment(playbackSettings)
|
||||
.environment(languages)
|
||||
.environment(quickPlay)
|
||||
.environment(subtitleAppearance)
|
||||
.environment(network)
|
||||
.environment(downloads)
|
||||
.environment(homeLayout)
|
||||
.environment(diagnostics)
|
||||
.environment(theme)
|
||||
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
||||
//
|
||||
// Scene-Bodies werten Änderungen an beobachteten Objekten
|
||||
// nicht zuverlässig neu aus. Stand `.tint` hier, blieb nach
|
||||
// einem Farbwechsel ein Teil der Oberfläche auf dem alten
|
||||
// Wert stehen — sichtbar etwa an Picker-Werten, die weiter in
|
||||
// der Standardfarbe leuchteten, während die Schalter schon
|
||||
// umgestellt waren.
|
||||
//
|
||||
// Der eigentliche Schalter für „nur dunkel" steht in der
|
||||
// Info.plist (UIUserInterfaceStyle). Diese Zeile ist die
|
||||
// Absicherung für SwiftUI-Vorschauen, die sie nicht lesen.
|
||||
.preferredColorScheme(.dark)
|
||||
.task {
|
||||
#if os(iOS)
|
||||
appDelegate.downloads = downloads
|
||||
#endif
|
||||
await downloads.restore()
|
||||
// Beim Start einmal prüfen: während die App weg war,
|
||||
// können Folgen gesehen oder gelöscht worden sein.
|
||||
await DownloadRefiller(
|
||||
backend: session.backend,
|
||||
coordinator: downloads
|
||||
).refill()
|
||||
}
|
||||
}
|
||||
.modelContainer(modelContainer)
|
||||
#if os(macOS)
|
||||
// Ohne diese Angabe öffnet macOS ein Fenster von der Größe eines
|
||||
// Notizzettels — und darin fällt der Anmeldebildschirm in sich
|
||||
// zusammen, weil seine Abstände dehnbar sind.
|
||||
.defaultSize(width: 1280, height: 820)
|
||||
// Das Fenster darf nicht kleiner werden, als der Inhalt verträgt.
|
||||
.windowResizability(.contentMinSize)
|
||||
// Keine graue Titelleiste über einem dunklen Bild: die Ampelknöpfe
|
||||
// schweben über dem Inhalt, wie man es von Fernseh-Apps kennt.
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
extension HaloApp {
|
||||
/// Gibt dem Fenster eine vernünftige Mindestgröße.
|
||||
///
|
||||
/// Auf dem Mac öffnet das System sonst ein Fenster von der Größe eines
|
||||
/// Notizzettels — und darin fällt der Aufmacher in sich zusammen, weil
|
||||
/// seine Abstände dehnbar sind.
|
||||
///
|
||||
/// `sizeRestrictions` gibt es nur dort, wo Fenster sich überhaupt frei
|
||||
/// ziehen lassen; auf dem iPhone ist die Eigenschaft `nil`. Das
|
||||
/// Fragezeichen ist deshalb die ganze Fallunterscheidung — ein Aufruf
|
||||
/// auf einem Gerät ohne Fenster tut schlicht nichts.
|
||||
@MainActor
|
||||
static func widenWindow() {
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 900, height: 620)
|
||||
windowScene.sizeRestrictions?.maximumSize = CGSize(
|
||||
width: CGFloat.greatestFiniteMagnitude,
|
||||
height: CGFloat.greatestFiniteMagnitude
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der klassische App-Delegat, für das eine, was SwiftUI nicht abdeckt.
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var downloads: DownloadCoordinator?
|
||||
|
||||
/// Nicht `@Sendable`: UIKit reicht den Block ohne diese Zusage herein,
|
||||
/// und er wird ohnehin nur auf dem Hauptakteur aufgerufen. Der Delegat
|
||||
/// selbst ist an den Hauptakteur gebunden.
|
||||
private var pendingCompletionHandler: (() -> Void)?
|
||||
|
||||
/// Welche Ausrichtungen gerade erlaubt sind.
|
||||
///
|
||||
/// Wird vom System bei jeder Drehung gefragt. Ohne diese Antwort dreht
|
||||
/// `requestGeometryUpdate` zwar kurz, das System dreht aber sofort zurück
|
||||
/// — genau das sah aus wie „wechselt nicht so richtig ins Querformat".
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
supportedInterfaceOrientationsFor window: UIWindow?
|
||||
) -> UIInterfaceOrientationMask {
|
||||
MainActor.assumeIsolated { OrientationLock.shared.mask }
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
// iOS hat die App im Hintergrund gestartet, weil Downloads fertig
|
||||
// geworden sind. Der Abschlussblock muss aufgerufen werden, sobald
|
||||
// alles verarbeitet ist — sonst beendet das System die App hart und
|
||||
// merkt sich das für künftige Aufweckversuche negativ.
|
||||
guard identifier == DownloadCoordinator.sessionIdentifier else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
pendingCompletionHandler = completionHandler
|
||||
|
||||
Task { [weak self] in
|
||||
await self?.downloads?.restore()
|
||||
self?.pendingCompletionHandler?()
|
||||
self?.pendingCompletionHandler = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user