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:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
+1
View File
@@ -0,0 +1 @@
<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-name="Layer 1"><linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="-52.588" x2="390.826" y1="178.158" y2="434.163"><stop offset="0" stop-color="#55d6ff"/><stop offset="1" stop-color="#ce55ff"/></linearGradient><path d="m485.036 256a54.706 54.706 0 0 1 -27.636 47.868l-347.527 200.644a54.742 54.742 0 0 1 -55.272 0 54.713 54.713 0 0 1 -27.637-47.868v-401.288a54.718 54.718 0 0 1 27.636-47.869 54.75 54.75 0 0 1 55.273 0l347.527 200.645a54.708 54.708 0 0 1 27.636 47.868z" fill="url(#linear-gradient)"/></svg>

After

Width:  |  Height:  |  Size: 675 B

+49
View File
@@ -0,0 +1,49 @@
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.44346,0.27892,0.96783,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "display-p3:0.14018,0.08725,0.31323,1.00000"
}
}
],
"groups" : [
{
"layers" : [
{
"fill" : {
"automatic-gradient" : "display-p3:0.73918,0.46760,0.96783,1.00000"
},
"image-name" : "play.svg",
"name" : "play",
"position" : {
"scale" : 1.18,
"translation-in-points" : [
49.7265625,
1.468530916781674e-05
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+50
View File
@@ -0,0 +1,50 @@
import Foundation
/// Alles, was den Namen der App trägt — an einer Stelle.
///
/// Zum Umbenennen reichen zwei Stellen: `appName` hier und in `project.yml`
/// die drei Werte `name`, `PRODUCT_NAME` und `CFBundleDisplayName`. Die
/// übersetzten Texte nennen den Namen bewusst nirgends aus — sonst bliebe er
/// dort stehen, und zwar unbemerkt, weil nichts fehlschlägt.
///
/// Der Name steht noch nicht fest. Damit ein späterer Wechsel nicht zur Suche
/// quer durch den Quelltext wird, greift die App nirgends auf ein hart
/// geschriebenes „Halo" zu, sondern immer hierauf. Zu ändern sind dann genau
/// zwei Dinge: diese Datei und `project.yml` (Produktname, Bundle-Kennung,
/// Anzeigename).
///
/// Die Typen aus HaloKit behalten ihre Namen — sie sind Bauteile, keine Marke.
enum Branding {
/// Wie die App sich selbst nennt.
static let appName = "Halo"
/// Unter welchem Namen die Tokens im Schlüsselbund liegen.
///
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
/// Umbenennung der App soll niemanden abmelden.
static var keychainService: String {
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.halo"
}
/// Wann dieser Build entstanden ist.
///
/// Gelesen als Änderungsdatum der eigenen Binärdatei — kein Build-Skript,
/// keine erzeugte Datei, und immer richtig: die Binärdatei entsteht genau
/// dann, wenn gebaut wird.
///
/// Steht in den Einstellungen. Ohne diese Angabe lässt sich nicht
/// unterscheiden, ob ein Fehler noch besteht oder ob nur ein alter Build
/// auf dem Gerät liegt — und das kostet jedes Mal eine Rückfrage.
static var buildStamp: String {
guard let url = Bundle.main.executableURL,
let date = try? url.resourceValues(forKeys: [.contentModificationDateKey])
.contentModificationDate
else { return "—" }
return date.formatted(date: .abbreviated, time: .shortened)
}
/// Die Version aus dem Bundle, für die Kopfzeile `X-Plex-Version`.
static var version: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
}
}
+204
View File
@@ -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
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Halo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Halo benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen und Medien direkt von ihm zu laden.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>
@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
@@ -0,0 +1,12 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : { "alpha" : "1.000", "blue" : "0x0D", "green" : "0x0B", "red" : "0x0B" }
},
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
import Foundation
import HaloDownloads
import MediaKit
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
///
/// Die Regel-Engine rechnet aus, *wie viele* Folgen je Serie fehlen; hier wird
/// entschieden, *welche*. Die Trennung ist Absicht: das Rechnen braucht keinen
/// Server und ist deshalb prüfbar, das Auswählen braucht ihn zwangsläufig.
@MainActor
struct DownloadRefiller {
let backend: (any MediaBackend)?
let coordinator: DownloadCoordinator
/// Lädt nach, was den Regeln nach fehlt.
///
/// Läuft nach dem Ansehen einer Folge und beim Start — nicht auf einem
/// Zeitgeber. Eine App, die im Hintergrund unaufgefordert Gigabytes lädt,
/// wäre eine unangenehme Überraschung auf der Mobilfunkrechnung.
@discardableResult
func refill() async -> Int {
guard let backend, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 }
let needed = coordinator.rules.seriesNeedingRefill(in: coordinator.items)
guard !needed.isEmpty else { return 0 }
var added = 0
let service = DownloadService(backend: backend, coordinator: coordinator)
for (seriesKey, count) in needed {
let candidates = await nextUnwatchedEpisodes(
inSeries: seriesKey,
count: count,
backend: backend
)
for episode in candidates {
// Die Kinderliste bringt keine Dateiangaben mit — ohne sie
// ließe sich weder das Speicherlimit prüfen noch ein
// Fortschritt anzeigen.
let detailed = (try? await backend.item(id: episode.id)) ?? episode
// Folgen ohne abrufbare Datei still überspringen: das Nachladen
// läuft im Hintergrund, da hilft keine Fehlermeldung.
guard let plan = try? service.plan(for: detailed) else { continue }
if (try? coordinator.enqueue(plan)) != nil { added += 1 }
}
}
return added
}
/// Die nächsten ungesehenen Folgen einer Serie, die noch nicht offline sind.
///
/// In Ausstrahlungsreihenfolge, nicht nach Beliebtheit oder Zufall: wer
/// drei Folgen vorhalten lässt, will die drei, die als Nächstes dran sind.
private func nextUnwatchedEpisodes(
inSeries seriesKey: String,
count: Int,
backend: any MediaBackend
) async -> [MediaItem] {
guard let seasons = try? await backend.children(of: seriesKey) else { return [] }
let offlineKeys = Set(coordinator.items.map(\.ratingKey))
var result: [MediaItem] = []
for season in seasons.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
// Specials führt Plex als Staffel 0. Sie gehören nicht in den
// Vorrat für „was kommt als Nächstes".
guard (season.index ?? 0) > 0 else { continue }
guard let episodes = try? await backend.children(of: season.id) else { continue }
for episode in episodes.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
guard !episode.isWatched, !offlineKeys.contains(episode.id) else { continue }
result.append(episode)
if result.count >= count { return result }
}
}
return result
}
}
+383
View File
@@ -0,0 +1,383 @@
import Foundation
import HaloDownloads
import HaloPlayback
import MediaKit
/// Übersetzt Titel vom Server in Download-Aufträge.
///
/// Die Trennung ist Absicht: `DownloadCoordinator` kennt nur Adressen, Bytes
/// und Zustände — nichts von Plex. Dadurch bleibt die Engine ohne Server
/// testbar, und dieser Typ ist die einzige Stelle, die beides kennt.
/// In welcher Qualität heruntergeladen wird.
enum DownloadQuality: String, CaseIterable, Identifiable {
/// Die Datei, wie sie auf dem Server liegt.
case original
case fullHD
case hd
case low
var id: String { rawValue }
var maximumBitrate: Int? {
switch self {
case .original: nil
case .fullHD: 8_000
case .hd: 4_000
case .low: 2_000
}
}
var resolution: String? {
switch self {
case .original: nil
case .fullHD: "1920x1080"
case .hd: "1280x720"
case .low: "854x480"
}
}
/// Die Bildhöhe, auf die diese Stufe herunterrechnet.
var pixelHeight: Int? {
switch self {
case .original: nil
case .fullHD: 1080
case .hd: 720
case .low: 480
}
}
/// Welche Stufen für eine Datei überhaupt sinnvoll sind.
///
/// Eine 720p-Datei auf „Full HD" hochzurechnen bringt nichts außer einer
/// größeren Datei — und im Auswahldialog standen dann drei Einträge mit
/// derselben geschätzten Größe untereinander, was aussah, als wäre die
/// Schätzung kaputt. Sie war es nicht; die Stufen waren es.
static func available(forSourceHeight height: Int?) -> [DownloadQuality] {
guard let height else { return allCases }
// Zehn Prozent Spielraum: 1080p-Dateien sind oft 1072 oder 1088 hoch.
let threshold = Double(height) * 0.9
return allCases.filter { quality in
guard let target = quality.pixelHeight else { return true }
return Double(target) < threshold
}
}
/// Grobe Schätzung der Dateigröße bei dieser Stufe.
///
/// Aus Bitrate mal Laufzeit. Nicht genau — der Transcoder arbeitet mit
/// variabler Bitrate —, aber genau genug für die Frage, ob es aufs Gerät
/// passt. Bei `original` steht die echte Größe zur Verfügung.
func estimatedBytes(durationMilliseconds: Int?, originalBytes: Int64) -> Int64 {
guard let maximumBitrate, let durationMilliseconds, durationMilliseconds > 0 else {
return originalBytes
}
let seconds = Double(durationMilliseconds) / 1000
let estimate = Int64(Double(maximumBitrate) * 1000 / 8 * seconds)
// Nie mehr schätzen als das Original groß ist.
return originalBytes > 0 ? min(estimate, originalBytes) : estimate
}
}
@MainActor
struct DownloadService {
let backend: (any MediaBackend)?
let coordinator: DownloadCoordinator
/// Welche Sprache gewünscht ist — nur fürs Umpacken.
///
/// **Weil beim Umpacken eine Tonspur übrig bleibt.** Online wählt man im
/// Player; offline gibt es nach dem Herunterladen nichts mehr zu wählen,
/// also fällt die Entscheidung hier. Dieselbe Abwägung wie beim Abspielen
/// (`AudioTrackChooser`) — eine zweite Regel liefe irgendwann auseinander.
///
/// Optional, damit Aufrufer ohne Spracheinstellungen weiter bauen: dann
/// gilt, was der Server als Standard meldet.
var languages: LanguagePreferences?
/// Baut den Auftrag für einen einzelnen Titel.
///
/// Wirft mit Begründung, statt still `nil` zurückzugeben. Der Unterschied
/// ist nicht akademisch: mit `nil` sah der Download-Knopf aus, als täte er
/// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server
/// noch nicht analysiert hat.
func plan(
for item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original,
audioStream: MediaStream? = nil
) throws(DownloadRefusal) -> DownloadPlan {
guard let backend else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
}
let media = item.versions.indices.contains(mediaIndex)
? item.versions[mediaIndex]
: item.preferredVersion
guard let media else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia"))
}
guard let part = media.files.first else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
}
// **Was offline liegt, muss der Systemplayer öffnen können.** Online
// springt für eine Matroska das Umpacken ein; auf dem Gerät gibt es
// hinterher niemanden mehr, der das nachholen könnte — und seit es
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
// schwarz. Also wird schon beim Herunterladen umgepackt.
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
let url: URL?
switch quality {
case .original where umpacken:
// **Gewählt schlägt geraten.** Hier stand nur die Abwägung aus
// `AudioTrackChooser` — und die hat bei einer Aufnahme mit
// Audiodeskription die falsche Spur genommen, ohne zu fragen.
// Nach dem Download gibt es nichts mehr zu korrigieren; drei
// Gigabyte sind dann weg. Jetzt entscheidet, wer gefragt wurde,
// und die Abwägung ist nur noch der Vorschlag.
let ton = audioStream ?? preferredAudioStream(in: part, of: item)
let untertitel = part.subtitleStreams.first { $0.isSelected }
?? part.subtitleStreams.first
url = backend.remuxDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
audioStreamIndex: ton?.index,
subtitleStreamIndex: untertitel?.index
) ?? backend.downloadURL(for: part)
case .original:
url = backend.downloadURL(for: part)
case .fullHD, .hd, .low:
// Über den Universal-Transcoder. NOCH NICHT AM SERVER GEPRÜFT:
// offen ist, ob Plex eine Content-Length mitschickt — ohne sie
// gibt es keinen Fortschrittsbalken und kein Fortsetzen.
url = backend.compressedDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
maximumBitrate: quality.maximumBitrate ?? 8_000,
resolution: quality.resolution
)
}
guard let url else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noURL"))
}
return DownloadPlan(
ratingKey: item.id,
title: item.rowTitle,
subtitle: item.downloadSubtitle,
sourceURL: url,
seriesKey: item.seriesID,
seriesTitle: item.seriesTitle,
seasonKey: item.parentID,
seasonIndex: item.parentIndex,
episodeIndex: item.index,
durationMilliseconds: item.duration,
expectedBytes: quality.estimatedBytes(
durationMilliseconds: item.duration,
originalBytes: part.size ?? 0
),
// **Nur beim Original ist die Größe eine Tatsache.** Sie steht in
// den Metadaten des Servers. Die umgerechneten Stufen sind aus
// Bitrate und Dauer hochgerechnet — und was hochgerechnet ist,
// darf abweichen, ohne dass die Datei kaputt wäre.
// Umgepackt stimmt die Größe des Servers nicht mehr: dieselben
// Ströme in einer anderen Hülle wiegen anders. Dann ist sie wieder
// eine Schätzung — und die weiche Prüfung die richtige.
expectedIsExact: quality == .original && !umpacken && part.size != nil,
offlineMedia: offlineMedia(for: item, media: media, part: part, remuxed: umpacken)
)
}
/// Lädt einen einzelnen Titel.
@discardableResult
func download(
_ item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original,
audioStream: MediaStream? = nil
) throws(DownloadRefusal) -> DownloadItem {
try coordinator.enqueue(
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
)
}
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
/// werden muss.
///
/// **Die Frage gehört vor den Download, nicht danach.** Wird umgepackt,
/// legt der Server genau eine Tonspur in die neue Hülle; was nicht
/// mitkommt, ist offline nicht mehr zu holen. Automatisch zu wählen hiess
/// hier: dem Nutzer die Entscheidung abnehmen und ihn mit dem Ergebnis
/// leben lassen — bei einer Aufnahme mit Audiodeskription genau der
/// falsche Weg.
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
guard quality == .original else { return false }
guard let media = mediaIndex < item.versions.count
? item.versions[mediaIndex] : item.preferredVersion,
let part = media.files.first
else { return false }
return !systemPlayerOpens(media: media, part: part) && part.audioStreams.count > 1
}
/// Was ein Stapel ergeben hat.
typealias BatchResult = (
accepted: Int,
refused: [(MediaItem, DownloadRefusal)],
unavailable: Int
)
/// Lädt alle Folgen einer Staffel oder Serie.
func downloadAll(
in container: MediaItem,
quality: DownloadQuality = .original
) async -> BatchResult {
guard let backend else { return (0, [], 0) }
guard let episodes = try? await allEpisodes(in: container, backend: backend) else {
return (0, [], 0)
}
return await download(episodes, quality: quality)
}
/// Lädt genau die übergebenen Folgen.
///
/// **Holt die Metadaten jeder Folge einzeln nach.** Die Kinderliste einer
/// Staffel enthält keine Dateiangaben; ohne Dateigröße ließe sich weder
/// das Speicherlimit prüfen noch ein Fortschrittsbalken zeigen — und eine
/// erwartete Größe von null überspringt die Prüfung auf freien Platz
/// stillschweigend.
func download(
_ episodes: [MediaItem],
quality: DownloadQuality = .original,
audioStreamIndex: Int? = nil
) async -> BatchResult {
guard let backend else { return (0, [], 0) }
var accepted = 0
var refused: [(MediaItem, DownloadRefusal)] = []
var unavailable = 0
for episode in episodes {
// Folgen, die schon offline sind, werden übersprungen statt
// abgelehnt — bei einer Serie, von der die Hälfte geladen ist,
// wäre eine Liste aus zwanzig Fehlern keine Hilfe.
if coordinator.items.contains(where: { $0.ratingKey == episode.id }) {
continue
}
let detailed = (try? await backend.item(id: episode.id)) ?? episode
do {
// Die Nummer, nicht die Spur: innerhalb einer Staffel sind
// die Spuren gleich angeordnet, und die Folge selbst kennt
// ihre eigene Liste.
let ton = audioStreamIndex.flatMap { nummer in
detailed.preferredVersion?.files.first?.audioStreams
.first { $0.index == nummer }
}
_ = try coordinator.enqueue(
try plan(for: detailed, quality: quality, audioStream: ton)
)
accepted += 1
} catch DownloadRefusal.noDownloadableFile {
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
// werden am Ende nur gezählt.
unavailable += 1
} catch {
refused.append((episode, error))
// Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen
// noch einzeln durchzuprobieren — sie scheitern alle gleich.
if case .storageLimitReached = error { break }
if case .insufficientDeviceSpace = error { break }
}
}
return (accepted, refused, unavailable)
}
private func allEpisodes(in container: MediaItem, backend: any MediaBackend) async throws -> [MediaItem] {
switch container.kind {
case .season:
return try await backend.children(of: container.id)
case .show:
var episodes: [MediaItem] = []
for season in try await backend.children(of: container.id) {
episodes.append(contentsOf: try await backend.children(of: season.id))
}
return episodes
default:
return [container]
}
}
/// Der Abzug der Serverangaben, den der Player offline braucht.
/// Welche Tonspur vorgeschlagen wird.
///
/// **Die gemerkte der Serie zuerst.** Wer eine Folge schon einmal auf
/// Deutsch gesehen hat, will sie nicht auf Englisch herunterladen — und
/// die App merkt sich diese Entscheidung ohnehin schon fürs Abspielen
/// (`LanguagePreferences.rememberedTrack`). Dieselbe Abwägung also, über
/// denselben Weg: `wish` trägt das Gemerkte, die Serieneinstellung und
/// die allgemeine Sprache in dieser Reihenfolge.
///
/// Die Mediathek bleibt aussen vor: sie nachzuschlagen kostet eine
/// Anfrage je Folge, und die Ebenen darüber tragen den Wunsch ohnehin.
func preferredAudioStream(in part: MediaFile, of item: MediaItem) -> MediaStream? {
guard let languages else { return part.selectedAudioStream }
return AudioTrackChooser.choose(
from: part.streams,
wish: languages.wish(seriesID: item.seriesID, libraryID: nil)
) ?? part.selectedAudioStream
}
/// Ob AVFoundation die Datei so, wie sie auf dem Server liegt, öffnet.
private func systemPlayerOpens(media: MediaVersion, part: MediaFile) -> Bool {
DeviceCapabilities.avFoundation.opens(
container: part.container ?? media.container,
videoCodec: media.videoCodec,
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec
)
}
private func offlineMedia(
for item: MediaItem,
media: MediaVersion,
part: MediaFile,
remuxed: Bool
) -> OfflineMedia {
OfflineMedia(
// Umgepackt liegt eine MP4 auf dem Gerät, keine Matroska. Stünde
// hier weiter die alte Schachtel, entschiede der Player anhand
// einer Datei, die es so nicht mehr gibt.
container: remuxed ? "mp4" : (part.container ?? media.container),
videoCodec: media.videoCodec,
// **Beim Umpacken wissen wir es nicht.** Der Server rechnet nur
// um, was nicht durchpasst — ob aus DTS dann AAC oder AC3 wird,
// sagt er vorher nicht. Eine Behauptung wäre schlechter als die
// Lücke: der Player liest die Spuren ohnehin aus der Datei.
audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec),
videoResolution: media.videoResolution,
bitrate: media.bitrate,
// **Beim Umpacken bleibt je eine übrig.** Alle zu notieren wäre
// ein Versprechen, das die Datei nicht hält — und offline ist es
// nicht nachprüfbar, bevor der Player sie öffnet.
audioLanguages: remuxed ? [] : part.audioStreams.compactMap(\.language),
subtitleLanguages: remuxed ? [] : part.subtitleStreams.compactMap(\.language),
markers: item.markers.map {
OfflineMarker(
type: $0.kind.rawValue,
startMilliseconds: $0.startMilliseconds,
endMilliseconds: $0.endMilliseconds
)
}
)
}
}
extension MediaItem {
/// Die Zeile unter dem Titel in der Downloads-Ansicht.
var downloadSubtitle: String? {
if kind == .episode, let season = parentIndex, let episode = index {
return "\(String(localized: "episode.short \(season) \(episode)")) · \(title)"
}
return year.map(String.init)
}
}
+403
View File
@@ -0,0 +1,403 @@
import Foundation
import JellyfinKit
import MediaKit
import Observation
/// Der Zustand der Verbindung zum Server.
///
/// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß,
/// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort,
/// an dem sich entscheidet, was auf dem Bildschirm erscheint.
///
/// Seit es zwei Serverarten gibt, hält diese Klasse **einen** aktiven Server,
/// nicht zwei. Zwei gleichzeitig hießen: zwei Startseiten, zwei Mediatheken,
/// zwei Suchergebnisse — und bei jedem Titel die Frage, woher er stammt.
/// Umschalten ja, nebeneinander nein.
@MainActor
@Observable
final class HaloSession {
enum Phase: Equatable {
/// Beim Start: liegt ein Token im Schlüsselbund?
case launching
case signedOut
/// Ein Weg zum Server wird gesucht.
case connecting(serverName: String)
case ready
case failed(message: String)
// Kein eigener ==-Operator mehr: er war nur nötig, weil `PlexResource`
// nicht `Equatable` war und über die Kennung verglichen werden musste.
// Alle verbliebenen Nutzlasten sind `String`, also leitet der Compiler
// die Gleichheit selbst ab — und ein künftiger Fall wird nicht
// stillschweigend als „ungleich" behandelt.
}
private enum Key {
static let jellyfinAddress = "halo.jellyfin.address"
static let jellyfinUserID = "halo.jellyfin.userID"
static let jellyfinUserName = "halo.jellyfin.userName"
static let jellyfinServerName = "halo.jellyfin.serverName"
static let jellyfinIsAdministrator = "halo.jellyfin.isAdministrator"
}
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
///
/// Nicht in den Voreinstellungen: es gibt vollen Zugriff auf die
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
/// später daneben passt, ohne diesen zu verdrängen.
private static let jellyfinTokenAccount = "jellyfin.server"
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
///
/// **Diese Namen stehen sonst nirgends mehr.** Mit dem Plex-Code ging auch
/// das Einzige, was die beiden Token je wieder entfernt hätte — wer sich
/// nie abgemeldet hat, trüge ein gültiges plex.tv-Token bis in alle
/// Ewigkeit im Schlüsselbund, ohne dass irgendein Weg es noch löschen
/// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
/// verschwinden.
private enum Altlast {
static let merker = "halo.migration.plexPurged"
static let tokenKonten = ["plex.account", "plex.server"]
static let schlüssel = [
"halo.backend.kind",
"halo.server.clientIdentifier",
"halo.server.connectionURI",
"halo.server.name",
"halo.server.connectionIsLocal",
]
}
/// Ob der Server in **demselben** Netz steht wie wir.
///
/// Entscheidet, ob „Auto" gedeckelt anfängt: zuhause trägt die Leitung
/// das Original, unterwegs nicht. Der Server wird danach gefragt, weil
/// ein Blick auf die Adresse nicht genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
/// auch von der Couch aus unter einem öffentlichen Namen.
///
/// Im Zweifel `false`: lieber einmal zu vorsichtig gedeckelt als eine
/// Wiedergabe, die über eine Fernverbindung gar nicht erst anläuft.
private(set) var isLocalConnection = false
private(set) var phase: Phase = .launching
/// Der aktive Server hinter dem neutralen Vertrag.
///
/// Die Ansichten sprechen ausschließlich hierüber und sehen nicht, wer
/// dahintersteckt. Dass dieser Vertrag einmal zwei Server gleichzeitig
/// getragen hat, ist kein Zufall, sondern der Grund, warum für einen
/// weiteren keine einzige Ansicht neu geschrieben werden muss.
private(set) var backend: (any MediaBackend)?
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
var accountName: String? { jellyfinAccount?.userName }
/// Die Mediatheken, die Halo darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
private(set) var libraries: [MediaLibrary] = []
/// Wie der Server heißt, so wie er sich selbst nennt.
var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird.
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
/// Wie die Verbindung zum Server zu beschreiben ist.
///
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
/// die Sitzung. Nah oder fern ist dabei die Angabe, die etwas erklärt —
/// an ihr hängt, ob „Auto" gedeckelt anfängt, und damit die Hälfte aller
/// Fragen nach Rucklern. Die blosse Adresse steht schon eine Zeile höher.
var connectionDescription: String? {
guard let serverAddress else { return nil }
return isLocalConnection
? "Lokal · \(serverAddress)"
: "Von außen · \(serverAddress)"
}
/// Ob der aktive Server Live-TV anbietet.
///
/// Die Fähigkeit selbst, nicht der Umweg über die Serverart. Einen
/// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine
/// Zusage, die niemand einlöst — so gibt sie nur, wer sie auch einlöst.
var supportsLiveTV: Bool { backend?.capabilities.contains(.liveTV) == true }
/// Ob er darüber hinaus aufnehmen kann.
///
/// Getrennt gefragt: ein Server kann empfangen, ohne aufnehmen zu können.
var supportsRecordings: Bool { backend?.capabilities.contains(.liveTVRecordings) == true }
/// Der Server als Live-TV-Server — sofern er einer ist.
var liveTV: (any LiveTVBackend)? { backend as? any LiveTVBackend }
/// Der Server als Aufnahme-Server — sofern er einer ist.
var recorder: (any LiveTVRecordingBackend)? { backend as? any LiveTVRecordingBackend }
/// Warum gerade nicht abgespielt werden kann.
///
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
///
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
var playbackUnavailableMessage: String {
backend != nil
? String(localized: "player.jellyfinPending")
: String(localized: "error.serverUnreachable")
}
private let jellyfinIdentity: JellyfinIdentity
private let tokenStore: any TokenStoring
private let defaults: UserDefaults
init(
jellyfinIdentity: JellyfinIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.jellyfinIdentity = jellyfinIdentity
self.tokenStore = tokenStore
self.defaults = defaults
}
// MARK: - Start
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
func restore() async {
purgePlexLeftovers()
#if DEBUG
if await adoptStubServerIfRequested() { return }
#endif
if await restoreJellyfin() { return }
phase = .signedOut
}
/// Räumt einmal weg, was die Plex-Zeit hinterlassen hat.
///
/// **Der einzige Weg, auf dem das je wieder passiert.** Vorher entfernte
/// `signOut()` die Plex-Token über den Authenticator; den gibt es nicht
/// mehr, und `plex.account` stünde sonst in keiner Zeile dieses Projekts.
/// Wer bis heute angemeldet war und nie auf „Abmelden" gedrückt hat,
/// behielte ein gültiges Token am fremden Konto — unsichtbar und
/// unlöschbar.
///
/// Der Merker wird **vor** der Prüfung gesetzt: ein Aufräumen, das
/// scheitert, soll nicht bei jedem Start wiederkehren.
private func purgePlexLeftovers() {
guard !defaults.bool(forKey: Altlast.merker) else { return }
defaults.set(true, forKey: Altlast.merker)
// Nur anfassen, wo auch etwas liegt. Sonst verlöre jeder
// Jellyfin-Nutzer beim ersten Start seinen Mediatheken-Speicher für
// nichts.
let hattePlex = Altlast.tokenKonten.contains { tokenStore.token(for: $0) != nil }
|| Altlast.schlüssel.contains { defaults.object(forKey: $0) != nil }
guard hattePlex else { return }
for konto in Altlast.tokenKonten { tokenStore.remove(for: konto) }
for schlüssel in Altlast.schlüssel { defaults.removeObject(forKey: schlüssel) }
// Titel und Sehfortschritt gehörten zu einem Konto, das es hier nicht
// mehr gibt. Gelesen würden sie ohnehin nie wieder.
Task { await LibraryCache.shared.clearAll() }
}
/// Meldet ab und räumt dabei alles ab, was zur Anmeldung gehörte.
///
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
func signOut() {
forgetJellyfin()
clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
// verloren.
Task { await LibraryCache.shared.clearAll() }
phase = .signedOut
}
// MARK: - Verbinden mit Jellyfin
/// Übernimmt eine fertige Jellyfin-Anmeldung.
///
/// Die Anmeldung selbst führt `JellyfinSignInModel` — sie besteht aus
/// 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:
/// `-HaloStubServer 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: "HaloStubServer"),
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")
jellyfinAccount = account
self.backend = backend
do {
libraries = try await loadLibraries(from: backend)
phase = .ready
} catch {
phase = .failed(message: ErrorText.describe(error))
}
return true
}
#endif
func adoptJellyfin(
client: JellyfinClient,
account: JellyfinAccount,
serverName: String
) async {
phase = .connecting(serverName: serverName)
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
self.backend = backend
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
} catch {
self.backend = nil
jellyfinAccount = nil
phase = .failed(message: ErrorText.describe(error))
return
}
rememberJellyfin(account: account, serverName: serverName, baseURL: client.baseURL)
phase = .ready
}
/// Nimmt eine gespeicherte Jellyfin-Anmeldung wieder auf.
///
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Token nicht
/// mehr gilt — dann steht die Anmeldung wieder am Anfang. Ist der Server
/// bloß gerade nicht erreichbar, bleibt die Anmeldung dagegen bestehen und
/// es gibt einen Fehler mit „Erneut versuchen": auf dem Weg zur Arbeit ist
/// jeder Server unerreichbar, und das ist kein Grund, jemanden abzumelden.
private func restoreJellyfin() async -> Bool {
guard let address = defaults.string(forKey: Key.jellyfinAddress),
let baseURL = URL(string: address),
let userID = defaults.string(forKey: Key.jellyfinUserID),
let token = tokenStore.token(for: Self.jellyfinTokenAccount)
else { return false }
let serverName = defaults.string(forKey: Key.jellyfinServerName) ?? ""
phase = .connecting(serverName: serverName)
let account = JellyfinAccount(
userID: userID,
userName: defaults.string(forKey: Key.jellyfinUserName) ?? "",
token: token,
isAdministrator: defaults.bool(forKey: Key.jellyfinIsAdministrator)
)
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: token)
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
phase = .ready
return true
} catch MediaBackendError.unauthorized {
// Der Token wurde am Server zurückgezogen. Ihn zu behalten würde
// bei jedem Start denselben Fehler erzeugen.
forgetJellyfin()
clearActiveServer()
return false
} catch {
phase = .failed(message: ErrorText.describe(error))
return true
}
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName)
defaults.set(serverName, forKey: Key.jellyfinServerName)
defaults.set(account.isAdministrator, forKey: Key.jellyfinIsAdministrator)
try? tokenStore.store(account.token, for: Self.jellyfinTokenAccount)
}
private func forgetJellyfin() {
for key in [Key.jellyfinAddress, Key.jellyfinUserID, Key.jellyfinUserName,
Key.jellyfinServerName, Key.jellyfinIsAdministrator] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.jellyfinTokenAccount)
}
// MARK: - Der aktive Server
/// Holt die Mediathekenliste und merkt sie sich.
///
/// **Der Speicher ist hier nur ein Rettungsanker**, kein Beschleuniger:
/// die Liste ist eine einzige kleine Anfrage, und sie sofort zu zeigen
/// hiesse, eine Sitzung für fertig zu erklären, bevor feststeht, dass der
/// Token noch gilt. Antwortet der Server dagegen **nicht**, ist eine
/// Seitenleiste von gestern besser als eine leere — im Zug ist das der
/// Unterschied zwischen „das Heruntergeladene ansehen" und „gar nichts".
private func loadLibraries(from backend: any MediaBackend) async throws -> [MediaLibrary] {
let kennung = backend.identity
do {
let geladen = try await backend.libraries().filter(\.isSupported)
await LibraryCache.shared.storeLibraries(geladen, server: kennung)
return geladen
} catch {
guard let gemerkt = await LibraryCache.shared.libraries(server: kennung),
!gemerkt.isEmpty
else { throw error }
return gemerkt
}
}
private func clearActiveServer() {
backend = nil
jellyfinAccount = nil
libraries = []
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
if await restoreJellyfin() { return }
phase = .signedOut
}
/// Die Kennung für Jellyfin-Anmeldungen.
///
/// Der Anmeldebildschirm baut sich seinen eigenen Client, bevor es eine
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
}
+120
View File
@@ -0,0 +1,120 @@
import Foundation
import HaloDesign
import MediaKit
/// Woher der Aufmacher seine Bilder nimmt.
///
/// **Liegt bei den Modellen, obwohl der Name nach Ansicht klingt.** Hier steht
/// keine Zeile SwiftUI, nur die Frage, welche Adresse in welcher Grösse
/// angefordert wird — und die beantwortet der Fernseher genauso wie das
/// Telefon.
///
/// An **einer** Stelle, weil zwei Stellen es schon zweimal falsch gemacht
/// haben:
///
/// Erstens wurde das Hintergrundbild in 900×1200 angefordert — hochkant — und
/// dann in einem 16:9-Rahmen gezeigt. Zu sehen war davon ein schmaler Streifen
/// aus der Mitte; vom Bild fehlte oben und unten je ein Drittel. Die Maße
/// richten sich jetzt nach dem Rahmen, in dem das Bild landet.
///
/// Zweitens holt das Karussell das nächste Bild vor, solange das aktuelle noch
/// steht. Weicht dessen Adresse auch nur in einer Zahl ab, ist es eine andere
/// Adresse, der Zwischenspeicher trifft daneben und der Aufmacher blendet auf
/// eine graue Fläche über.
enum HeroArtwork {
/// Wofür das Bild gebraucht wird.
///
/// **Auf einem breiten Bildschirm macht das keinen Unterschied** — dort
/// ist beides ein 16:9-Hintergrundbild über die ganze Breite. Schmal
/// gehen die beiden auseinander, und zwar aus einem inhaltlichen Grund:
///
/// Im **Karussell** sucht man noch. Dort soll eine Szene stehen, kein
/// Plakat: das Plakat trägt den Titel selbst, und darüber liegt noch der
/// freigestellte Schriftzug — derselbe Name zweimal.
///
/// Auf einer **Detailseite** hat man sich entschieden. Dort ist das
/// Plakat das Bild dieses Films, und der Kopf der Seite darf die Höhe
/// nehmen, die ein Plakat braucht.
enum Placement {
/// Der Aufmacher auf der Startseite.
case carousel
/// Der Kopf einer Detailseite.
case detailHeader
}
/// Ob der Rahmen hochkant ist.
///
/// Die eine Stelle, an der die Entscheidung fällt — Maße, Verhältnis und
/// Reihenfolge der Bildpfade hängen alle daran, und sie müssen
/// zusammenpassen.
private static func isPortrait(_ placement: Placement, compact: Bool) -> Bool {
compact && placement == .detailHeader
}
/// Die Maße, in denen das Bild angefordert wird.
///
/// Im Seitenverhältnis des Rahmens, in dem es landet — sonst schneidet der
/// Server zu, und man sieht einen Streifen aus der Mitte.
static func size(_ placement: Placement = .carousel, compact: Bool) -> (width: Int, height: Int) {
if isPortrait(placement, compact: compact) { return (600, 900) }
// Schmal landet das Querbild in einem 4:3-Rahmen von höchstens 440
// Punkten Breite — bei dreifacher Auflösung rund 1320 Bildpunkte.
// Zwölfhundert ist genug und spart gegenüber 1920 ein Drittel.
return compact ? (1200, 900) : (1920, 1080)
}
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
if isPortrait(placement, compact: compact) { return HaloAspectRatio.poster }
return compact ? HaloAspectRatio.heroCompact : HaloAspectRatio.hero
}
static func backdropURL(
for item: MediaItem,
backend: any MediaBackend,
placement: Placement = .carousel,
compact: Bool
) -> URL? {
backdropURLs(for: item, backend: backend, placement: placement, 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,
placement: Placement = .carousel,
compact: Bool
) -> [URL] {
let size = size(placement, compact: compact)
let pfade: [String?] = isPortrait(placement, compact: 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.
///
/// Er ist ein PNG mit durchsichtigem Grund, und der Transcoder liefert
/// JPEG. Aus dem Schriftzug würde sonst ein schwarzer Kasten.
static func logoURL(for item: MediaItem, backend: any MediaBackend) -> URL? {
backend.rawImageURL(path: item.clearLogoPath)
}
}
+165
View File
@@ -0,0 +1,165 @@
import MediaKit
import Observation
/// Die Startseite als Modell: laden, ordnen, auswählen.
///
/// **Warum das nicht mehr in `HomeView` steht.** Der Fernseher zeigt dieselben
/// Reihen wie das Telefon, nur ganz anders — und ohne diesen Schnitt hiesse
/// „auch auf tvOS" schlicht: hundert Zeilen kopieren. Kopiert wäre dabei
/// ausgerechnet der Teil, an dem schon mehrere Fehler steckten: die Reihenfolge
/// des Aufmachers, das Zusammenlegen der Weiterschauen-Reihen, das Ausblenden
/// von Gesehenem. Einer davon — das Los des Aufmachers hing an der Position in
/// der Liste statt am Titel — wäre in einer Kopie ein zweites Mal zu finden
/// gewesen.
///
/// **Die Naht verläuft zwischen Laden und Zeigen.** Hier steht, *was* auf der
/// Startseite steht; wie es aussieht, entscheidet jede Plattform für sich.
@MainActor
@Observable
final class HomeFeed {
private(set) var state: LoadState<[MediaRowGroup]> = .loading
/// Holt die Reihen vom Server.
///
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
/// Muster wie bei `QuickPlay.start`: so bleibt der Typ ohne Abhängigkeiten
/// baubar, und ein Serverwechsel veraltet hier nichts.
func load(from backend: (any MediaBackend)?) async {
guard let backend else { return }
state = await LoadState.load(keepingPrevious: state) {
try await backend.homeRows()
}
#if os(tvOS)
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
if case .loaded(let reihen) = state {
TopShelfWriter.update(rows: reihen, backend: backend)
}
#endif
}
/// Die Reihen in der eingestellten Form.
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
guard case .loaded(let alle) = state else { return [] }
return layout.apply(to: prepared(alle, layout: layout))
}
// MARK: - Ordnen
/// Bringt die Reihen vom Server in die Form, die eingestellt ist.
///
/// Hier und nicht im Backend: „Weiterschauen und Als Nächstes zusammen"
/// und „Gesehenes ausblenden" sind Fragen der Darstellung, keine des
/// Servers. Beides hier zu erledigen hält den Vertrag schmal und gilt für
/// Plex und Jellyfin gleichermaßen.
private func prepared(_ rows: [MediaRowGroup], layout: HomeLayoutSettings) -> [MediaRowGroup] {
var rows = rows
if layout.hidesWatchedInLatest {
rows = rows.map { row in
guard row.id.hasPrefix("latest") else { return row }
return MediaRowGroup(
id: row.id,
title: row.title,
moreKey: row.moreKey,
// **Die Mediathek muss mit.** Sie fehlte hier, und das
// war teuer: dieser Zweig baut genau die Reihen neu, die
// als einzige eine tragen — und der Aufmacher wirft seit
// Neuestem jede Reihe ohne Mediathek weg, sobald eine
// ausgewählt ist. „Gesehenes ausblenden" und „Mediathek
// für den Aufmacher" zusammen ergaben deshalb gar keinen
// Aufmacher mehr. Beide Schalter stehen im selben
// Bildschirm untereinander.
libraryID: row.libraryID,
items: row.items.filter { !$0.isWatched }
)
}
}
if layout.combinesContinueRows {
rows = combiningContinueRows(rows)
}
return rows.filter { !$0.items.isEmpty }
}
/// Legt „Weiterschauen" und „Als Nächstes" in eine Reihe.
///
/// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre
/// sie für die gespeicherte Reihenfolge eine neue und stünde plötzlich
/// wieder ganz unten.
private func combiningContinueRows(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
let continuing = rows.filter(\.isContinueWatching)
guard continuing.count > 1, let first = continuing.first else { return rows }
var seen = Set<String>()
let merged = continuing.flatMap(\.items).filter { seen.insert($0.id).inserted }
var result: [MediaRowGroup] = []
for row in rows {
guard row.isContinueWatching else {
result.append(row)
continue
}
guard row.id == first.id else { continue }
// `moreKey` und `libraryID` wie oben: was die Reihe mitbrachte,
// soll sie behalten. Ohne `moreKey` fiel bei zusammengelegten
// Reihen „Alle anzeigen" weg — ein eigener, älterer Fehler.
result.append(MediaRowGroup(
id: row.id,
title: row.title,
moreKey: row.moreKey,
libraryID: row.libraryID,
items: merged
))
}
return result
}
// MARK: - Der Aufmacher
/// Was oben groß gezeigt wird.
///
/// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen.
/// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen
/// Folge gäbe es sonst nichts zu blättern. Wer den Aufmacher lieber als
/// Schaufenster versteht, stellt auf Neuzugänge um.
func heroItems(for layout: HomeLayoutSettings) -> [MediaItem] {
// **Nur hier, nicht an den Reihen selbst.** Eine abgewählte Mediathek
// verschwindet nicht von der Startseite — sie soll nur nicht mehr
// oben gross stehen. Wer sie ganz loswerden will, blendet die Reihe
// aus; das ist eine andere Frage und hat ihren eigenen Schalter.
let hubs = HeroSelection.rows(rows(for: layout), limitedTo: layout.heroLibraries)
let candidates: [MediaItem]
switch layout.heroSource {
case .continueFirst:
let continuing = hubs.first(where: \.isContinueWatching)?.items ?? []
let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items)
candidates = continuing + rest
case .latestOnly:
candidates = hubs.filter { $0.id.hasPrefix("latest") }.flatMap(\.items)
}
var seen = Set<String>()
let unique = candidates.filter { seen.insert($0.id).inserted }
// **Gemischt, aber nicht bei jedem Blick.**
//
// 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 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(HeroSelection.ordered(unique).prefix(layout.heroCount))
}
}
}
+162
View File
@@ -0,0 +1,162 @@
import Foundation
import Observation
import MediaKit
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
///
/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht
/// `Arrangement` in MediaKit — dort ist es reine Rechnung und ohne Simulator
/// prüfbar, und genau dort liegen die kniffligen Fälle: neue Reihen vom
/// Server, verschwundene aus der gespeicherten Liste.
@MainActor
@Observable
final class HomeLayoutSettings {
private enum Key {
static let hidden = "halo.home.hiddenHubs"
static let order = "halo.home.hubOrder"
static let heroCount = "halo.home.heroCount"
static let heroSource = "halo.home.heroSource"
static let heroLibraries = "halo.home.heroLibraries"
static let combinesContinueRows = "halo.home.combinesContinueRows"
static let hidesWatchedInLatest = "halo.home.hidesWatchedInLatest"
static let cardScale = "halo.home.cardScale"
static let heroHeight = "halo.home.heroHeight"
}
/// Woraus sich der Aufmacher speist.
enum HeroSource: String, CaseIterable, Identifiable, Sendable {
/// Angefangenes zuerst, danach aufgefüllt.
case continueFirst
/// Nur Neuzugänge — für den, der den Aufmacher als Schaufenster
/// versteht und nicht als Fortsetzung.
case latestOnly
var id: String { rawValue }
}
/// Wie viele Titel der Aufmacher durchwechselt.
static let heroCountChoices = [1, 3, 5, 7, 10]
/// Die Grenzen der Kachelgröße.
///
/// Nach unten, wo ein Plakat noch erkennbar ist; nach oben, wo auf einem
/// iPad noch mehr als zwei nebeneinander passen — sonst ist es keine Reihe
/// mehr, sondern eine Abfolge.
static let cardScaleRange: ClosedRange<Double> = 0.7...1.8
/// Wie viel vom Bildschirm der Aufmacher einnehmen darf.
///
/// Nach unten bleibt er ein breites Vorschaubild, nach oben verdeckt er
/// die erste Reihe ganz — und dann weiß niemand, dass es weitergeht.
static let heroHeightRange: ClosedRange<Double> = 0.4...0.95
private let defaults: UserDefaults
private(set) var arrangement: Arrangement
var heroCount: Int { didSet { defaults.set(heroCount, forKey: Key.heroCount) } }
var heroSource: HeroSource { didSet { defaults.set(heroSource.rawValue, forKey: Key.heroSource) } }
/// Aus welchen Mediatheken der Aufmacher sich speist.
///
/// **Leer heisst alle** — siehe `HeroSelection.rows(_:limitedTo:)`. Eine
/// Auswahl, bei der nichts angekreuzt ist, darf nicht in einen leeren
/// Aufmacher führen.
///
/// Als `Set` und nicht als eine einzelne Kennung: wer Filme und Serien
/// will, aber keine Musikvideos, hat zwei Antworten und nicht eine.
var heroLibraries: Set<String> {
didSet { defaults.set(Array(heroLibraries), forKey: Key.heroLibraries) }
}
/// Ob „Weiterschauen" und „Als Nächstes" eine Reihe sind.
///
/// Getrennt ist genauer, zusammen ist kürzer. Beides ist vertretbar, und
/// welches richtig ist, hängt davon ab, wie viele Serien jemand parallel
/// schaut.
var combinesContinueRows: Bool { didSet { defaults.set(combinesContinueRows, forKey: Key.combinesContinueRows) } }
/// Ob Gesehenes aus „Zuletzt hinzugefügt" verschwindet.
var hidesWatchedInLatest: Bool { didSet { defaults.set(hidesWatchedInLatest, forKey: Key.hidesWatchedInLatest) } }
/// Wie groß Plakate und Vorschaubilder erscheinen, als Faktor auf die
/// gestalterisch festgelegten Breiten.
///
/// Ein Faktor und keine Punktzahl: die Breiten von Plakat und Querformat
/// stehen in einem Verhältnis zueinander, das stimmen soll. Zwei getrennte
/// Zahlen ließen sich gegeneinander verstellen, bis eine Reihe zur anderen
/// nicht mehr passt.
///
/// **Pro Gerät**, nicht über den Server: am Fernseher will man größere
/// Kacheln als am Laptop, und das eine soll das andere nicht umstellen.
var cardScale: Double { didSet { defaults.set(cardScale, forKey: Key.cardScale) } }
/// Welchen Anteil der sichtbaren Höhe der Aufmacher einnimmt.
var heroHeight: Double { didSet { defaults.set(heroHeight, forKey: Key.heroHeight) } }
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.arrangement = Arrangement(
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
order: defaults.stringArray(forKey: Key.order) ?? []
)
self.heroCount = defaults.object(forKey: Key.heroCount) as? Int ?? 5
self.heroSource = defaults.string(forKey: Key.heroSource)
.flatMap(HeroSource.init(rawValue:)) ?? .continueFirst
self.heroLibraries = Set(defaults.stringArray(forKey: Key.heroLibraries) ?? [])
self.combinesContinueRows = defaults.object(forKey: Key.combinesContinueRows) as? Bool ?? false
self.hidesWatchedInLatest = defaults.object(forKey: Key.hidesWatchedInLatest) as? Bool ?? false
// Die Voreinstellungen sind genau das bisherige feste Verhalten: wer
// nichts anfasst, merkt von der neuen Einstellung nichts.
self.cardScale = (defaults.object(forKey: Key.cardScale) as? Double)
.map { $0.clamped(to: Self.cardScaleRange) } ?? 1.0
self.heroHeight = (defaults.object(forKey: Key.heroHeight) as? Double)
.map { $0.clamped(to: Self.heroHeightRange) } ?? 0.82
}
func isHidden(_ identifier: String) -> Bool {
arrangement.hidden.contains(identifier)
}
func setHidden(_ hidden: Bool, for identifier: String) {
if hidden {
arrangement.hidden.insert(identifier)
} else {
arrangement.hidden.remove(identifier)
}
defaults.set(Array(arrangement.hidden), forKey: Key.hidden)
}
func setOrder(_ identifiers: [String]) {
arrangement.order = identifiers
defaults.set(identifiers, forKey: Key.order)
}
func apply(to rows: [MediaRowGroup]) -> [MediaRowGroup] {
arrangement.apply(to: rows)
}
/// Die Zeilen für den Einstellungsbildschirm.
func rows(from rows: [MediaRowGroup]) -> [HomeRow] {
arrangement.arrangedIdentifiers(from: rows).compactMap { identifier in
guard let row = rows.first(where: { $0.id == identifier }) else { return nil }
return HomeRow(id: identifier, title: row.title, isHidden: isHidden(identifier))
}
}
}
/// Eine Reihe im Einstellungsbildschirm.
struct HomeRow: Identifiable, Hashable {
let id: String
let title: String
var isHidden: Bool
}
extension Comparable {
/// Auf einen Bereich begrenzt.
///
/// Gespeicherte Werte werden beim Lesen geprüft, nicht blind übernommen:
/// eine ältere Fassung mit anderen Grenzen — oder ein von Hand
/// verändertes Voreinstellungs-Sheet — soll nicht zu einer Kachel führen,
/// die den halben Bildschirm einnimmt.
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
+248
View File
@@ -0,0 +1,248 @@
import Foundation
import JellyfinKit
import MediaKit
import Observation
/// Führt durch die Anmeldung an einem Jellyfin-Server.
///
/// Eigenes Modell statt eines weiteren Falls in `HaloSession.Phase`: die
/// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse
/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort
/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am
/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin.
///
/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits
/// angemeldeten Client bestätigt; das Passwort erreicht Halo dabei nie —
/// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt.
/// Benutzername und Passwort sind der Rückfall für Server, auf denen Quick
/// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer
/// einer Anfrage und wirft es danach weg.
@MainActor
@Observable
final class JellyfinSignInModel {
enum Step: Equatable {
/// Die Adresse wird eingetippt.
case address
/// Die Adresse wird durchprobiert.
case checking
/// Der Code steht, jetzt wird auf die Bestätigung gewartet.
case quickConnect(code: String)
/// Der Rückfall: Benutzername und Passwort.
case credentials
case signingIn
}
/// Welcher Weg nach dem Fund zuerst angeboten wird.
///
/// **Am Telefon und am Fernseher verschieden herum.** Dort füllt ein
/// Passwortspeicher beide Felder in einer Sekunde; hier tippt man sie mit
/// einem Steuerkreuz, Buchstabe für Buchstabe, und der Code ist der
/// bequemere Weg. Welcher Fall gilt, weiß die Oberfläche — dieses Modell
/// nicht. Deshalb ein Wert im Initialisierer statt einer
/// Plattformbedingung mitten in `findServer()`.
enum PreferredMethod { case credentials, quickConnect }
var address = ""
var userName = ""
var password = ""
private(set) var step: Step = .address
private(set) var errorMessage: String?
/// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse
/// stimmt, noch bevor jemand Anmeldedaten eintippt.
private(set) var serverName: String?
/// Ob der Server die Anmeldung per Code überhaupt anbietet.
private(set) var offersQuickConnect = false
/// Wird gerufen, sobald ein Konto feststeht.
///
/// Der Client kommt mit: er trägt den Token und die Gerätekennung bereits
/// und wird von der Sitzung unverändert weiterverwendet.
var onAuthenticated: ((JellyfinClient, JellyfinAccount, String) -> Void)?
private let identity: JellyfinIdentity
private let preferred: PreferredMethod
private var client: JellyfinClient?
private var authenticator: JellyfinAuthenticator?
private var pollTask: Task<Void, Never>?
/// Der Vorgabewert hält iOS unverändert.
init(identity: JellyfinIdentity, preferring: PreferredMethod = .credentials) {
self.identity = identity
self.preferred = preferring
}
// MARK: - Schritt 1: den Server finden
/// Probiert die plausiblen Ergänzungen der eingetippten Adresse durch.
///
/// Nicht eine raten, sondern der Reihe nach fragen: „192.168.1.50" kann
/// alles Mögliche heißen, und wer die Adresse vollständig eintippen muss,
/// tippt sie dreimal falsch.
func findServer() async {
pollTask?.cancel()
errorMessage = nil
let candidates = JellyfinAddress.candidates(for: address)
guard !candidates.isEmpty else {
errorMessage = String(localized: "signIn.jellyfin.addressMissing")
return
}
step = .checking
for candidate in candidates {
let client = JellyfinClient(baseURL: candidate, identity: identity)
let authenticator = JellyfinAuthenticator(client: client)
guard let info = try? await authenticator.serverInfo() else { continue }
self.client = client
self.authenticator = authenticator
serverName = info.serverName
address = candidate.absoluteString
offersQuickConnect = await authenticator.supportsQuickConnect()
// **Am Telefon Name und Passwort zuerst, am Fernseher der Code.**
//
// Hier sprang der Weg früher immer von selbst in den Code-Fluss,
// sobald der Server ihn anbietet. Das war gut gemeint — dabei
// läuft kein Passwort durch diese App —, geht am Telefon aber am
// Alltag vorbei: der Code muss in einem zweiten Gerät bestätigt
// werden, und wer gerade im Zug sitzt, hat das nicht offen. Ein
// Passwortspeicher füllt dagegen beide Felder in einer Sekunde.
//
// Vor einem Fernseher stimmt jedes Wort davon nicht mehr: dort
// gibt es keinen Passwortspeicher, dort tippt man mit dem
// Steuerkreuz, und das zweite Gerät liegt sowieso auf dem Sofa.
//
// Beide Wege bleiben erreichbar: `offersQuickConnect` steuert den
// Knopf darunter, und `useCredentials()` führt zurück.
if preferred == .quickConnect, offersQuickConnect {
await beginQuickConnect()
return
}
step = .credentials
return
}
step = .address
errorMessage = String(localized: "signIn.jellyfin.notFound")
}
// MARK: - Schritt 2a: Code bestätigen lassen
func beginQuickConnect() async {
guard let authenticator else { return }
errorMessage = nil
step = .checking
do {
let request = try await authenticator.beginQuickConnect()
step = .quickConnect(code: request.code)
startPolling(secret: request.secret, authenticator: authenticator)
} catch {
// Quick Connect abgeschaltet oder gestört: dann eben der andere
// Weg, statt hier stehen zu bleiben.
offersQuickConnect = false
step = .credentials
errorMessage = ErrorText.describe(error)
}
}
/// Fragt im Zweisekundentakt, ob der Code bestätigt wurde.
///
/// Fünf Minuten lang — so lange gilt ein Code bei Jellyfin in der
/// Voreinstellung. Danach wird nicht weiter gefragt, sondern gesagt, dass
/// er abgelaufen ist; eine Schleife, die ewig läuft, sieht aus wie eine
/// App, die hängt.
private func startPolling(secret: String, authenticator: JellyfinAuthenticator) {
pollTask?.cancel()
pollTask = Task { [weak self] in
for _ in 0..<150 {
try? await Task.sleep(for: .seconds(2))
if Task.isCancelled { return }
do {
if let account = try await authenticator.completeQuickConnect(secret: secret) {
self?.finish(with: account)
return
}
} catch MediaBackendError.notFound {
// Jellyfin kennt den Code nicht mehr: abgelaufen oder
// abgelehnt.
self?.expireQuickConnect()
return
} catch {
self?.errorMessage = ErrorText.describe(error)
return
}
}
self?.expireQuickConnect()
}
}
private func expireQuickConnect() {
errorMessage = String(localized: "signIn.jellyfin.codeExpired")
step = .credentials
}
// MARK: - Schritt 2b: Benutzername und Passwort
func signInWithPassword() async {
guard let authenticator else { return }
pollTask?.cancel()
errorMessage = nil
step = .signingIn
let secret = password
// Sofort aus dem Feld nehmen. Es wird nur noch für diese eine Anfrage
// gebraucht, und was nicht mehr dasteht, kann auch nicht in einem
// Bildschirmfoto landen.
password = ""
do {
let account = try await authenticator.signIn(userName: userName, password: secret)
finish(with: account)
} catch {
errorMessage = ErrorText.describe(error)
step = .credentials
}
}
// MARK: - Ende
private func finish(with account: JellyfinAccount) {
pollTask?.cancel()
guard let client else { return }
password = ""
onAuthenticated?(client, account, serverName ?? address)
}
/// Von der Code-Anzeige auf Benutzername und Passwort wechseln.
///
/// Der Weg ohne Passwort steht vorn, aber er ist kein Zwang: wer keinen
/// zweiten angemeldeten Client zur Hand hat, käme sonst nicht weiter.
func useCredentials() {
pollTask?.cancel()
errorMessage = nil
step = .credentials
}
/// Zurück zum Adressfeld — etwa nach einem Vertipper.
func startOver() {
pollTask?.cancel()
client = nil
authenticator = nil
serverName = nil
offersQuickConnect = false
password = ""
errorMessage = nil
step = .address
}
func cancel() {
pollTask?.cancel()
password = ""
}
}
+181
View File
@@ -0,0 +1,181 @@
import Foundation
import Observation
import MediaKit
import HaloPlayback
/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand
/// gesagt hat.
///
/// **Drei Ebenen, eine Frage.**
///
/// Serie → Mediathek → allgemein
///
/// Gefragt wird von unten nach oben; die erste Antwort gilt. Bei Filmen gibt
/// es die Serienebene nicht, dort endet es bei der Mediathek.
///
/// **Warum ein eigener Typ und nicht in `PlaybackSettings`.** Weil das hier
/// keine Einstellung ist, sondern ein kleines Regelwerk: drei Ebenen, eine
/// Rangliste darunter, und ein Gedächtnis, das sich ohne Nachfrage füllt. Das
/// neben Sprungweiten und Abspielgeschwindigkeit zu legen hiesse, beim Lesen
/// jedes Mal beides gleichzeitig im Kopf zu haben.
///
/// **Die Schlüssel der allgemeinen Sprachen bleiben die alten.** Wer sie
/// eingestellt hat, soll sie nach dem Umzug wiederfinden — ein Umzug, den
/// niemand bemerkt, ist ein guter Umzug.
@MainActor
@Observable
final class LanguagePreferences {
private enum Key {
/// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der
/// Besitzer gewechselt, nicht der Wert.
static let audio = "halo.playback.audioLanguage"
static let subtitles = "halo.playback.subtitleLanguage"
static let fallbacks = "halo.languages.fallbacks"
static let avoidsAudioDescription = "halo.languages.avoidsAudioDescription"
static let perLibrary = "halo.languages.library"
static let perSeries = "halo.languages.series"
static let rememberedTracks = "halo.languages.seriesTrack"
}
/// Die Sprachen, die zur Auswahl stehen.
///
/// Keine vollständige Liste aller Sprachen der Welt: ein Auswahlrad mit
/// zweihundert Einträgen, um „Deutsch" zu finden, ist keine Hilfe.
/// Schweizerdeutsch steht dabei, weil es in dieser Sammlung vorkommt und
/// **keine** Schreibweise von Deutsch ist — wer es will, muss es nennen
/// können.
static let offeredCodes = [
"de", "gsw", "en", "fr", "it", "es", "nl", "pt", "pl", "ru", "sv",
"da", "no", "fi", "tr", "ja", "ko", "zh", "cs", "hu",
]
private let defaults: UserDefaults
// MARK: - Allgemein
var audioLanguage: String {
didSet { defaults.set(audioLanguage, forKey: Key.audio) }
}
var subtitleLanguage: String {
didSet { defaults.set(subtitleLanguage, forKey: Key.subtitles) }
}
/// Die Rangliste für den Fall, dass die Wunschsprache fehlt.
///
/// **Nur einmal, für alles.** Sie ist keine dritte Einstellung je
/// Mediathek oder Serie: dort steht der *Wunsch*, die Rangliste ist der
/// Boden darunter und gilt überall gleich. Eine Reihenfolge je Mediathek
/// zu pflegen wäre Arbeit für einen Fall, den es nicht gibt.
var fallbacks: [String] {
didSet { defaults.set(fallbacks, forKey: Key.fallbacks) }
}
/// Ob Spuren mit Audiodeskription gemieden werden.
///
/// An, und das ist die richtige Vorgabe: wer Audiodeskription braucht,
/// weiss das und findet den Schalter. Wer sie nicht braucht, ahnt nicht
/// einmal, dass es sie gibt — und bekäme sie sonst untergeschoben, weil
/// sie in manchen Dateien als die deutsche Spur ausgezeichnet ist.
var avoidsAudioDescription: Bool {
didSet { defaults.set(avoidsAudioDescription, forKey: Key.avoidsAudioDescription) }
}
// MARK: - Je Mediathek und je Serie
private var perLibrary: [String: String] {
didSet { defaults.set(perLibrary, forKey: Key.perLibrary) }
}
private var perSeries: [String: String] {
didSet { defaults.set(perSeries, forKey: Key.perSeries) }
}
/// Was zuletzt **von Hand** für eine Serie gewählt wurde.
///
/// Keine Sprache, sondern eine Beschreibung der Spur. Das ist die
/// Kernentscheidung des ganzen Entwurfs: bei manchen Titeln ist die als
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
/// abzulegen hiesse dort, den Fehler festzuschreiben.
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
didSet {
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
defaults.set(daten, forKey: Key.rememberedTracks)
}
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.audioLanguage = defaults.string(forKey: Key.audio) ?? ""
self.subtitleLanguage = defaults.string(forKey: Key.subtitles) ?? ""
self.fallbacks = defaults.stringArray(forKey: Key.fallbacks) ?? []
// `object(forKey:)` statt `bool(forKey:)`: das gäbe für „nie gesetzt"
// ein `false` zurück, und die Vorgabe wäre damit still das Gegenteil
// der beabsichtigten.
self.avoidsAudioDescription =
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
} ?? [:]
}
// MARK: - Lesen und Setzen je Ebene
func audioLanguage(forLibrary id: String) -> String? { perLibrary[id] }
func setAudioLanguage(_ code: String?, forLibrary id: String) {
perLibrary[id] = code
}
func audioLanguage(forSeries id: String) -> String? { perSeries[id] }
func setAudioLanguage(_ code: String?, forSeries id: String) {
perSeries[id] = code
}
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
rememberedTracks[id]
}
/// Merkt sich, was jemand von Hand gewählt hat — ohne zu fragen.
///
/// **Ohne Nachfrage**, weil „für diese Serie merken?" bei jeder Umstellung
/// genau die Art Dialog ist, die man nach dem dritten Mal wegklickt.
/// Sichtbar wird es trotzdem: das Spurenblatt zeigt es an und lässt es
/// löschen. Eine automatische Handlung, die man nicht sehen kann, ist von
/// einem Fehler nicht zu unterscheiden.
func remember(_ stream: MediaStream, forSeries id: String) {
rememberedTracks[id] = AudioTrackChooser.TrackSignature(stream)
}
func forgetTrack(forSeries id: String) {
rememberedTracks[id] = nil
}
// MARK: - Was dabei herauskommt
/// Der Wunsch für einen bestimmten Titel.
///
/// Die Werte kommen von hier, die Rangfolge steht in
/// `AudioTrackChooser.resolve` — dort lässt sie sich prüfen.
func wish(seriesID: String?, libraryID: String?) -> AudioTrackChooser.Wish {
AudioTrackChooser.resolve(
series: seriesID.flatMap { perSeries[$0] },
library: libraryID.flatMap { perLibrary[$0] },
general: audioLanguage,
fallbacks: fallbacks,
remembered: seriesID.flatMap { rememberedTracks[$0] },
avoidsAudioDescription: avoidsAudioDescription
)
}
/// Ob überhaupt eine Mediathek ausschlaggebend sein kann.
///
/// Wer nie eine Sprache je Mediathek gesetzt hat — der Normalfall —, für
/// den braucht auch niemand herauszufinden, in welcher Mediathek ein Titel
/// liegt. Das spart bei jeder Wiedergabe eine Serveranfrage.
var usesLibraryLevel: Bool { !perLibrary.isEmpty }
}
+131
View File
@@ -0,0 +1,131 @@
import MediaKit
import Observation
/// Eine Mediathek als Modell: laden, nachladen, entdoppeln.
///
/// **Der teuerste Brocken, den man hätte kopieren können.** Hier stecken der
/// Abzug aus dem Zwischenspeicher, der Seitenversatz, die Entdoppelung gegen
/// Plex-Serverscans und die Sperre nach einem Fehlschlag — und an mindestens
/// zwei davon hat schon je ein Fehler geklebt, der sich als „es sind nicht
/// alle Filme drin" äusserte und sonst nichts. Ein zweites Mal wären sie in
/// einer Kopie zu finden gewesen.
///
/// **Was das Nachladen auslöst, steht hier nicht.** Am Telefon ist es das
/// Erscheinen einer Kachel, am Fernseher der Fokus. Die Regel selbst —
/// `PagingWindow` — ist ein reiner Typ mit Tests.
@MainActor
@Observable
final class LibraryFeed {
let library: MediaLibrary
private(set) var items: [MediaItem] = []
private(set) var state: LoadState<Void> = .loading
private(set) var page: MediaPage?
/// Ob der letzte Nachladeversuch misslungen ist.
///
/// Dann passiert von selbst nichts mehr, und es braucht einen Knopf: die
/// Kacheln, die das Nachladen auslösen, sind längst erschienen und
/// erscheinen kein zweites Mal.
private(set) var loadMoreFailed = false
private(set) var isLoadingMore = false
var query = MediaQuery()
init(library: MediaLibrary) {
self.library = library
}
/// Wie viele Platzhalter noch unter dem Geladenen stehen.
var pendingCount: Int {
guard let page else { return 0 }
return max(0, page.totalCount - items.count)
}
func reload(from backend: (any MediaBackend)?) async {
guard let server = backend else { return }
let schlüssel = query.cacheKey
let kennung = server.identity
// Nur wenn nichts dasteht. Beim Aktualisieren und beim Filterwechsel
// ist das Gezeigte aktueller als jeder Abzug.
if items.isEmpty,
let stand = await LibraryCache.shared.snapshot(
server: kennung, library: library.id, queryKey: schlüssel
) {
items = stand.items
page = MediaPage(items: stand.items, totalCount: stand.totalCount)
state = .loaded(())
} else if items.isEmpty {
state = .loading
}
do {
let erste = try await server.items(
inLibrary: library, query: query, offset: 0, limit: 60
)
items = erste.items
page = erste
loadMoreFailed = false
state = .loaded(())
await LibraryCache.shared.store(
LibraryCache.Snapshot(
queryKey: schlüssel, items: erste.items, totalCount: erste.totalCount
),
server: kennung,
library: library.id
)
} catch {
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
// sich als Übertragungsfehler, nicht als Abbruch.
if Task.isCancelled { return }
// Steht schon etwas da — aus dem Speicher oder von vorhin —, dann
// bleibt es. Den Fehler über eine brauchbare Liste zu legen nähme
// dem Nutzer etwas weg, statt ihm etwas zu sagen.
guard items.isEmpty else { return }
state = .failed(message: ErrorText.describe(error))
}
}
func loadMore(from backend: (any MediaBackend)?) async {
guard let server = backend, let page, !isLoadingMore,
items.count < page.totalCount
else { return }
isLoadingMore = true
loadMoreFailed = false
defer { isLoadingMore = false }
do {
let nächste = try await server.items(
inLibrary: library, query: query, offset: page.nextOffset, limit: 60
)
// **Anhängen, nie ersetzen.** Plex kann bei gleichzeitigem
// Serverscan denselben Titel auf zwei Seiten liefern; ohne diese
// Prüfung stünde er doppelt im Raster, und SwiftUI beschwert sich
// über doppelte Kennungen. Und auf dem Fernseher hinge daran noch
// mehr: würde `items` als Ganzes ersetzt, verlöre der Fokus mitten
// im Scrollen seinen Halt und spränge an den Anfang.
let bekannt = Set(items.map(\.id))
items.append(contentsOf: nächste.items.filter { !bekannt.contains($0.id) })
self.page = nächste
} catch {
// **Nicht `page = nil`.** Das stand hier einmal, und es beendete
// das Nachladen **endgültig**: eine einzige misslungene Anfrage
// auf einer wackeligen Leitung, und die Mediathek blieb für immer
// bei sechzig Titeln stehen — lautlos. Genau so sieht „es sind
// nicht alle Filme drin" aus.
loadMoreFailed = true
}
}
/// Ob der Eintrag an dieser Stelle nah genug am Ende liegt.
///
/// Die Regel steht in `PagingWindow`; hier steht nur, woher die Zahlen
/// kommen. **Was das auslöst**, entscheidet die Ansicht.
func shouldLoadMore(at index: Int, columns: Int) -> Bool {
guard !loadMoreFailed, !isLoadingMore, let page else { return false }
return PagingWindow(columns: columns)
.shouldLoad(focusedIndex: index, loaded: items.count, total: page.totalCount)
}
}
+99
View File
@@ -0,0 +1,99 @@
import MediaKit
/// Welcher Titel und welches Bild auf eine Kachel gehören.
///
/// **Hier und nicht bei einer Ansicht.** Das stand in `MediaRow.swift` — und
/// damit in einer Datei, die nur das Telefon übersetzt. Der Fernseher zeigt
/// dieselben Kacheln mit denselben Rückfallketten; sie dort noch einmal
/// hinzuschreiben hiesse, zwei Stellen gleich zu halten, an denen die Fehler
/// erst nach Wochen auffallen: ein Plakat, das bei einer Staffel grau bleibt,
/// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild.
///
/// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den
/// übrigen Modellen und nicht in HaloDesign, das `MediaItem` gar nicht kennt.
extension MediaItem {
/// Der Titel auf einer Kachel in einer Reihe.
///
/// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei
/// einer Staffel der Name der Serie. Die Einordnung — Serie, Staffel —
/// gehört darunter. „Staffel 10" allein als Überschrift sagt nichts
/// darüber, welche Serie gemeint ist.
var rowTitle: String {
switch kind {
case .episode: title
// Die Serie steht je nach Antwort mal unter `parentTitle`, mal unter
// `seriesTitle`. Fehlt beides, bleibt „Staffel 2" — besser als
// gar keine Beschriftung.
case .season: parentTitle ?? seriesTitle ?? title
default: title
}
}
/// Welches Bild auf die Querformat-Kachel gehört.
///
/// Bei Folgen ist `thumb` das Szenenbild der Folge — genau richtig. Bei
/// Filmen ist `thumb` das Hochkant-Plakat, das im Querformat beschnitten
/// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl.
var rowArtworkPath: String? {
switch kind {
case .episode: thumb ?? seriesThumb
// Eine Staffel hat bei Jellyfin oft gar kein eigenes Bild — sie leiht
// sich das der Serie. Ohne diesen Rückfall blieb „Nachtstreife,
// Staffel 7" eine graue Fläche, obwohl die Serie ein Plakat hat.
case .season: art ?? thumb ?? seriesArt ?? seriesThumb
default: art ?? thumb
}
}
/// Welches Bild auf die Hochkant-Kachel gehört.
///
/// Bei Folgen das Plakat der Serie statt des Szenenbilds: ein 16:9-Bild
/// hochkant beschnitten zeigt einen zufälligen Ausschnitt und ist zum
/// Wiedererkennen wertlos.
var posterArtworkPath: String? {
switch kind {
case .episode: seriesThumb ?? parentThumb ?? thumb
// Das eigene Staffelplakat zuerst: es zeigt die Staffel. Gibt es
// keines, ist das der Serie allemal besser als nichts.
case .season: thumb ?? seriesThumb
default: thumb
}
}
}
/// Laufzeiten als Text.
///
/// Aus derselben Ansichtsdatei hierher geholt wie die Kachel-Ableitungen
/// darüber: am Fernseher steht dieselbe Zeile unter demselben Aufmacher.
extension Duration {
/// Laufzeit als „2 Std 46 Min" bzw. „46 Min".
var runtimeLabel: String {
let totalMinutes = Int(components.seconds / 60)
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
if hours > 0 {
return String(localized: "runtime.hoursMinutes \(hours) \(minutes)")
}
return String(localized: "runtime.minutes \(minutes)")
}
}
extension MediaLibrary.Kind {
/// Das Symbol, an dem man eine Mediathek erkennt.
///
/// **Eine Zuordnung, nicht drei.** Dieselbe Entscheidung stand bisher an
/// drei Stellen — in der Seitenleiste, im Zwischenbildschirm und am
/// Navigationsziel —, und die dritte war falsch: sie gab jeder Mediathek
/// „film", auch einer mit Serien. Drei Kopien einer Zuordnung halten sich
/// nicht von allein gleich.
public var symbol: String {
switch self {
case .movie: "film"
case .show: "tv"
case .music: "music.note"
case .photo: "photo"
case .liveTV: "dot.radiowaves.left.and.right"
case .other: "rectangle.stack"
}
}
}
@@ -0,0 +1,64 @@
import Foundation
import Observation
import MediaKit
/// Welche Einträge die Navigation zeigt und in welcher Reihenfolge.
///
/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht
/// `Arrangement` in MediaKit — dieselbe Rechnung, die schon die Reihen der
/// Startseite ordnet, samt ihrer Tests. Dort liegen die kniffligen Fälle:
/// eine Mediathek, die neu auf dem Server auftaucht, und eine, die aus der
/// gespeicherten Reihenfolge verschwindet.
@MainActor
@Observable
final class NavigationLayoutSettings {
private enum Key {
static let hidden = "halo.navigation.hidden"
static let order = "halo.navigation.order"
}
private let defaults: UserDefaults
private(set) var arrangement: Arrangement
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
arrangement = Arrangement(
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
order: defaults.stringArray(forKey: Key.order) ?? []
)
}
func isHidden(_ id: String) -> Bool {
arrangement.hidden.contains(id)
}
func setHidden(_ hidden: Bool, for id: String) {
if hidden {
arrangement.hidden.insert(id)
} else {
arrangement.hidden.remove(id)
}
defaults.set(Array(arrangement.hidden), forKey: Key.hidden)
}
/// Legt die Reihenfolge neu fest.
///
/// Gespeichert wird die **vollständige** Liste, auch die ausgeblendeten
/// Einträge. Sonst rutschte ein Eintrag, den man aus- und wieder
/// einblendet, ans Ende — er stünde in der Reihenfolge nicht mehr drin.
func setOrder(_ ids: [String]) {
arrangement.order = ids
defaults.set(ids, forKey: Key.order)
}
/// Wendet Auswahl und Reihenfolge an.
func apply<Element: Arrangeable>(to elements: [Element]) -> [Element] {
arrangement.apply(to: elements)
}
/// Nur die Reihenfolge, ohne auszublenden — für den Bearbeiten-Modus, in
/// dem auch die ausgeblendeten Einträge zu sehen sein müssen.
func ordered<Element: Arrangeable>(_ elements: [Element]) -> [Element] {
Arrangement(hidden: [], order: arrangement.order).apply(to: elements)
}
}
+154
View File
@@ -0,0 +1,154 @@
import Foundation
import MediaPlayer
/// Meldet die laufende Wiedergabe beim System an — und nimmt von dort Befehle
/// entgegen.
///
/// Ohne diese Anmeldung sind die Medientasten der Tastatur wirkungslos, und
/// zwar lautlos: macOS schickt sie an das Programm, das sich als „läuft
/// gerade" gemeldet hat. Meldet sich keines, gewinnt irgendein anderes —
/// Musik, ein Browser-Tab, was zuletzt etwas abgespielt hat. Man drückt auf
/// Pause und pausiert etwas, das man gar nicht sieht.
///
/// Dieselbe Anmeldung trägt die Sperrbildschirm-Bedienung, das
/// Kontrollzentrum, die Knöpfe an Kopfhörern und die Auskunft in der
/// Menüleiste. Es ist **eine** Sache, nicht vier.
///
/// Eine Stelle für die ganze App, kein Wert je Player: das System kennt
/// ohnehin nur einen laufenden Titel. Läge die Anmeldung beim Modell, würde
/// ein zweiter Player dem ersten die Tasten wegnehmen und beim Beenden die
/// des anderen abräumen.
@MainActor
final class NowPlaying {
static let shared = NowPlaying()
/// Wofür wir uns angemeldet haben — zum Wiederabmelden.
///
/// `addTarget` gibt eine Marke zurück, und nur mit ihr lässt sich der
/// eigene Eintrag wieder lösen. Ohne diese Liste sammelten sich bei jedem
/// neuen Film weitere Empfänger an, und ein Tastendruck löste die
/// Wiedergabe fünfmal um.
private var registered: [(command: MPRemoteCommand, target: Any)] = []
fileprivate(set) weak var model: PlayerModel?
/// Wann zuletzt gemeldet wurde und mit welchem Tempo.
///
/// Die Stelle kommt viermal je Sekunde herein; sie jedes Mal zu melden
/// wäre umsonst, weil das System zwischen zwei Meldungen selbst
/// weiterzählt — es kennt ja das Tempo. Gemeldet wird deshalb nur, wenn
/// sich das Tempo ändert oder seit der letzten Meldung Zeit vergangen ist.
private var lastReport: (at: Date, rate: Double)?
private init() {}
// MARK: - An- und abmelden
func takeOver(_ model: PlayerModel) {
release()
self.model = model
let center = MPRemoteCommandCenter.shared()
add(center.togglePlayPauseCommand) { $0.togglePlayPause() }
add(center.playCommand) { if !$0.isPlaying { $0.togglePlayPause() } }
add(center.pauseCommand) { if $0.isPlaying || $0.isBuffering { $0.togglePlayPause() } }
// Die Spulweiten aus den Einstellungen, damit die Knöpfe im
// Kontrollzentrum dasselbe tun wie die im Player. Zwei Zahlen, die
// auseinanderlaufen, sind schlimmer als eine, die nicht gefällt.
center.skipBackwardCommand.preferredIntervals = [NSNumber(value: model.rewindSeconds)]
center.skipForwardCommand.preferredIntervals = [NSNumber(value: model.forwardSeconds)]
add(center.skipBackwardCommand) { $0.skip(by: .seconds(-$0.rewindSeconds)) }
add(center.skipForwardCommand) { $0.skip(by: .seconds($0.forwardSeconds)) }
// Das Ziehen am Balken im Kontrollzentrum. Die Stelle wird vor dem
// Sprung auf den Hauptakteur ausgelesen: das Ereignis selbst darf
// nicht mitreisen.
let position = center.changePlaybackPositionCommand
let target = position.addTarget { event in
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
let seconds = event.positionTime
Task { @MainActor in
guard let model = NowPlaying.shared.model else { return }
model.seek(to: .seconds(seconds))
NowPlaying.shared.update(from: model)
}
return .success
}
registered.append((position, target))
update(from: model)
}
/// Meldet ab — aber nur, wenn dieser Player noch der angemeldete ist.
///
/// Ohne die Prüfung räumte ein Player, der spät zu Ende geht, dem längst
/// gestarteten nächsten die Anmeldung weg: die Tasten wären tot, obwohl
/// etwas läuft.
func release(for model: PlayerModel? = nil) {
if let model, self.model !== model { return }
for (command, target) in registered { command.removeTarget(target) }
registered = []
self.model = nil
lastReport = nil
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
MPNowPlayingInfoCenter.default().playbackState = .stopped
}
// MARK: - Melden
func update(from model: PlayerModel) {
guard self.model === model else { return }
let rate = model.isPlaying ? 1.0 : 0.0
if let last = lastReport, last.rate == rate, Date.now.timeIntervalSince(last.at) < 2 {
return
}
lastReport = (Date.now, rate)
var info: [String: Any] = [
MPMediaItemPropertyTitle: model.title,
MPNowPlayingInfoPropertyPlaybackRate: rate,
MPNowPlayingInfoPropertyElapsedPlaybackTime: model.currentTime.seconds,
MPNowPlayingInfoPropertyIsLiveStream: model.isLive,
]
if let subtitle = model.subtitle {
info[MPMediaItemPropertyArtist] = subtitle
}
// Bei einem laufenden Sender keine Länge: es gibt kein Ende, und ein
// Balken, der auf eines zuläuft, wäre eine Behauptung.
if let duration = model.duration, !model.isLive {
info[MPMediaItemPropertyPlaybackDuration] = duration.seconds
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
MPNowPlayingInfoCenter.default().playbackState = model.isPlaying ? .playing : .paused
}
// MARK: - Kleinkram
/// Meldet sich für einen Befehl an und merkt sich die Marke dazu.
///
/// Die Handlung läuft über einen Sprung auf den Hauptakteur statt über
/// `assumeIsolated`: dass diese Rückrufe dort ankommen, ist nirgends
/// zugesagt, und dieselbe Annahme hat die App schon einmal
/// zum Absturz gebracht. Der Umweg kostet einen Durchlauf — unbemerkbar
/// neben dem Tastendruck, der ihn ausgelöst hat.
private func add(
_ command: MPRemoteCommand,
handle: @escaping @Sendable @MainActor (PlayerModel) -> Void
) {
command.isEnabled = true
let target = command.addTarget { [weak self] _ in
Task { @MainActor [weak self] in
guard let model = self?.model else { return }
handle(model)
self?.update(from: model)
}
return .success
}
registered.append((command, target))
}
}
+110
View File
@@ -0,0 +1,110 @@
#if os(iOS)
import SwiftUI
import UIKit
/// Bestimmt, in welche Richtungen sich die App drehen darf.
///
/// In reinem SwiftUI gibt es dafür keinen Weg: `requestGeometryUpdate` dreht
/// zwar, aber das System dreht sofort zurück, wenn der Bildschirm es nicht
/// auch erlaubt. Erlaubt wird es über
/// `application(_:supportedInterfaceOrientationsFor:)` — und das gibt es nur
/// am klassischen App-Delegaten. Diese Klasse ist die Brücke dazwischen.
@MainActor
final class OrientationLock {
static let shared = OrientationLock()
private(set) var mask: UIInterfaceOrientationMask = .portrait
private init() {
// Auf dem iPad bleibt alles erlaubt: dort ist das Querformat beim
// Blättern der Normalfall, nicht die Ausnahme.
if UIDevice.current.userInterfaceIdiom == .pad {
mask = .all
}
}
/// Dreht zum Start der Wiedergabe ins Querformat — und gibt danach frei.
///
/// **Gedreht wird einmal, gesperrt wird nicht.** Vorher blieb die Maske
/// auf `.landscape` stehen, und damit liess sich im Player nicht mehr ins
/// Hochformat drehen. Ein Film beginnt quer, weil er quer ist; wer danach
/// im Bett auf der Seite liegt, soll drehen dürfen.
func lockLandscape() {
guard UIDevice.current.userInterfaceIdiom == .phone else { return }
apply(.allButUpsideDown, rotatingTo: .landscape)
}
/// Gibt die Ausrichtung wieder frei und dreht zurück ins Hochformat.
func releaseLandscape() {
guard UIDevice.current.userInterfaceIdiom == .phone else { return }
apply(.portrait, rotatingTo: .portrait)
}
/// Dreht auf Knopfdruck, ohne die Freiheit danach zu nehmen.
func rotate(to target: UIInterfaceOrientationMask) {
guard UIDevice.current.userInterfaceIdiom == .phone else { return }
apply(.allButUpsideDown, rotatingTo: target)
}
private func apply(
_ newMask: UIInterfaceOrientationMask,
rotatingTo target: UIInterfaceOrientationMask
) {
mask = newMask
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive })
else { return }
// Erst die neue Erlaubnis bekanntgeben, dann drehen. In der anderen
// Reihenfolge lehnt das System die Drehung ab, weil die alte Maske
// noch gilt — und das sah aus wie „dreht sich nicht richtig".
scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
// **Mit Fehlerbehandler.** Bei eingeschalteter Ausrichtungssperre
// lehnt das System die Drehung ab. Ohne diesen Block wirft UIKit die
// Ablehnung in die Konsole und es sieht aus, als hinge die App —
// dabei ist es eine Einstellung des Nutzers und in Ordnung. Der Film
// läuft dann eben hochkant weiter.
scene.requestGeometryUpdate(.iOS(interfaceOrientations: target)) { fehler in
#if DEBUG
print("Drehung abgelehnt (vermutlich Ausrichtungssperre): \(fehler)")
#endif
}
}
}
/// Dreht beim Erscheinen ins Querformat und beim Verschwinden zurück.
struct LandscapeWhileVisible: ViewModifier {
/// Ob überhaupt gesperrt wird.
///
/// Für die allermeisten Filme ist das Querformat richtig — und für den,
/// der im Bett auf der Seite liegt, das Gegenteil davon.
let isEnabled: Bool
func body(content: Content) -> some View {
content
.onAppear { if isEnabled { OrientationLock.shared.lockLandscape() } }
.onDisappear { OrientationLock.shared.releaseLandscape() }
}
}
extension View {
/// Für den Vollbildplayer: Film ist Querformat.
func landscapeWhileVisible(_ isEnabled: Bool = true) -> some View {
modifier(LandscapeWhileVisible(isEnabled: isEnabled))
}
}
#else
import SwiftUI
/// Auf dem Mac dreht sich nichts.
///
/// Ein Fenster hat keine Ausrichtung, die sich sperren ließe — der Nutzer
/// bestimmt seine Größe. Die Stelle bleibt trotzdem bestehen, damit der
/// Player auf beiden Plattformen dieselbe Zeile schreiben kann.
extension View {
func landscapeWhileVisible(_ isEnabled: Bool = true) -> some View { self }
}
#endif
+165
View File
@@ -0,0 +1,165 @@
import Foundation
import Observation
import MediaKit
/// Was abgespielt werden soll.
struct PlaybackRequest: Identifiable, Hashable {
let id = UUID()
let ratingKey: String
/// Für die Titelzeile, solange die Metadaten noch geladen werden.
let title: String
/// Die zweite Zeile, wenn sie schon feststeht.
///
/// Bei Live-TV die laufende Sendung: sie steht nirgends nachzuschlagen —
/// ein Sender hat keine Metadaten in der Mediathek —, und ohne sie sieht
/// man im Player nur den Sendernamen und weiß nicht, was läuft.
var subtitle: String?
/// Wann die laufende Sendung endet.
///
/// Damit im Player nicht den ganzen Abend derselbe Name steht: ein Sender
/// läuft weiter, die Sendung wechselt. Ohne diese Zeit wüsste niemand,
/// wann die Angabe falsch wird.
var subtitleEndsAt: Date?
/// Welche Fassung, wenn ein Titel in mehreren vorliegt.
let mediaIndex: Int
/// Ab wo — aus dem gemeldeten Fortschritt.
let startTime: Duration
/// Eine bereits feststehende Adresse.
///
/// Für Live-TV. Ein Sender liegt nicht in der Mediathek, und der übliche
/// Weg — Kennung nachschlagen, Datei suchen — findet für ihn nichts.
/// Daran ist das Einschalten gescheitert: die Adresse war längst
/// berechnet und wurde weggeworfen.
var streamURL: URL?
/// Womit der Tuner nach dem Zusehen wieder freigegeben wird.
///
/// Bei vier Tunern ist der fünfte Sender sonst nicht mehr einzuschalten.
var liveSubscriptionKey: String?
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
/// Nimmt Abspielwünsche von überall entgegen.
///
/// Ohne diese Stelle bräuchte jeder Bildschirm, von dem aus sich etwas starten
/// lässt — Aufmacher, Reihe, Detailseite, Suche — seinen eigenen
/// Vollbild-Übergang. Vier Kopien desselben Ablaufs, die auseinanderdriften.
@MainActor
@Observable
final class PlaybackCoordinator {
/// Wie der Player gerade zu sehen ist.
///
/// Der Unterschied zwischen „klein" und „gar nicht" ist der Kern der
/// Sache: verkleinern darf die Wiedergabe **nicht** anhalten. Vorher gab
/// es diese Unterscheidung nicht — der Player war da oder weg, und mit
/// ihm die Engine, der Transcoder und beim Live-TV der belegte Tuner.
enum Presentation: Equatable {
case hidden
case mini
case full
}
private(set) var request: PlaybackRequest?
private(set) var presentation: Presentation = .hidden
func play(_ item: MediaItem, mediaIndex: Int = 0, resuming: Bool = true) {
play(PlaybackRequest(
ratingKey: item.id,
title: item.playbackTitle,
mediaIndex: mediaIndex,
startTime: resuming ? item.resumeTime : .zero
))
}
/// Schaltet einen laufenden Sender ein.
///
/// Eigener Weg, weil ein Sender nicht in der Mediathek liegt: Adresse und
/// Tuner-Belegung stehen schon fest und dürfen nicht neu ermittelt werden.
func playLive(
channelID: String,
title: String,
programme: String?,
programmeEndsAt: Date?,
stream: URL,
subscriptionKey: String?
) {
play(PlaybackRequest(
ratingKey: channelID,
title: title,
subtitle: programme,
subtitleEndsAt: programmeEndsAt,
mediaIndex: 0,
startTime: .zero,
streamURL: stream,
liveSubscriptionKey: subscriptionKey
))
}
/// Übernimmt einen neuen Titel.
///
/// Läuft bereits die Kachel, bleibt es dabei: wer nebenher stöbert, will
/// beim Antippen nicht ins Vollbild gerissen werden. Sonst Vollbild, wie
/// man es von einem Antippen erwartet.
func play(_ next: PlaybackRequest) {
presentation = presentation == .mini ? .mini : .full
request = next
}
/// Verkleinert den Player zur schwebenden Kachel. Die Wiedergabe läuft.
func minimize() {
guard request != nil else { return }
presentation = .mini
}
func expand() {
guard request != nil else { return }
presentation = .full
}
/// Beendet die Wiedergabe wirklich.
///
/// Die einzige Stelle, an der das passiert. Alles andere — verkleinern,
/// vergrößern, Bild-in-Bild — lässt die Engine am Leben.
func close() {
request = nil
presentation = .hidden
}
}
extension MediaItem {
/// Wie der Titel im Player heißt.
///
/// Bei Folgen ist der eigene Titel allein nutzlos — „Die erste Folge"
/// sagt nichts darüber, welche Serie läuft.
var playbackTitle: String {
if kind == .episode, let series = seriesTitle {
return "\(series) · \(title)"
}
return title
}
/// Die Beschriftung unter einer Kachel.
///
/// Sie ordnet ein, was darüber steht: zu einer Folge gehört die Serie, zu
/// einer Staffel ihre Nummer. Die Reihenfolge ist Absicht — wird der
/// Platz eng, bleibt der Serienname stehen und die Folgennummer fällt weg.
var cardSubtitle: String? {
switch kind {
case .episode:
var code: String?
if let season = parentIndex, let episode = index {
code = String(localized: "episode.short \(season) \(episode)")
}
guard let series = seriesTitle else { return code }
return code.map { "\(series) · \($0)" } ?? series
case .season:
// Ohne die Serie darüber wäre „Staffel 10" beliebig — deshalb
// steht die Nummer hier und der Serienname als Titel.
if let index { return String(localized: "season.short \(index)") }
return parentTitle == nil ? nil : title
default:
return year.map(String.init)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import Foundation
import Observation
import HaloPlayback
/// Hält fest, was beim letzten Abspielversuch passiert ist.
///
/// Der Player verschwindet, wenn man ihn schließt — und mit ihm jeder Hinweis
/// darauf, woran es lag. Dieser Mitschnitt überlebt das und steht in den
/// Einstellungen. Aus „es läuft nicht" wird damit „umgepackt, lokale
/// Verbindung, hängt bei Öffnen" — ein Unterschied, der Stunden spart.
@MainActor
@Observable
final class PlaybackDiagnostics {
struct Attempt: Identifiable {
let id = UUID()
let startedAt: Date
var title: String
var method: String
var connection: String
/// Die Adresse ohne Abfrageteil.
///
/// Der Token steckt in der Abfrage. Er hat in einem Protokoll nichts
/// zu suchen — es landet in Screenshots, und wer ihn hat, kommt an die
/// ganze Mediathek.
var sourceDescription: String
var steps: [String] = []
var outcome: String?
}
private(set) var lastAttempt: Attempt?
func begin(title: String, connection: String?) {
lastAttempt = Attempt(
startedAt: Date(),
title: title,
method: "—",
connection: connection ?? "—",
sourceDescription: "—"
)
note("Player geöffnet")
}
func record(decision: PlaybackDecision, source: URL) {
lastAttempt?.method = Self.describe(decision)
lastAttempt?.sourceDescription = Self.describe(source)
note("Quelle gewählt")
}
/// Ein Zwischenschritt, mit Zeitabstand zum Beginn.
///
/// Der Abstand ist die eigentliche Auskunft: steht zwischen „Quelle
/// gewählt" und „spielt" eine Minute, liegt es an der Leitung; kommt
/// „spielt" gar nicht, am Engine.
func note(_ step: String) {
guard var attempt = lastAttempt else { return }
let elapsed = Date().timeIntervalSince(attempt.startedAt)
attempt.steps.append(String(format: "%.1fs %@", elapsed, step))
lastAttempt = attempt
}
func finish(outcome: String) {
lastAttempt?.outcome = outcome
note(outcome)
}
private static func describe(_ decision: PlaybackDecision) -> String {
switch decision.method {
case .directPlay:
"Direkt"
case .remux:
decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } }
? "Umgepackt · Bild unverändert, Ton umgerechnet"
: "Umgepackt · MP4, ohne Neukodierung"
case .transcode(let limit):
limit.map { "Transcode · max. \($0 / 1000) Mbit" } ?? "Transcode"
}
}
private static func describe(_ url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return "—"
}
components.query = nil
return components.string ?? "—"
}
}
+144
View File
@@ -0,0 +1,144 @@
import Foundation
import Observation
import HaloPlayback
/// Was der Nutzer über die Wiedergabe eingestellt hat.
///
/// Getrennt nach WLAN und Mobilfunk, weil sich die Anforderungen dort
/// grundlegend unterscheiden: im Heimnetz ist die Originaldatei das Beste,
/// unterwegs zählt das Datenvolumen.
@MainActor
@Observable
final class PlaybackSettings {
/// Die Stufen, die zur Auswahl stehen.
///
/// Bewusst wenige und in Schritten, die man spürt. Eine freie Eingabe in
/// Kilobit hilft niemandem bei der Frage „reicht das für heute Abend".
enum Quality: Int, CaseIterable, Identifiable, Sendable {
case original = 0
case high = 12_000
case medium = 8_000
case low = 4_000
case minimal = 2_000
var id: Int { rawValue }
/// `nil` heißt: keine Grenze, also die Originaldatei.
var bitrateLimit: Int? { self == .original ? nil : rawValue }
}
private enum Key {
static let wifiQuality = "halo.playback.quality.wifi"
static let cellularQuality = "halo.playback.quality.cellular"
static let autoSkipIntro = "halo.playback.autoSkipIntro"
static let autoSkipCredits = "halo.playback.autoSkipCredits"
static let autoPlayNext = "halo.playback.autoPlayNext"
static let locksLandscape = "halo.playback.locksLandscape"
static let rewindSeconds = "halo.playback.rewindSeconds"
static let forwardSeconds = "halo.playback.forwardSeconds"
static let defaultRate = "halo.playback.defaultRate"
static let remembersQuality = "halo.playback.remembersQuality"
static let rememberedQuality = "halo.playback.rememberedQuality"
static let showsBitrate = "halo.playback.showsBitrate"
static let lastPlaybackStruggled = "halo.playback.struggled"
}
/// Die Sprünge, die es zur Auswahl gibt.
///
/// Nicht frei einstellbar: ein Schieberegler für Sekunden lädt dazu ein,
/// 23 einzustellen, und dafür gibt es kein Symbol. Diese fünf haben eins.
static let skipChoices = [5, 10, 15, 30, 45]
/// Die Geschwindigkeiten, mit denen eine Wiedergabe beginnen kann.
static let rateChoices: [Double] = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
private let defaults: UserDefaults
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
/// Ob der Player beim Öffnen ins Querformat dreht und dort bleibt.
///
/// Für die allermeisten Filme richtig — und für den, der im Bett auf der
/// Seite liegt, das Gegenteil davon. Deshalb abschaltbar.
var locksLandscape: Bool { didSet { defaults.set(locksLandscape, forKey: Key.locksLandscape) } }
/// Wie weit der Rücksprung geht, in Sekunden.
var rewindSeconds: Int { didSet { defaults.set(rewindSeconds, forKey: Key.rewindSeconds) } }
/// Wie weit der Vorsprung geht. Getrennt vom Rücksprung, weil die beiden
/// verschiedene Zwecke haben: zurück, weil man etwas nicht verstanden hat;
/// vor, weil man etwas überspringen will.
var forwardSeconds: Int { didSet { defaults.set(forwardSeconds, forKey: Key.forwardSeconds) } }
/// Mit welcher Geschwindigkeit eine Wiedergabe beginnt.
var defaultRate: Double { didSet { defaults.set(defaultRate, forKey: Key.defaultRate) } }
/// Ob die im Player gewählte Stufe für den nächsten Titel gilt.
var remembersQuality: Bool { didSet { defaults.set(remembersQuality, forKey: Key.remembersQuality) } }
/// Ob im Qualitätsmenü steht, was gerade tatsächlich ankommt.
var showsBitrate: Bool { didSet { defaults.set(showsBitrate, forKey: Key.showsBitrate) } }
/// Die zuletzt gewählte Stufe, als Zahl.
///
/// Hier und nicht im Player: der lebt nur so lange wie ein Film. Was über
/// mehrere Filme hinweg gelten soll, gehört in die Einstellungen.
var rememberedQuality: Int? {
get {
guard remembersQuality else { return nil }
let value = defaults.integer(forKey: Key.rememberedQuality)
return value == 0 ? nil : value
}
set { defaults.set(newValue ?? 0, forKey: Key.rememberedQuality) }
}
/// Ob die letzte Wiedergabe von der Leitung nicht getragen wurde.
///
/// **Ein Beleg, keine Vermutung.** Der Hinweis bei „ohne Grenze" soll nur
/// erscheinen, wenn es tatsächlich gehakt hat — eine Warnung, die immer
/// dasteht, liest nach dem zweiten Mal niemand mehr.
///
/// Gesetzt vom Player, wenn im gleitenden Fenster ein Viertel der Zeit
/// fürs Warten draufging oder ein einzelner Rückler über fünfzehn
/// Sekunden dauerte.
var lastPlaybackStruggled: Bool {
get { defaults.bool(forKey: Key.lastPlaybackStruggled) }
set { defaults.set(newValue, forKey: Key.lastPlaybackStruggled) }
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// Voreinstellung: im WLAN das Original, unterwegs 4 Mbit. Das ist die
// Aufteilung, die im Alltag am seltensten stört — zuhause die beste
// Qualität, unterwegs ein Wert, der auch bei mäßigem Empfang trägt.
self.wifiQuality = Quality(rawValue: defaults.object(forKey: Key.wifiQuality) as? Int ?? 0) ?? .original
self.cellularQuality = Quality(rawValue: defaults.object(forKey: Key.cellularQuality) as? Int ?? 4_000) ?? .low
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
self.rewindSeconds = defaults.object(forKey: Key.rewindSeconds) as? Int ?? 10
self.forwardSeconds = defaults.object(forKey: Key.forwardSeconds) as? Int ?? 10
self.defaultRate = defaults.object(forKey: Key.defaultRate) as? Double ?? 1
self.remembersQuality = defaults.object(forKey: Key.remembersQuality) as? Bool ?? false
self.showsBitrate = defaults.object(forKey: Key.showsBitrate) as? Bool ?? true
}
/// Die Richtlinie für die aktuelle Lage.
///
/// Das ist die Stelle, an der Phase 2 greift: `allowsTranscoding` steht
/// jetzt auf `true`. Am Decider selbst musste dafür nichts geändert werden.
func policy(for conditions: NetworkConditions) -> PlaybackPolicy {
let quality = conditions.isExpensive || conditions.isConstrained
? cellularQuality
: wifiQuality
return PlaybackPolicy(
allowsTranscoding: true,
maximumBitrate: quality.bitrateLimit
)
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
import Observation
import MediaKit
/// „Spiel das jetzt" — von einer Kachel aus, ohne Umweg über die Detailseite.
///
/// **Warum ein eigener Typ.** Bei einem Film ist nichts nachzuschlagen: er
/// läuft sofort. Bei einer Serie schon — es muss erst feststehen, welche
/// Folge dran ist, und das ist eine Serveranfrage. Diese Anfrage gehört
/// weder in `PlaybackCoordinator`, der keinen Serverzugang hat und dessen
/// Aufgabe die Darstellung ist, noch in die Kacheln, wo sie dreimal stünde.
///
/// **Die Regel selbst steht nicht hier**, sondern in `EpisodeSelection` — als
/// reiner Typ mit Tests. Hier steht nur, woher die Liste kommt.
@MainActor
@Observable
final class QuickPlay {
/// Welche Kachel gerade nachschlägt.
///
/// Eine Kennung, kein Wahrheitswert: der Kreisel soll an **dieser** einen
/// Kachel stehen und nicht an allen zwanzig.
private(set) var resolvingID: String?
/// Warum es nicht ging — einmal für die ganze App.
///
/// Ein Hinweis je Kachel wäre bei einem Serverausfall ein Stapel aus
/// zwanzig identischen Meldungen.
var failure: String?
private var task: Task<Void, Never>?
/// Startet, was hinter dieser Kachel steckt.
///
/// `backend` und `coordinator` kommen als Argumente statt als gespeicherte
/// Werte: so bleibt der Typ ohne Abhängigkeiten baubar, und die Sitzung
/// kann den Server wechseln, ohne dass hier etwas veraltet. Dasselbe
/// Muster wie bei `DownloadRefiller`.
func start(
_ item: MediaItem,
backend: (any MediaBackend)?,
coordinator: PlaybackCoordinator
) {
// Der zweite Klick gewinnt. Sonst liefen zwei Abfragen, und die
// langsamere überschriebe am Ende die gewollte.
task?.cancel()
switch item.kind {
case .show, .season:
resolve(item, backend: backend, coordinator: coordinator)
default:
// **Sofort, ohne Aufgabe und ohne Kreisel.** Der häufige Fall
// soll nicht aufblitzen, nur weil der seltene eine Anfrage
// braucht.
resolvingID = nil
coordinator.play(item)
}
}
func cancel() {
task?.cancel()
task = nil
resolvingID = nil
}
private func resolve(
_ item: MediaItem,
backend: (any MediaBackend)?,
coordinator: PlaybackCoordinator
) {
guard let backend else {
failure = String(localized: "quickPlay.noServer")
return
}
resolvingID = item.id
task = Task { [weak self] in
let folgen: [MediaItem]
let bereich: EpisodeSelection.Scope
do {
if item.kind == .show {
folgen = try await backend.episodes(inSeries: item.id)
bereich = .series
} else {
// Eine Staffel ist schon eine Anfrage — `children(of:)`
// genügt, und der Server muss nichts Besonderes können.
folgen = try await backend.children(of: item.id)
bereich = .season
}
} catch {
guard !Task.isCancelled, let self else { return }
resolvingID = nil
failure = ErrorText.describe(error)
return
}
guard !Task.isCancelled, let self else { return }
resolvingID = nil
guard let wahl = EpisodeSelection.nextUp(in: folgen, scope: bereich) else {
failure = String(localized: "quickPlay.nothingToPlay")
return
}
coordinator.play(wahl.episode, resuming: wahl.resumes)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import SwiftUI
import UIKit
/// Wo der Bildschirm aufhört — für die Ansichten, die absichtlich darüber
/// hinauslaufen.
///
/// **Warum nicht aus SwiftUI.** Der Sicherheitsrand ist in SwiftUI kein Wert,
/// den man erfragt, sondern ein Rand, den man verbraucht: wer ihn respektiert,
/// sieht ihn als null, und wer ihn mit `ignoresSafeArea` überschreitet, sieht
/// ihn danach ebenfalls als null. Ein `GeometryReader` dazwischenzuschieben
/// hilft nicht, er nimmt der Bildfläche das `ignoresSafeArea` gleich mit — und
/// dann ist das Bild seitlich um die Notch-Breite kleiner.
///
/// Der Versuch, ihn eine Ebene höher zu messen und hineinzureichen, hat es
/// auch nicht behoben: gemessen in einer Ansicht, angewendet in einer anderen,
/// über eine Drehung hinweg. Auf dem Gerät kamen dabei die Werte der falschen
/// Ausrichtung an.
///
/// **Das Fenster weiß es immer.** Es kennt seine Ausrichtung, es kennt die
/// Notch, und es antwortet in dem Moment, in dem gefragt wird. Das ist die
/// einzige Stelle in dieser App, die so fragt — und genau die eine, an der es
/// keinen SwiftUI-Weg gibt.
///
/// Gebraucht wird es für die Untertitel im Füllmodus: die Bildebene reicht
/// dort über die untere Kante hinaus, und AVFoundation zeichnet die Schrift an
/// ihren unteren Rand.
@MainActor
enum ScreenSafeArea {
/// Was der Bildschirm gerade verdeckt — Notch, Dynamic Island,
/// Home-Indikator, gerundete Ecken.
///
/// **Jedes Mal frisch gefragt, nie gemerkt.** Beim Drehen ändert sich
/// jeder dieser Werte, und ein gemerkter wäre genau dann falsch, wenn er
/// gebraucht wird.
static var current: EdgeInsets {
let i = windowInsets
// `left`/`right` und nicht `leading`/`trailing`: UIKit denkt in
// Bildschirmseiten, SwiftUI in Leserichtung. Bei einer Sprache, die
// von rechts nach links läuft, wären sie vertauscht — deshalb hier
// ausdrücklich in die Leserichtung übersetzt.
let vonLinks = fenster?.effectiveUserInterfaceLayoutDirection != .rightToLeft
return EdgeInsets(
top: i.top,
leading: vonLinks ? i.left : i.right,
bottom: i.bottom,
trailing: vonLinks ? i.right : i.left
)
}
private static var windowInsets: UIEdgeInsets { fenster?.safeAreaInsets ?? .zero }
/// Das Fenster, das gerade zählt.
///
/// **Mit Rückfall, und das ist der Punkt.** Zuerst stand hier nur
/// „die Szene, die `foregroundActive` ist" — und genau die gibt es in
/// zwei Momenten nicht: während einer Drehung und kurz nach dem
/// Zurückholen der App. Wer dann fragt, bekommt `nil`, daraus werden
/// Ränder von null, und die bleiben stehen, bis sich die Fläche wieder
/// ändert. Die Knöpfe sitzen dann im Home-Indikator, und zwar dauerhaft,
/// obwohl die Rechnung stimmt.
///
/// Ein Fenster, das die richtigen Ränder kennt, ist in dem Moment
/// trotzdem da — es ist nur nicht „aktiv". Also der Reihe nach: aktiv,
/// sonst sichtbar, sonst irgendeines.
private static var fenster: UIWindow? {
let szenen = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let gewählt = szenen.first { $0.activationState == .foregroundActive }
?? szenen.first { $0.activationState == .foregroundInactive }
?? szenen.first
return gewählt?.keyWindow ?? gewählt?.windows.first { $0.isKeyWindow } ?? gewählt?.windows.first
}
}
+80
View File
@@ -0,0 +1,80 @@
import Foundation
import MediaKit
import Observation
/// Die Suche als Modell: entprellen, fragen, merken.
///
/// **Herausgelöst wie `HomeFeed` und `LibraryFeed`**, und aus demselben
/// Grund: der Fernseher stellt dieselben Fragen an denselben Server, nur ganz
/// anders dargestellt. Kopiert wären dabei die drei Stellen, an denen es
/// nicht offensichtlich ist — die Pause vor der Anfrage, die Mindestlänge und
/// vor allem die Prüfung auf `Task.isCancelled`, ohne die jeder Tastendruck
/// die Ergebnisse des vorigen löscht.
@MainActor
@Observable
final class SearchFeed {
/// Wie viele zuletzt gesuchte Begriffe gemerkt werden.
private static let recentLimit = 8
private static let recentKey = "halo.search.recent"
private(set) var hubs: [MediaRowGroup] = []
private(set) var isSearching = false
private(set) var recent: [String]
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.recent = defaults.stringArray(forKey: Self.recentKey) ?? []
}
/// Sucht — nach einer kurzen Pause.
///
/// Die Pause ist der Kern: aufgerufen wird das bei **jedem** Tastendruck,
/// und ohne sie liefe für „Nachtzug" eine Anfrage je Buchstabe. Ein
/// neuer Tastendruck bricht die laufende Aufgabe ab, und der Abbruch
/// landet im `catch` der Pause — deshalb steht dort kein Fehler, sondern
/// ein `return`.
func search(_ text: String, from backend: (any MediaBackend)?) async {
let bereinigt = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let backend, bereinigt.count >= 2 else {
hubs = []
return
}
do {
try await Task.sleep(for: .milliseconds(300))
} catch {
return
}
isSearching = true
defer { isSearching = false }
do {
hubs = try await backend.search(bereinigt, limit: 30)
if !hubs.isEmpty { remember(bereinigt) }
} catch {
// **`Task.isCancelled` statt `catch is CancellationError`.** Eine
// abgebrochene Netzanfrage kommt als Übertragungsfehler zurück,
// nicht als Abbruch; die naheliegende Prüfung lief deshalb ins
// Leere, und jeder Tastendruck leerte kurz die Liste.
if Task.isCancelled { return }
hubs = []
}
}
// MARK: - Zuletzt gesucht
private func remember(_ eintrag: String) {
recent.removeAll { $0.caseInsensitiveCompare(eintrag) == .orderedSame }
recent.insert(eintrag, at: 0)
recent = Array(recent.prefix(Self.recentLimit))
defaults.set(recent, forKey: Self.recentKey)
}
func forgetRecent() {
recent = []
defaults.removeObject(forKey: Self.recentKey)
}
}
+44
View File
@@ -0,0 +1,44 @@
import Foundation
import Observation
import HaloPlayback
/// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg.
///
/// Eigener Typ statt zweier Felder an `PlaybackSettings`, aus demselben Grund
/// wie bei `LanguagePreferences`: dort geht es um Qualität und Leitung, hier
/// um Darstellung. Zwei Fragen, zwei Orte.
///
/// **Die Werte selbst liegen in `SubtitleStyle`**, also im prüfbaren Teil.
/// Hier steht nur, wie sie einen Neustart überleben.
@MainActor
@Observable
final class SubtitleAppearance {
private enum Key {
static let size = "halo.subtitles.size"
static let palette = "halo.subtitles.palette"
}
private let defaults: UserDefaults
var size: SubtitleStyle.Size {
didSet { defaults.set(size.rawValue, forKey: Key.size) }
}
var palette: SubtitleStyle.Palette {
didSet { defaults.set(palette.rawValue, forKey: Key.palette) }
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// Über `object(forKey:)`, nicht über `integer(forKey:)`: das gäbe für
// „nie gesetzt" eine Null zurück, und die ist keine gültige Stufe.
self.size = (defaults.object(forKey: Key.size) as? Int)
.flatMap(SubtitleStyle.Size.init(rawValue:)) ?? .normal
self.palette = defaults.string(forKey: Key.palette)
.flatMap(SubtitleStyle.Palette.init(rawValue:)) ?? .whiteOnClear
}
var style: SubtitleStyle {
SubtitleStyle(size: size, palette: palette)
}
}
+37
View File
@@ -0,0 +1,37 @@
import HaloPlayback
import Foundation
/// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe
/// Muster wie bei `PlaybackSettings.Quality`: `SubtitleStyle` liegt im
/// prüfbaren Teil und soll keinen Textkatalog kennen.
///
/// **Hier und nicht bei der Einstellungsansicht.** Dort standen sie, und
/// damit übersetzte nur das Telefon sie: am Fernseher steht derselbe Stil im
/// Untertitelmenü des Players, und zwei Listen gleich zu halten ist genau die
/// Art Fehler, die erst auffällt, wenn jemand „Gelb auf Schwarz" wählt und
/// „Weiss auf Schwarz" bekommt.
///
/// Als `String` und nicht als `LocalizedStringKey`: ein `UIMenu` nimmt keinen
/// Schlüssel entgegen, und `Text` kommt mit beidem zurecht.
extension SubtitleStyle.Size {
var label: String {
switch self {
case .small: String(localized: "subtitleStyle.size.small")
case .normal: String(localized: "subtitleStyle.size.normal")
case .large: String(localized: "subtitleStyle.size.large")
case .huge: String(localized: "subtitleStyle.size.huge")
}
}
}
extension SubtitleStyle.Palette {
var label: String {
switch self {
case .whiteOnClear: String(localized: "subtitleStyle.palette.whiteOnClear")
case .whiteOnBlack: String(localized: "subtitleStyle.palette.whiteOnBlack")
case .blackOnWhite: String(localized: "subtitleStyle.palette.blackOnWhite")
case .yellowOnClear: String(localized: "subtitleStyle.palette.yellowOnClear")
case .yellowOnBlack: String(localized: "subtitleStyle.palette.yellowOnBlack")
}
}
}
+112
View File
@@ -0,0 +1,112 @@
// **Die ganze Datei nur für den Fernseher.**
//
// Sie liegt im geteilten Ordner, weil `Halo/Session/` von beiden Zielen
// übersetzt wird — und `TVServices` gibt es auf iOS nicht. Ein `#if` um den
// einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft
// ihn trotzdem und bricht mit „Unable to resolve module dependency:
// TVServices" ab, ohne den Fernseher zu erwähnen. Dieselbe Falle wie bei
// siehe `PlaybackSettings`.
#if os(tvOS)
import Foundation
import MediaKit
import TVServices
/// Legt für das Regal über der App hin, was es zeigen soll.
///
/// **Beim Laden der Startseite, nicht auf einem Zeitgeber.** Das Regal wird
/// gezeichnet, wenn jemand im Menü über der App steht — und dann ist die App
/// nicht gestartet. Was dort steht, ist also immer das, was beim letzten
/// Öffnen zu sehen war. Ein Zeitgeber änderte daran nichts und hielte nur die
/// App wach.
@MainActor
enum TopShelfWriter {
/// Die App-Gruppe, die App und Erweiterung teilen.
static let group = "group.com.scarriffleservices.halo"
/// Die Kante, in der das Regal Plakate zeigt.
///
/// **Apples Mass für die doppelte Auflösung**, und für beide Stufen
/// dasselbe Bild. Ein Apple TV zeichnet das Regal ohnehin doppelt
/// aufgelöst; zwei Adressen abzulegen hiesse, dem Server zwei Grössen
/// desselben Plakats abzuverlangen, damit eine davon nie gebraucht wird.
private static let posterWidth = 666
private static let posterHeight = 1000
static func update(
rows: [MediaRowGroup],
backend: (any MediaBackend)?
) {
guard let backend else { return }
let abschnitte = rows
.prefix(TopShelfSnapshot.sectionLimit)
.map { reihe in
TopShelfSnapshot.Section(
id: reihe.id,
title: reihe.title,
entries: reihe.items
.prefix(TopShelfSnapshot.entryLimit)
.map { item in
TopShelfSnapshot.Entry(
id: item.id,
title: item.rowTitle,
subtitle: item.cardSubtitle,
// **Das Hochkant-Plakat, nicht `thumb`.** Bei
// einer Folge ist `thumb` das Szenenbild; im
// Regal stünde davon ein zufälliger
// Ausschnitt. Dieselbe Rückfallkette wie auf
// den Kacheln.
imageURL: backend.artworkURL(
path: item.posterArtworkPath,
width: posterWidth,
height: posterHeight
),
progress: item.watchProgress
)
}
)
}
.filter { !$0.entries.isEmpty }
TopShelfStore.write(
TopShelfSnapshot(sections: Array(abschnitte)),
group: group
)
// **Und dem System sagen, dass es neu fragen soll.**
//
// Ohne diese Zeile bleibt die Erweiterung ungefragt, und im Header
// steht, was dann geschieht: „If the completion handler is not called
// in a timely manner or is called with empty content the system will
// display the static image instead." Genau das war zu sehen — das
// Plakat aus dem Asset-Katalog, gross und unverändert, während die
// Datei daneben längst drei Reihen enthielt.
//
// Die Erweiterung selbst kann das nicht auslösen: sie läuft nur,
// wenn sie gefragt wird.
TVTopShelfContentProvider.topShelfContentDidChange()
}
/// Was zuletzt abgelegt wurde — in einem Satz, zum Hinsehen.
///
/// **Weil man dem Regal sonst nichts ansieht.** Bleibt es leer, gibt es
/// genau drei Möglichkeiten: die App-Gruppe fehlt, die Datei ist nicht da,
/// oder sie ist da und das System fragt trotzdem nicht. Von aussen sehen
/// alle drei gleich aus — nämlich nach dem grossen Plakat aus dem
/// Asset-Katalog. Diese Zeile unterscheidet sie.
static var status: String {
guard let ort = TopShelfStore.location(group: group) else {
return String(localized: "topShelf.noGroup")
}
guard let abzug = TopShelfStore.read(group: group) else {
let daIst = FileManager.default.fileExists(atPath: ort.path)
return daIst
? String(localized: "topShelf.unreadable")
: String(localized: "topShelf.noFile")
}
let einträge = abzug.sections.reduce(0) { $0 + $1.entries.count }
let zeit = abzug.savedAt.formatted(date: .omitted, time: .shortened)
return String(localized: "topShelf.written \(abzug.sections.count) \(einträge) \(zeit)")
}
}
#endif
@@ -0,0 +1,103 @@
import SwiftUI
import MediaKit
/// Die Einträge, die am Server etwas ändern — ohne Hülle darum.
///
/// **Gesehen, Favorit, Sterne gehören nicht nur auf die Detailseite.** Wer in
/// einer Reihe steht und weiss, dass er die Folge schon kennt, will sie dort
/// abhaken und nicht erst eine Seite tiefer gehen. Dieselben Einträge, zwei
/// Orte — und deshalb an einer Stelle beschrieben.
///
/// **Warum sie keinen eigenen Zustand tragen.** Der Inhalt eines
/// Kontextmenüs lebt nur, solange das Menü offen ist: SwiftUI baut ihn beim
/// Öffnen und wirft ihn beim Schliessen weg. Ein `@State` darin könnte also
/// nichts anzeigen, was **nach** dem Tippen passiert — weder Kreisel noch
/// Fehlermeldung, denn da ist das Menü längst zu. Ausgeführt wird deshalb vom
/// Aufrufer, der länger lebt als das Menü.
///
/// Aus demselben Grund kommen `capabilities` als Wert herein, statt aus der
/// Umgebung gelesen zu werden: was in ein Kontextmenü durchgereicht wird, ist
/// von SwiftUI-Fassung zu SwiftUI-Fassung verschieden — ein Wert, den der
/// Aufrufer übergibt, ist es nicht.
struct ItemActionEntries: View {
let item: MediaItem
let capabilities: Set<BackendCapability>
/// Führt die Serveraktion aus und räumt hinterher auf.
let run: (@escaping (any MediaBackend) async throws -> Void) -> Void
var body: some View {
Button {
run { backend in
if item.isWatched {
try await backend.markUnwatched(id: item.id)
} else {
try await backend.markWatched(id: item.id)
}
}
} label: {
Label(
item.isWatched ? "action.markUnwatched" : "action.markWatched",
systemImage: item.isWatched ? "eye.slash" : "checkmark.circle"
)
}
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
// Fehler bei sich.
if item.watchProgress != nil {
Button {
run { try await $0.removeFromContinueWatching(item) }
} label: {
Label("action.removeFromContinue", systemImage: "minus.circle")
}
}
if capabilities.contains(.favourites) {
Button {
run { try await $0.setFavourite(!item.isFavourite, id: item.id) }
} label: {
Label(
item.isFavourite ? "action.removeFavourite" : "action.addFavourite",
systemImage: item.isFavourite ? "heart.slash" : "heart"
)
}
}
if capabilities.contains(.ratings) {
Menu {
ForEach((1...5).reversed(), id: \.self) { stars in
Button {
// In Zehnteln: fünf Sterne sind eine 10.
run { try await $0.rate(id: item.id, rating: stars * 2) }
} label: {
Label(String(repeating: "★", count: stars), systemImage: "star")
}
}
if item.rating != nil {
Divider()
Button(role: .destructive) {
run { try await $0.rate(id: item.id, rating: 0) }
} label: {
Label("action.clearRating", systemImage: "star.slash")
}
}
} label: {
Label("action.rate", systemImage: "star")
}
}
}
}
extension EnvironmentValues {
/// Was zu tun ist, wenn ein Titel am Server verändert wurde.
///
/// **Über die Umgebung, weil der Weg sonst lang ist.** Eine Kachel steckt
/// in einer Reihe, die in der Startseite steckt — und nur die Startseite
/// weiss, wie man ihre Reihen neu lädt. Diesen Rückweg durch jede
/// Zwischenebene zu reichen hiesse, drei Ansichten um ein Argument zu
/// erweitern, das sie nur weitergeben.
///
/// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es
/// auch nichts neu zu laden.
@Entry var haloItemChanged: () async -> Void = {}
}
+308
View File
@@ -0,0 +1,308 @@
import SwiftUI
import HaloDesign
import HaloDownloads
import MediaKit
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
/// startet.
///
/// **Der Umweg, den es spart.** Wer weiß, was er sehen will, klickte bisher
/// auf die Kachel, wartete auf die Detailseite und drückte dort „Abspielen".
/// Zwei Schritte für eine Absicht.
///
/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt
/// in `HaloDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es
/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
/// und wäre für VoiceOver gar nicht mehr da.
struct QuickPlayTile<Card: View>: View {
let item: MediaItem
/// Ob ein Tipp auf die Detailseite führt, statt abzuspielen.
///
/// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will —
/// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf
/// der Startseite richtig (die Kachel stand schon da, man kennt sie) und
/// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des
/// Servers, keine Entscheidung des Nutzers.
var opensDetails: Bool = false
@ViewBuilder let card: () -> Card
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
@Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent
@State private var isPointerInside = false
@State private var errorMessage: String?
/// Der nachgeladene Titel, sobald die Qualitätsauswahl offen sein soll.
///
/// **Zwei Dinge in einem Zustand**, und das mit Absicht: die Kachel kennt
/// nur eine Zusammenfassung ohne Dateiangaben, also muss vor der Frage
/// nachgeladen werden. Ein eigenes `Bool` daneben wäre ein zweiter
/// Zustand, der mit diesem gleichziehen müsste.
@State private var downloadChoice: MediaItem?
/// Stufe steht, Tonspur fehlt — siehe `DownloadButton`.
@State private var pendingQuality: (item: MediaItem, quality: DownloadQuality)?
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
/// hat. Siehe `ItemActionEntries`.
@Environment(\.haloItemChanged) private var itemChanged
/// Der Weg auf die Detailseite — siehe `MediaStack`.
@Environment(\.haloPush) private var push
private var isMac: Bool { HaloPlatform.isMac }
/// Ob es überhaupt etwas zu starten gibt.
///
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
private var isPlayable: Bool { item.kind != .collection }
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
/// Wohin ein Tipp führt.
///
/// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
/// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt.
private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable }
var body: some View {
kachel
.buttonStyle(.plain)
// **Als Überzug auf dem Link, nicht als Kind darin.**
//
// Ein `Button` im *Label* eines `NavigationLink` löst auf iOS nie aus:
// der Link macht sein Label zu einer einzigen Fläche, verschachtelte
// Bedienelemente werden nicht eigens getroffen — und `.plain` macht
// das Versagen lautlos. Als Geschwister darüber gewinnt er.
.overlay(alignment: .topTrailing) {
if isPlayable, isMac { playButton }
}
.onContinuousHover { phase in
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
// der iOS-Bau und läuft trotzdem auf dem Mac.
guard isMac else { return }
if case .active = phase { isPointerInside = true } else { isPointerInside = false }
}
.animation(.easeOut(duration: 0.15), value: isPointerInside)
// Langes Drücken am Telefon und am iPad, Rechtsklick am Mac.
//
// **Hier und nicht nur auf der Detailseite.** Eine Folge abzuhaken,
// die man schon kennt, war bisher zwei Schritte weit weg: Kachel
// antippen, warten, Menü oben rechts. Jetzt steht es dort, wo die
// Kachel steht.
.contextMenu {
if isPlayable {
// **Am Telefon und am iPad führt der Weg zur Detailseite
// hierher.** Antippen spielt dort ab; wer stattdessen
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
// Starten gibt es den Knopf beim Überfahren.
if !tapOpensDetails {
Button("hero.details", systemImage: "info.circle") { push(route) }
Divider()
}
Button("tile.play", systemImage: "play.fill") { trigger() }
// **Herunterladen gehört hierher.** Bisher führte der Weg
// über die Detailseite — zwei Schritte für etwas, das man
// beim Vorbeiscrollen entscheidet.
if isDownloadable {
Button("downloads.download", systemImage: "arrow.down.circle") {
Task { await prepareDownload() }
}
}
Divider()
}
ItemActionEntries(
item: item,
capabilities: session.backend?.capabilities ?? [],
run: run
)
}
// Für VoiceOver eine **Aktion**, kein zweites Element: die Kachel
// bleibt einer, und im Aktionen-Rotor steht „Abspielen". Zwanzig
// Kacheln mit je zwei Elementen wären sechzig Wischer.
.accessibilityAction(named: Text("tile.play")) { trigger() }
// Für VoiceOver der zweite Weg: am Telefon führt das Antippen in den
// Film, und die Detailseite wäre sonst nur über ein Kontextmenü zu
// erreichen, das eine Geste verlangt.
.accessibilityAction(named: Text("hero.details")) {
if tapOpensDetails { return }
push(route)
}
// **Nur der Fehler, kein Kreisel.** Wer im Kontextmenü tippt, sieht
// das Menü verschwinden; ein Kreisel auf der Kachel käme für eine
// Aktion, die Millisekunden dauert, zu spät und ginge zu früh. Geht
// sie dagegen schief, muss man es erfahren — sonst bliebe der Haken
// einfach aus, und das sieht aus wie ein Fehler der App.
.confirmationDialog(
Text("downloads.quality.title"),
isPresented: Binding(
get: { downloadChoice != nil },
set: { if !$0 { downloadChoice = nil } }
),
titleVisibility: .visible
) {
if let ziel = downloadChoice {
let wahl = DownloadQualityChoice(item: ziel, backend: session.backend)
ForEach(wahl.available) { quality in
Button(wahl.label(for: quality)) { chose(ziel, quality: quality) }
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.quality.hint")
}
.confirmationDialog(
Text("downloads.audio.title"),
isPresented: Binding(
get: { pendingQuality != nil },
set: { if !$0 { pendingQuality = nil } }
),
titleVisibility: .visible
) {
if let offen = pendingQuality {
let spuren = offen.item.preferredVersion?.files.first?.audioStreams ?? []
let empfohlen = offen.item.preferredVersion?.files.first.flatMap {
service.preferredAudioStream(in: $0, of: offen.item)
}
ForEach(spuren) { stream in
Button(
stream == empfohlen
? String(localized: "downloads.audio.recommended \(stream.name)")
: stream.name
) {
enqueue(offen.item, quality: offen.quality, audioStream: stream)
}
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.audio.hint")
}
.alert(
Text("error.title"),
isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
) {
Button("action.close") { errorMessage = nil }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
/// Die Kachel selbst — und was ein Antippen bedeutet.
///
/// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss
/// meistens schon, was er sehen will; der Umweg über die Detailseite
/// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über
/// langes Drücken, wo auch „gesehen" und „entfernen" stehen.
///
/// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den
/// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche
/// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film.
///
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
/// Inneren: die Kachel soll am Mac ein Link **sein**, nicht einer
/// aussehen.
@ViewBuilder
private var kachel: some View {
if tapOpensDetails {
NavigationLink(value: route) { card() }
} else {
Button { trigger() } label: { card() }
}
}
/// Oben rechts, weil der Fortschrittsbalken unten liegt.
private var playButton: some View {
Button(action: trigger) {
Group {
if quickPlay.resolvingID == item.id {
ProgressView().controlSize(.small).tint(.white)
} else {
Image(systemName: "play.fill")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
}
}
// Feste Grösse, damit der Kreisel den Knopf nicht wachsen lässt
// und nichts springt, wenn nachgeschlagen wird.
.frame(width: 28, height: 28)
.background(accent.opacity(0.92), in: .circle)
.shadow(color: .black.opacity(0.35), radius: 3, y: 1)
}
.buttonStyle(.plain)
.padding(HaloSpacing.snug)
.opacity(isPointerInside ? 1 : 0)
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
.accessibilityHidden(true)
}
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
///
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
/// *den* Titel, sondern seine Folgen, und dafür gibt es auf der Seite
/// „Alle Folgen laden".
private var isDownloadable: Bool {
guard item.kind == .movie || item.kind == .episode else { return false }
return !downloads.items.contains { $0.ratingKey == item.id }
}
/// Holt die Dateiangaben nach und öffnet dann die Auswahl.
///
/// Erst beim Tippen, nicht beim Zeichnen: eine Reihe aus zwanzig Kacheln
/// wären sonst zwanzig Anfragen für ein Menü, das selten aufgeht.
private func prepareDownload() async {
downloadChoice = (try? await session.backend?.item(id: item.id)) ?? item
}
private var service: DownloadService {
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
}
/// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge.
private func chose(_ ziel: MediaItem, quality: DownloadQuality) {
downloadChoice = nil
if service.needsAudioChoice(for: ziel, quality: quality) {
pendingQuality = (ziel, quality)
} else {
enqueue(ziel, quality: quality, audioStream: nil)
}
}
private func enqueue(_ ziel: MediaItem, quality: DownloadQuality, audioStream: MediaStream?) {
pendingQuality = nil
do {
_ = try service.download(ziel, quality: quality, audioStream: audioStream)
} catch {
errorMessage = error.explanation
}
}
private func trigger() {
quickPlay.start(item, backend: session.backend, coordinator: playback)
}
/// Führt eine Serveraktion aus und lässt die Seite danach neu laden.
///
/// Das Neuladen gehört dazu: eine Folge, die als gesehen abgehakt wurde,
/// gehört nicht mehr in „Weiterschauen", und der Fortschrittsbalken unter
/// der Kachel stimmt sonst nicht mehr. Ohne diesen Schritt sähe es aus,
/// als wäre nichts passiert.
private func run(_ action: @escaping (any MediaBackend) async throws -> Void) {
guard let backend = session.backend else { return }
Task {
do {
try await action(backend)
await itemChanged()
} catch {
errorMessage = ErrorText.describe(error)
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Das Aktionsmenü auf einer Detailseite.
///
/// Alles, was den Server verändert, liegt hier zusammen — statt verstreut als
/// einzelne Knöpfe. Das hält die Kopfzeile frei für das, was man ständig
/// braucht: Abspielen und Herunterladen.
///
/// Welche Einträge erscheinen, entscheidet der Server: Plex kennt Sterne, aber
/// keine Favoriten, Jellyfin Favoriten, aber keine Sterne. Ein Eintrag, der
/// nichts bewirkt, ist schlimmer als keiner.
struct ItemActionsMenu: View {
let item: MediaItem
/// Wird gerufen, wenn sich am Server etwas geändert hat und die
/// Detailseite neu geladen werden sollte.
let onChange: () async -> Void
@Environment(HaloSession.self) private var session
@State private var isWorking = false
@State private var errorMessage: String?
private var capabilities: Set<BackendCapability> {
session.backend?.capabilities ?? []
}
var body: some View {
Menu {
// Dieselben Einträge wie im Kontextmenü einer Kachel — siehe
// `ItemActionEntries`. Hier mit Kreisel und Fehlermeldung, weil
// diese Ansicht lange genug lebt, um beides zu zeigen.
ItemActionEntries(item: item, capabilities: capabilities, run: run)
} label: {
if isWorking {
ProgressView().controlSize(.small)
} else {
Image(systemName: "ellipsis.circle")
}
}
.disabled(isWorking)
.alert(
Text("error.title"),
isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
) {
Button("action.close") { errorMessage = nil }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
/// Führt eine Serveraktion aus und lädt danach neu.
///
/// Bewusst ohne vorweggenommene Anzeige: den Haken sofort zu setzen und
/// bei einem Fehler wieder zurückzunehmen sieht aus wie ein Wackler. Die
/// Aktionen dauern Millisekunden.
private func run(_ action: @escaping (any MediaBackend) async throws -> Void) {
guard let backend = session.backend else { return }
isWorking = true
Task {
defer { isWorking = false }
do {
try await action(backend)
await onChange()
} catch {
errorMessage = ErrorText.describe(error)
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Die Detailseite eines Titels.
///
/// Holt die Metadaten frisch, statt den Eintrag aus der Liste weiterzureichen:
/// Einträge in Reihen und Rastern kommen ohne Dateiliste, ohne Besetzung und
/// ohne Marker. Für das Abspielen braucht es aber genau diese Angaben.
struct MediaDetailView: View {
let route: MediaRoute
@Environment(HaloSession.self) private var session
@State private var state: LoadState<MediaItem> = .loading
var body: some View {
Group {
switch state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await load() } }
}
case .loaded(let item):
switch item.kind {
case .show, .season:
SeriesDetailView(item: item)
default:
PlayableDetailView(item: item)
}
}
}
.background(Color.haloCanvas)
.navigationTitle(state.value?.title ?? route.title)
.haloInlineTitle()
.toolbar {
if let item = state.value {
ToolbarItem(placement: .haloTrailing) {
ItemActionsMenu(item: item) { await load() }
}
}
}
.task(id: route.ratingKey) { await load() }
}
private func load() async {
guard let server = session.backend else { return }
state = await LoadState.load {
try await server.item(id: route.ratingKey)
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
///
/// Bisher gab es ihn nicht. Wer über „Weiterschauen" oder „Zuletzt
/// hinzugefügt" bei einer einzelnen Folge landete, saß dort fest — die
/// übrigen Folgen derselben Staffel waren nur über den Umweg Bibliothek →
/// Serie → Staffel erreichbar. Genau dieser Umweg ist der Grund, dass die
/// Zeile existiert.
struct ParentLinks: View {
let item: MediaItem
var body: some View {
if !destinations.isEmpty {
ScrollView(.horizontal) {
HStack(spacing: HaloSpacing.snug) {
ForEach(destinations, id: \.route.ratingKey) { destination in
NavigationLink(value: destination.route) {
Label {
Text(destination.route.title)
.lineLimit(1)
} icon: {
Image(systemName: destination.symbol)
}
.font(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.snug)
.background(Color.haloElevated, in: .capsule)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
}
.scrollIndicators(.never)
}
}
private struct Destination {
let route: MediaRoute
let symbol: String
}
/// Die Stationen über diesem Eintrag.
///
/// Bei einer Folge zwei — Staffel und Serie; bei einer Staffel eine.
/// Plex führt beide Kennungen am Eintrag mit, deshalb braucht es dafür
/// keine zusätzliche Abfrage.
private var destinations: [Destination] {
switch item.kind {
case .episode:
var stops: [Destination] = []
if let season = item.parentID {
let title = item.parentIndex.map { String(localized: "season.short \($0)") }
?? item.parentTitle
?? String(localized: "detail.toSeason")
stops.append(Destination(
route: MediaRoute(ratingKey: season, title: title),
symbol: "rectangle.stack"
))
}
if let show = item.seriesID {
stops.append(Destination(
route: MediaRoute(
ratingKey: show,
title: item.seriesTitle ?? String(localized: "detail.toSeries")
),
symbol: "tv"
))
}
return stops
case .season:
guard let show = item.parentID else { return [] }
return [Destination(
route: MediaRoute(
ratingKey: show,
title: item.parentTitle ?? String(localized: "detail.toSeries")
),
symbol: "tv"
)]
default:
return []
}
}
}
+297
View File
@@ -0,0 +1,297 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
/// einzelne Folge.
struct PlayableDetailView: View {
let item: MediaItem
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.haloAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selectedVersion = 0
@State private var showsTechnicalDetails = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.section) {
header
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
// unter dem Titel. Zwei Wege zum selben Ziel, einer davon als
// Knopfreihe am unteren Rand, waren einer zu viel.
if item.kind != .episode {
ParentLinks(item: item)
}
if item.hasMultipleVersions {
VersionPicker(media: item.versions, selection: $selectedVersion)
.padding(.horizontal, HaloSpacing.screenMargin)
}
if let summary = item.summary, !summary.isEmpty {
Text(summary)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
.padding(.horizontal, HaloSpacing.screenMargin)
}
if !item.genres.isEmpty {
TagStrip(tags: item.genres)
}
if !item.cast.isEmpty {
CastRow(cast: item.cast)
}
if let media = currentMedia, let part = media.files.first {
TechnicalDetails(
media: media,
part: part,
isExpanded: $showsTechnicalDetails
)
.padding(.horizontal, HaloSpacing.screenMargin)
}
}
.padding(.bottom, HaloSpacing.section)
}
.scrollIndicators(.never)
.haloRevealsTopChrome()
}
private var currentMedia: MediaVersion? {
guard item.versions.indices.contains(selectedVersion) else { return item.versions.first }
return item.versions[selectedVersion]
}
private var header: some View {
HeroBanner(
title: item.rowTitle,
metadata: item.kind == .episode ? nil : metadataLine,
metadataContent: item.kind == .episode ? AnyView(episodeMetadata) : nil,
summary: nil,
// Über `HeroArtwork`, nicht von Hand: hier standen 900×1200 —
// 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.
artworkURLs: session.backend.map {
HeroArtwork.backdropURLs(
for: item,
backend: $0,
placement: .detailHeader,
compact: sizeClass == .compact
)
} ?? [],
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath),
// **Dasselbe Verhältnis, in dem das Bild bestellt wurde.** Hier
// stand keines, also galt die Vorgabe 16∶9 — während `HeroArtwork`
// auf einem schmalen Gerät ein **Plakat** anfordert, also hochkant.
// Zu sehen war davon ein Streifen aus der Mitte: das Bild wirkte
// angeschnitten und der Kopf der Seite viel zu flach.
//
// Beides kommt deshalb aus derselben Quelle und mit demselben
// Verwendungszweck: `detailHeader` bleibt hochkant, auch seit das
// Karussell auf dem Telefon quer ist.
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
) {
Button {
playback.play(item, mediaIndex: selectedVersion)
} label: {
Label(
item.watchProgress == nil ? "hero.play" : "hero.resume",
systemImage: "play.fill"
)
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(currentMedia?.files.first == nil)
if let progress = item.watchProgress, progress > 0 {
Button {
playback.play(item, mediaIndex: selectedVersion, resuming: false)
} label: {
Label("detail.playFromStart", systemImage: "gobackward")
.labelStyle(.iconOnly)
.font(.haloCardTitle)
.padding(HaloSpacing.snug)
}
.buttonStyle(.bordered)
.tint(Color.haloTextSecondary)
}
DownloadButton(item: item, mediaIndex: selectedVersion)
}
}
/// Die Zeile unter dem Folgentitel — mit Wegen nach oben.
///
/// Serienname und „Staffel 2" führen dorthin, wohin sie zeigen. Vorher
/// stand hier „S2 E1" als Text und darunter eine Reihe Knöpfe; wer zur
/// Serie wollte, musste erst den Knopf finden. Der Name der Serie steht
/// ohnehin da — dann soll er auch der Weg sein.
@ViewBuilder
private var episodeMetadata: some View {
let plain = plainPieces
HStack(spacing: 0) {
if let series = item.seriesTitle, let showID = item.seriesID {
NavigationLink(value: MediaRoute(ratingKey: showID, title: series)) {
Text(series).underline()
}
.buttonStyle(.plain)
separator
} else if let series = item.seriesTitle {
Text(series)
separator
}
if let season = item.parentIndex {
let title = String(localized: "season.number \(season)")
if let seasonID = item.parentID {
NavigationLink(value: MediaRoute(ratingKey: seasonID, title: title)) {
Text(title).underline()
}
.buttonStyle(.plain)
} else {
Text(title)
}
separator
}
if let episode = item.index {
// Über `String(localized:)` wie die Staffel darüber, nicht als
// `LocalizedStringKey`. Bei der Schlüsselform ist der
// Platzhalter nicht eindeutig: Xcode trug beim Auslesen
// `%@` ein, während die Laufzeit `%lld` nachschlägt — und
// zwischen zwei verschiedenen Schlüsseln findet man nie einen.
Text(String(localized: "episode.number \(episode)"))
if !plain.isEmpty { separator }
}
Text(plain.joined(separator: " · "))
}
.lineLimit(1)
}
private var separator: some View {
Text(" · ")
}
/// Was in der Zeile nur dasteht und nirgendwohin führt.
private var plainPieces: [String] {
var pieces: [String] = []
if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) }
if let resolution = currentMedia?.resolutionLabel { pieces.append(resolution) }
return pieces
}
private var metadataLine: String? {
var pieces: [String] = []
if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) }
if let resolution = currentMedia?.resolutionLabel { pieces.append(resolution) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
}
/// Auswahl zwischen mehreren Fassungen desselben Titels.
struct VersionPicker: View {
let media: [MediaVersion]
@Binding var selection: Int
var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
Text("detail.versions")
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
Picker("detail.versions", selection: $selection) {
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
Text(version.qualityLabel).tag(index)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
}
}
/// Genres als Streifen.
struct TagStrip: View {
let tags: [String]
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: HaloSpacing.snug) {
ForEach(tags, id: \.self) { tag in
Text(tag)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.tight)
.background(Color.haloElevated, in: .capsule)
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
}
.scrollIndicators(.never)
}
}
/// Die Besetzung mit Porträts.
struct CastRow: View {
let cast: [MediaPerson]
@Environment(HaloSession.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text("detail.cast")
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.screenMargin)
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: HaloSpacing.regular) {
ForEach(cast.prefix(20)) { role in
VStack(spacing: HaloSpacing.snug) {
HaloArtwork(
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
aspectRatio: 1,
fallbackSymbol: "person"
)
.frame(width: 72, height: 72)
.clipShape(.circle)
Text(role.name)
.font(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
.multilineTextAlignment(.center)
if let character = role.role {
Text(character)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
.frame(width: 90)
.accessibilityElement(children: .combine)
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
}
.scrollIndicators(.never)
}
}
}
+459
View File
@@ -0,0 +1,459 @@
import SwiftUI
import HaloDesign
import HaloDownloads
import MediaKit
/// Die Detailseite einer Serie oder Staffel.
///
/// Beide nutzen dieselbe Ansicht, weil sie sich nur im Inhalt der Liste
/// unterscheiden: eine Serie führt zu Staffeln, eine Staffel zu Folgen.
struct SeriesDetailView: View {
let item: MediaItem
@Environment(HaloSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var children: LoadState<[MediaItem]> = .loading
// MARK: - Mehrere Folgen auf einmal
/// Ob gerade ausgewählt wird.
///
/// **Ein eigener Zustand statt „leere Auswahl heisst aus".** Sonst ließe
/// sich der Modus nicht betreten, ohne sofort etwas anzukreuzen — und das
/// Abwählen der letzten Folge würde ihn wieder verlassen.
@State private var isSelecting = false
@State private var selection: Set<String> = []
@State private var sample: MediaItem?
@State private var showsQualityChoice = false
/// Stufe steht, Tonspur fehlt — gefragt wird einmal für den ganzen Stapel.
@State private var pendingQuality: DownloadQuality?
@State private var isWorking = false
@State private var summary: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.section) {
header
ParentLinks(item: item)
if let summary = item.summary, !summary.isEmpty {
Text(summary)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
.padding(.horizontal, HaloSpacing.screenMargin)
}
if !item.genres.isEmpty { TagStrip(tags: item.genres) }
// **Nur bei der Serie, nicht bei der Staffel.** Die Ebene
// heisst Serie; sie zweimal anzubieten hiesse, zwei Orte für
// dieselbe Einstellung zu haben, von denen einer nie gelesen
// wird.
if item.kind == .show {
AudioLanguagePicker(
scope: .series(item.id),
title: "languages.forThisSeries"
)
.padding(.horizontal, HaloSpacing.screenMargin)
}
childList
if !item.cast.isEmpty { CastRow(cast: item.cast) }
}
.padding(.bottom, HaloSpacing.section)
}
.scrollIndicators(.never)
.haloRevealsTopChrome()
.safeAreaInset(edge: .bottom, spacing: 0) { selectionBar }
.confirmationDialog(
Text("downloads.quality.title"),
isPresented: $showsQualityChoice,
titleVisibility: .visible
) {
let choice = DownloadQualityChoice(
item: sample ?? item,
backend: session.backend
)
ForEach(choice.available) { quality in
Button(choice.label(for: quality)) { chose(quality) }
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.quality.hint")
}
.confirmationDialog(
Text("downloads.audio.title"),
isPresented: Binding(
get: { pendingQuality != nil },
set: { if !$0 { pendingQuality = nil } }
),
titleVisibility: .visible
) {
// **Einmal für den Stapel.** Die Folgen einer Staffel sind gleich
// aufgebaut; zwanzigmal dieselbe Frage wäre keine Wahl, sondern
// eine Prüfung der Geduld.
if let muster = sample?.preferredVersion?.files.first {
let empfohlen = sample.map { downloadService.preferredAudioStream(in: muster, of: $0) }
ForEach(muster.audioStreams) { stream in
Button(
stream == empfohlen
? String(localized: "downloads.audio.recommended \(stream.name)")
: stream.name
) {
if let pendingQuality {
Task { await loadSelected(quality: pendingQuality, audioStreamIndex: stream.index) }
}
}
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.audio.hint")
}
.alert(
Text("downloads.batch.title"),
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
) {
Button("action.close") { summary = nil }
} message: {
if let summary { Text(summary) }
}
.task(id: item.id) { await loadChildren() }
}
private var header: some View {
HeroBanner(
title: item.rowTitle,
metadata: metadataLine,
summary: nil,
// Über `HeroArtwork`, nicht von Hand: hier standen 900×1200 —
// 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.
artworkURLs: session.backend.map {
HeroArtwork.backdropURLs(
for: item,
backend: $0,
placement: .detailHeader,
compact: sizeClass == .compact
)
} ?? [],
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath),
// **Dasselbe Verhältnis, in dem das Bild bestellt wurde.** Hier
// stand keines, also galt die Vorgabe 16∶9 — während `HeroArtwork`
// auf einem schmalen Gerät ein **Plakat** anfordert, also hochkant.
// Zu sehen war davon ein Streifen aus der Mitte: das Bild wirkte
// angeschnitten und der Kopf der Seite viel zu flach.
//
// Beides kommt deshalb aus derselben Quelle und mit demselben
// Verwendungszweck: `detailHeader` bleibt hochkant, auch seit das
// Karussell auf dem Telefon quer ist.
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
) {
DownloadAllButton(container: item)
}
}
@ViewBuilder
private var childList: some View {
switch children {
case .loading:
ProgressView().frame(maxWidth: .infinity).padding(.vertical, HaloSpacing.section)
case .failed(let message):
Text(message)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.padding(.horizontal, HaloSpacing.screenMargin)
case .loaded(let entries):
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
// als von allem anderen auf dieser Seite.
HStack {
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
Spacer()
if entries.contains(where: { $0.kind == .episode }) {
selectionControls(for: entries)
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
LazyVStack(spacing: 0) {
ForEach(entries) { entry in
row(for: entry)
Divider().overlay(Color.haloSeparator)
}
}
}
}
}
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
@ViewBuilder
private func row(for entry: MediaItem) -> some View {
if isSelecting, entry.kind == .episode {
let gewaehlt = selection.contains(entry.id)
// Hier darf der Kreis **im** Label stehen: die ganze Zeile ist der
// Knopf, es gibt nichts zu verschachteln.
Button {
if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) }
} label: {
HStack(spacing: HaloSpacing.snug) {
ChildRow(item: entry)
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(gewaehlt ? accent : Color.haloTextMuted)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.padding(.horizontal, HaloSpacing.screenMargin)
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
} else {
// **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im
// Label eines `NavigationLink` löst auf iOS nie aus — der Link
// macht sein Label zu einer einzigen Fläche, und `.plain` lässt das
// Versagen lautlos geschehen. Dieselbe Falle steht in
// `QuickPlayTile` beschrieben.
//
// Als Geschwister in einer Reihe statt als Überzug: dann teilt das
// Layout den Platz auf, und der Titel kann gar nicht erst unter
// dem Knopf verschwinden.
HStack(spacing: HaloSpacing.snug) {
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
ChildRow(item: entry)
}
.buttonStyle(.plain)
// **Nur bei Folgen.** Für eine ganze Staffel gibt es den Knopf
// oben im Kopf der Seite; zweimal dasselbe Angebot auf einem
// Bildschirm ist keine Abkürzung, sondern eine Frage.
if entry.kind == .episode {
DownloadButton(item: entry, loadsDetails: true)
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
}
}
@ViewBuilder
private func selectionControls(for entries: [MediaItem]) -> some View {
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
HStack(spacing: HaloSpacing.regular) {
if isSelecting {
Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") {
selection = selection.count == folgen.count ? [] : Set(folgen)
}
Button("action.cancel") {
isSelecting = false
selection = []
}
.foregroundStyle(Color.haloTextSecondary)
} else {
Button("detail.select") { isSelecting = true }
}
}
.font(.haloCaption)
.buttonStyle(.plain)
.foregroundStyle(accent)
}
/// Die Leiste unten, solange etwas angekreuzt ist.
///
/// Als Sicherheitsrand und nicht als Überzug: so endet die Liste darüber,
/// statt dass die letzte Folge dahinter verschwindet.
@ViewBuilder
private var selectionBar: some View {
if isSelecting, !selection.isEmpty {
Button {
Task { await askForQuality() }
} label: {
Group {
if isWorking {
ProgressView().controlSize(.small)
} else {
Label(
String(localized: "detail.loadSelected \(selection.count)"),
systemImage: "arrow.down.circle"
)
}
}
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(isWorking)
.padding(.vertical, HaloSpacing.regular)
.frame(maxWidth: .infinity)
.background(.bar)
}
}
private var selectedEpisodes: [MediaItem] {
guard case .loaded(let entries) = children else { return [] }
return entries.filter { selection.contains($0.id) }
}
/// Holt die Dateiangaben **einer** Folge und fragt dann nach der Qualität.
///
/// Eine reicht: die Folgen einer Staffel sind in derselben Fassung
/// kodiert, und zwanzig Anfragen für eine Frage, die einmal gestellt wird,
/// wären zwanzig zu viel. Geladen wird danach jede Folge einzeln — dort
/// zählt wieder ihre eigene Größe.
private func askForQuality() async {
isWorking = true
defer { isWorking = false }
if let erste = selectedEpisodes.first {
sample = (try? await session.backend?.item(id: erste.id)) ?? erste
}
showsQualityChoice = true
}
private var downloadService: DownloadService {
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
}
/// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge.
private func chose(_ quality: DownloadQuality) {
if let muster = sample, downloadService.needsAudioChoice(for: muster, quality: quality) {
pendingQuality = quality
} else {
Task { await loadSelected(quality: quality, audioStreamIndex: nil) }
}
}
private func loadSelected(quality: DownloadQuality, audioStreamIndex: Int?) async {
pendingQuality = nil
isWorking = true
defer { isWorking = false }
let result = await downloadService.download(
selectedEpisodes, quality: quality, audioStreamIndex: audioStreamIndex
)
var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")]
if let erster = result.refused.first {
// Der erste Grund reicht: bei einem erreichten Speicherlimit ist
// er für alle übrigen derselbe.
zeilen.append(erster.1.explanation)
}
if result.unavailable > 0 {
zeilen.append(String(localized: "downloads.batch.unavailable \(result.unavailable)"))
}
summary = zeilen.joined(separator: "\n")
isSelecting = false
selection = []
}
private var metadataLine: String? {
var pieces: [String] = []
// Bei einer Staffel steht oben der Serienname — welche Staffel es
// ist, muss deshalb hierher.
if item.kind == .season, let number = item.index {
pieces.append(String(localized: "season.short \(number)"))
}
if let year = item.year { pieces.append(String(year)) }
if let episodeCount = item.episodeCount {
pieces.append(String(localized: "detail.episodeCount \(episodeCount)"))
}
if let viewed = item.playedEpisodeCount, let total = item.episodeCount, viewed < total {
pieces.append(String(localized: "detail.unwatchedCount \(total - viewed)"))
}
if let rating = item.contentRating { pieces.append(rating) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
private func loadChildren() async {
guard let server = session.backend else { return }
children = await LoadState.load {
try await server.children(of: item.id)
}
}
}
/// Eine Staffel- oder Folgenzeile.
private struct ChildRow: View {
let item: MediaItem
@Environment(HaloSession.self) private var session
var body: some View {
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(
path: item.thumb,
width: item.kind == .season ? 120 : 240,
height: item.kind == .season ? 180 : 135
),
aspectRatio: item.kind == .season ? PosterCard.aspectRatio : LandscapeCard.aspectRatio,
fallbackSymbol: "play.rectangle"
)
.frame(width: item.kind == .season ? 48 : 96)
.clipShape(.rect(cornerRadius: 4))
.overlay(alignment: .bottom) {
if let progress = item.watchProgress {
WatchProgressBar(fraction: progress)
}
}
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
Text(rowTitle)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
if let subtitle {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
Spacer()
if item.isWatched {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("item.watched"))
}
}
// Das waagerechte Polster liegt jetzt eine Ebene höher, bei der
// Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am
// Bildschirmrand.
.padding(.vertical, HaloSpacing.snug)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
private var rowTitle: String {
if item.kind == .episode, let episode = item.index {
return "\(episode). \(item.title)"
}
return item.title
}
private var subtitle: String? {
switch item.kind {
case .season:
item.episodeCount.map { String(localized: "detail.episodeCount \($0)") }
case .episode:
item.duration.map { Duration.milliseconds($0).runtimeLabel }
default:
nil
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import SwiftUI
import HaloDesign
import HaloPlayback
import MediaKit
/// Die aufklappbaren technischen Angaben zu einer Datei.
///
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Halo sie
/// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die
/// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding
/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird.
struct TechnicalDetails: View {
let media: MediaVersion
let part: MediaFile
@Binding var isExpanded: Bool
/// Was mit dieser Datei passieren würde.
///
/// **Mit Serverhilfe gerechnet, nicht ohne.** Hier stand `.directPlayOnly`
/// — damals eine brauchbare Auskunft, weil ohne Serverhilfe die zweite
/// Engine übernahm und auch so hiess. Seit es sie nicht mehr gibt, kam
/// dort „Direkt (Systemplayer)" heraus, direkt über der Zeile „iOS öffnet
/// den Container MKV nicht" — zwei Angaben, die sich widersprechen, und
/// die zweite stimmte.
///
/// Ohne Bandbreitengrenze, weil sie von der Verbindung abhängt und diese
/// Seite sie nicht kennt. Gezeigt wird damit der Fall im WLAN.
private var decision: PlaybackDecision {
PlaybackDecider().decide(
profile: media.profile(file: part),
policy: PlaybackPolicy(allowsTranscoding: true)
)
}
var body: some View {
DisclosureGroup(isExpanded: $isExpanded) {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
row("technical.playbackMethod", playbackMethodLabel)
if let reason = rationaleLabel {
row("technical.reason", reason)
}
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
if let container = part.container { row("technical.container", container.uppercased()) }
if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
if let width = media.width, let height = media.height {
row("technical.resolution", "\(width) × \(height)")
}
if let bitrate = media.bitrate {
row("technical.bitrate", "\(bitrate / 1000) Mbit/s")
}
if let size = part.size {
row("technical.size", size.formatted(.byteCount(style: .file)))
}
if !part.audioStreams.isEmpty {
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
ForEach(part.audioStreams) { stream in
row("technical.audio", trackLabel(stream))
}
}
if !part.subtitleStreams.isEmpty {
ForEach(part.subtitleStreams) { stream in
row("technical.subtitle", trackLabel(stream))
}
}
if let file = part.file {
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
VStack(alignment: .leading, spacing: 2) {
Text("technical.file")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Text(file)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.haloTextSecondary)
.textSelection(.enabled)
}
}
}
.padding(.top, HaloSpacing.snug)
} label: {
Text("technical.title")
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
}
.tint(Color.haloTextSecondary)
}
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
HStack(alignment: .top) {
Text(label)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Spacer()
Text(value)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
.multilineTextAlignment(.trailing)
}
}
private func trackLabel(_ stream: MediaStream) -> String {
stream.displayTitle ?? [stream.language, stream.codec?.uppercased()]
.compactMap(\.self)
.joined(separator: " · ")
}
private var playbackMethodLabel: String {
switch decision.method {
case .directPlay:
String(localized: "technical.directPlayNative")
// **Zwei Fälle unter einem Namen wären eine Lüge.** Umgepackt bleibt
// das Bild unberührt — der Ton nur dann, wenn AVPlayer ihn kennt.
case .remux:
decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } }
? String(localized: "technical.remuxAudio")
: String(localized: "technical.remux")
case .transcode:
String(localized: "technical.transcode")
}
}
/// Der erste Grund, der gegen den Systemplayer sprach — mehrere
/// aufzuzählen hilft niemandem, es reicht einer.
private var rationaleLabel: String? {
for reason in decision.rationale {
switch reason {
case .containerUnsupported(let container):
return String(localized: "technical.reason.container \(container.uppercased())")
case .videoCodecUnsupported(let codec):
return String(localized: "technical.reason.videoCodec \(codec.uppercased())")
case .audioCodecUnsupported(let codec):
return String(localized: "technical.reason.audioCodec \(codec.uppercased())")
case .streamsUnknown:
return String(localized: "technical.reason.unanalyzed")
case .qualityChosen:
return String(localized: "technical.reason.chosen")
case .nativelySupported, .bitrateExceedsLimit, .transcodingUnavailable:
continue
}
}
return nil
}
}
+320
View File
@@ -0,0 +1,320 @@
import SwiftUI
import HaloDesign
import HaloDownloads
import MediaKit
/// Der Download-Knopf auf einer Detailseite.
///
/// Zeigt vier Zustände in einem Knopf: nicht geladen, lädt gerade, offline
/// verfügbar, gescheitert. Ein getrennter Knopf je Zustand würde die
/// Kopfzeile überfüllen — und der Zustand ist ohnehin immer genau einer.
struct DownloadButton: View {
let item: MediaItem
var mediaIndex: Int = 0
/// Ob die Dateiangaben erst noch geholt werden müssen.
///
/// **Für Zeilen, die nur eine Zusammenfassung tragen.** Die Folgenliste
/// einer Staffel kommt ohne Fassungen und ohne Dateigrößen — mit so einem
/// Titel wäre die Qualitätsliste leer und die erwartete Größe null, was
/// die Prüfung auf freien Speicherplatz stillschweigend überspringt.
/// `DownloadService.downloadAll` holt jede Folge aus genau diesem Grund
/// einzeln nach.
///
/// Geholt wird erst beim ersten Tipp: zwanzig Zeilen beim Erscheinen
/// nachzuladen wären zwanzig Anfragen für einen Knopf, den man meistens
/// nicht drückt.
var loadsDetails: Bool = false
@Environment(HaloSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent
@State private var refusal: DownloadRefusal?
@State private var showsQualityChoice = false
/// Der nachgeholte Titel — gilt ab dann statt `item`.
@State private var detailed: MediaItem?
@State private var isLoadingDetails = false
/// Die Stufe steht fest, die Tonspur fehlt noch.
///
/// **Weil die Wahl nach dem Herunterladen nicht mehr zu korrigieren ist.**
/// Beim Umpacken legt der Server genau eine Tonspur in die Datei; welche,
/// hat vorher die Abwägung entschieden — und bei einer Aufnahme mit
/// Audiodeskription die falsche. Drei Gigabyte später gab es keinen Weg
/// zurück.
@State private var pendingQuality: DownloadQuality?
private var existing: DownloadItem? {
downloads.items.first { $0.ratingKey == item.id }
}
/// Der Titel, aus dem Fassungen und Größen gelesen werden.
///
/// Die Kennung ist in beiden dieselbe — nur die Dateiangaben fehlen dem
/// einen.
private var source: MediaItem { detailed ?? item }
var body: some View {
Button {
act()
} label: {
Group {
if isLoadingDetails {
ProgressView().controlSize(.small)
} else {
Label(label, systemImage: symbol)
.labelStyle(.iconOnly)
.font(.haloCardTitle)
}
}
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
.frame(width: 22, height: 22)
.padding(HaloSpacing.snug)
}
.disabled(isLoadingDetails)
.buttonStyle(.bordered)
.tint(existing?.state == .completed ? accent : Color.haloTextSecondary)
.accessibilityLabel(Text(label))
.confirmationDialog(
Text("downloads.quality.title"),
isPresented: $showsQualityChoice,
titleVisibility: .visible
) {
ForEach(choice.available) { quality in
Button(choice.label(for: quality)) { chose(quality) }
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.quality.hint")
}
.confirmationDialog(
Text("downloads.audio.title"),
isPresented: Binding(
get: { pendingQuality != nil },
set: { if !$0 { pendingQuality = nil } }
),
titleVisibility: .visible
) {
ForEach(audioStreams) { stream in
Button(audioLabel(stream)) {
if let pendingQuality { enqueue(quality: pendingQuality, audioStream: stream) }
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.audio.hint")
}
.alert(
Text("downloads.refused.title"),
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
) {
Button("action.close") { refusal = nil }
} message: {
if let refusal { Text(refusal.explanation) }
}
}
private var symbol: String {
switch existing?.state {
case .completed: "checkmark.circle.fill"
case .downloading, .queued, .waiting: "stop.circle"
case .paused: "play.circle"
case .failed: continues ? "play.circle" : "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90"
case nil: "arrow.down.circle"
}
}
private var label: LocalizedStringKey {
switch existing?.state {
case .completed: "downloads.available"
case .downloading, .queued, .waiting: "downloads.cancel"
case .paused: "downloads.resume"
case .failed: continues ? "downloads.resume" : "action.retry"
case nil: "downloads.download"
}
}
/// Ob ein Tipp dort weitermacht, wo es aufgehört hat.
///
/// Ein gescheiterter Download hat seinen Wiederaufnahme-Datensatz nur,
/// wenn ihn das Netz unterbrochen hat — hat der Server abgelehnt, fängt
/// es von vorn an, und dann heißt der Knopf auch so.
private var continues: Bool { existing?.resumeData != nil }
private func act() {
guard let existing else {
// Vor dem Laden fragen. Der Unterschied zwischen einem
// 40-GB-Remux und 2 GB in 720p ist auf einem Telefon keine
// Nebensache — und hinterher lässt er sich nicht mehr ändern.
guard loadsDetails, detailed == nil else {
showsQualityChoice = true
return
}
Task { await loadDetailsThenAsk() }
return
}
switch existing.state {
case .completed:
// Ein fertiger Download wird hier nicht angetastet. Löschen geht
// über die Downloads-Ansicht — ein Fehlgriff in der Kopfzeile
// soll nicht 40 GB verwerfen.
break
case .downloading, .queued, .waiting:
Task { await downloads.cancel(existing) }
case .paused, .failed:
Task { await downloads.resume(existing) }
}
}
/// Holt die Dateiangaben nach und fragt dann nach der Qualität.
///
/// Scheitert das Nachholen, wird trotzdem gefragt — dann steht nur
/// „Original" zur Wahl, und die Ablehnung kommt mit einer Begründung aus
/// `DownloadService`. Das ist besser als ein Knopf, der nach einem Tipp
/// kommentarlos nichts tut.
private func loadDetailsThenAsk() async {
isLoadingDetails = true
detailed = try? await session.backend?.item(id: item.id)
isLoadingDetails = false
showsQualityChoice = true
}
private var service: DownloadService {
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
}
private var audioStreams: [MediaStream] {
let media = source.versions.indices.contains(mediaIndex)
? source.versions[mediaIndex] : source.preferredVersion
return media?.files.first?.audioStreams ?? []
}
/// Die Spur, die vorgeschlagen würde — gemerkte Sprache der Serie zuerst.
private var recommended: MediaStream? {
let media = source.versions.indices.contains(mediaIndex)
? source.versions[mediaIndex] : source.preferredVersion
guard let part = media?.files.first else { return nil }
return service.preferredAudioStream(in: part, of: source)
}
private func audioLabel(_ stream: MediaStream) -> String {
stream == recommended
? String(localized: "downloads.audio.recommended \(stream.name)")
: stream.name
}
/// Nach der Stufe: entweder fragen oder einreihen.
private func chose(_ quality: DownloadQuality) {
if service.needsAudioChoice(for: source, mediaIndex: mediaIndex, quality: quality) {
pendingQuality = quality
} else {
enqueue(quality: quality, audioStream: nil)
}
}
private func enqueue(quality: DownloadQuality, audioStream: MediaStream?) {
pendingQuality = nil
do {
_ = try service.download(
source, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream
)
} catch {
refusal = error
}
}
/// Was zur Wahl steht — siehe `DownloadQualityChoice`.
private var choice: DownloadQualityChoice {
DownloadQualityChoice(item: source, mediaIndex: mediaIndex, backend: session.backend)
}
}
extension DownloadQuality {
var localizedName: String.LocalizationValue {
switch self {
case .original: "quality.original"
case .fullHD: "quality.fullHD"
case .hd: "quality.hd"
case .low: "quality.lowRes"
}
}
}
/// Der Knopf für eine ganze Staffel oder Serie.
struct DownloadAllButton: View {
let container: MediaItem
@Environment(HaloSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent
@State private var isWorking = false
@State private var summary: String?
var body: some View {
Button {
Task { await downloadAll() }
} label: {
if isWorking {
ProgressView().controlSize(.small)
} else {
Label(
container.kind == .show ? "downloads.allEpisodes" : "downloads.season",
systemImage: "arrow.down.circle"
)
}
}
.buttonStyle(.bordered)
.tint(Color.haloTextSecondary)
.disabled(isWorking)
.alert(
Text("downloads.batch.title"),
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
) {
Button("action.close") { summary = nil }
} message: {
if let summary { Text(summary) }
}
}
private func downloadAll() async {
isWorking = true
defer { isWorking = false }
let service = DownloadService(
backend: session.backend, coordinator: downloads, languages: languages
)
let result = await service.downloadAll(in: container)
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
if !result.refused.isEmpty {
// Der erste Grund reicht: bei einem erreichten Speicherlimit ist
// er für alle übrigen derselbe.
lines.append(result.refused[0].1.explanation)
}
if result.unavailable > 0 {
lines.append(String(localized: "downloads.batch.unavailable \(result.unavailable)"))
}
summary = lines.joined(separator: "\n")
}
}
extension DownloadRefusal {
/// Der Grund im Klartext.
var explanation: String {
switch self {
case .alreadyExists:
String(localized: "downloads.refused.exists")
case .storageLimitReached(let limit, let used):
String(localized: "downloads.refused.limit \(used.formatted(.byteCount(style: .file))) \(limit.formatted(.byteCount(style: .file)))")
case .noDownloadableFile(let reason):
reason
case .insufficientDeviceSpace(let required, let available):
String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))")
}
}
}
@@ -0,0 +1,67 @@
import Foundation
import HaloDownloads
import MediaKit
/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen.
///
/// **Als eigener Typ, weil es die Frage inzwischen an zwei Stellen gibt:** am
/// Knopf einer einzelnen Folge und an der Leiste, mit der man mehrere auf
/// einmal lädt. Dieselbe Entscheidung zweimal zu schreiben hiesse, dass sie
/// sich auseinanderentwickelt — und die erste Kopie wäre die mit dem Fehler.
///
/// Kein `View`: hier steht keine Zeile SwiftUI, nur die Frage, was angeboten
/// werden darf und wie es beschriftet ist.
struct DownloadQualityChoice {
let item: MediaItem
var mediaIndex: Int = 0
let backend: (any MediaBackend)?
/// Nur Stufen, die unter der Auflösung der Datei liegen.
///
/// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt.
/// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die
/// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage.
var available: [DownloadQuality] {
let alle = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
guard allowsCompressed else { return [.original] }
return alle
}
/// Die Beschriftung samt Auflösung und geschätzter Größe.
///
/// Beim Original steht dabei, was es überhaupt ist: „Original · 720p".
/// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade
/// herunterrechnet oder nicht.
func label(for quality: DownloadQuality) -> String {
let originalBytes = currentMedia?.files.first?.size ?? 0
let bytes = quality.estimatedBytes(
durationMilliseconds: item.duration,
originalBytes: originalBytes
)
var name = String(localized: quality.localizedName)
if quality == .original, let resolution = currentMedia?.resolutionLabel {
name += " · \(resolution)"
}
guard bytes > 0 else { return name }
let size = bytes.formatted(.byteCount(style: .file))
return quality == .original
? "\(name) · \(size)"
: String(localized: "downloads.quality.approx \(name) \(size)")
}
private var currentMedia: MediaVersion? {
item.versions.indices.contains(mediaIndex) ? item.versions[mediaIndex] : item.preferredVersion
}
private var allowsCompressed: Bool {
backend?.compressedDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
maximumBitrate: 8_000,
resolution: nil
) != nil
}
}
+414
View File
@@ -0,0 +1,414 @@
import SwiftUI
import HaloDesign
import HaloDownloads
import HaloPlayback
/// Was offline ist und was gerade geladen wird.
struct DownloadsView: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackCoordinator.self) private var playback
@Environment(NetworkConditions.self) private var network
@Environment(\.haloAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
Group {
if downloads.items.isEmpty {
ContentUnavailableView {
Label("downloads.empty.title", systemImage: "arrow.down.circle")
} description: {
Text("downloads.empty.description")
}
} else {
list
}
}
.background(Color.haloCanvas)
.navigationTitle(Text("sidebar.downloads"))
.toolbar {
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
// mehr, und dies ist der Weg dorthin.
if sizeClass == .compact {
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
}
}
.haloInlineTitle()
// Keine Rückfrage beim Entfernen.
//
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
// aber nur die heruntergeladene Kopie: der Titel bleibt auf dem Server
// und ist mit einem Tippen wieder da. Wer nach links wischt und auf
// „Löschen" tippt, hat zweimal gesagt, was er will — ein drittes Mal
// zu fragen ist keine Vorsicht, sondern Aufhalten.
}
/// Was die Übertragung gerade aufhält.
private var hold: TransferHold {
TransferHold.current(
isWiFiOnly: downloads.isWiFiOnly,
isReachable: network.isReachable,
isExpensive: network.isExpensive
)
}
private var list: some View {
List {
StorageSection()
// Erst was läuft, dann was fertig ist. Wer die Ansicht öffnet,
// während etwas lädt, will meistens genau das sehen.
if !active.isEmpty {
Section("downloads.active") {
// Die Hintergrund-Sitzung wartet in diesen Fällen von
// sich aus: kein Fehler, nur Stillstand. Ohne diesen
// Hinweis sieht ein wartender Download genauso aus wie
// ein hängender.
if hold.isHolding { HoldNotice(hold: hold) }
ForEach(active) { item in
DownloadRow(
item: item,
hold: hold,
onRemove: { Task { await downloads.cancel(item) } }
)
}
}
}
ForEach(completedGroups, id: \.title) { group in
Section(group.title) {
ForEach(group.items) { item in
// Fertige Downloads wartet nichts mehr auf.
DownloadRow(item: item, hold: .none, onRemove: { Task { await downloads.cancel(item) } })
.contentShape(.rect)
.onTapGesture { play(item) }
}
}
}
}
.scrollContentBackground(.hidden)
.listRowBackground(Color.haloSurface)
.haloRevealsTopChrome()
}
private var active: [DownloadItem] {
downloads.items.filter { $0.state != .completed }
}
/// Fertige Downloads, nach Serie gruppiert; Filme stehen zusammen.
private var completedGroups: [(title: String, items: [DownloadItem])] {
let completed = downloads.items.filter { $0.state == .completed }
let grouped = Dictionary(grouping: completed) {
$0.seriesTitle ?? String(localized: "downloads.movies")
}
return grouped
.map { (title: $0.key, items: $0.value.sorted(by: episodeOrder)) }
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
}
private func episodeOrder(_ lhs: DownloadItem, _ rhs: DownloadItem) -> Bool {
(lhs.seasonIndex ?? 0, lhs.episodeIndex ?? 0) < (rhs.seasonIndex ?? 0, rhs.episodeIndex ?? 0)
}
private func play(_ item: DownloadItem) {
guard item.state == .completed else { return }
playback.play(PlaybackRequest(
ratingKey: item.ratingKey,
title: item.title,
mediaIndex: 0,
startTime: .zero
))
}
}
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
private struct StorageSection: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.haloAccent) private var accent
var body: some View {
@Bindable var downloads = downloads
Section {
HStack {
Text("downloads.used")
.foregroundStyle(Color.haloTextSecondary)
Spacer()
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
.foregroundStyle(Color.haloTextPrimary)
}
if let limit = downloads.storageLimitBytes, limit > 0 {
ProgressView(value: min(Double(downloads.usedBytes) / Double(limit), 1))
.tint(accent)
.accessibilityLabel(Text("downloads.used"))
}
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
}
}
}
/// Eine Zeile in der Downloads-Ansicht.
private struct DownloadRow: View {
let item: DownloadItem
let hold: TransferHold
let onRemove: () -> Void
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.haloAccent) private var accent
var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
HStack(spacing: HaloSpacing.regular) {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle = item.subtitle {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
Spacer()
action
}
if item.state != .completed {
progress
}
// Nur bei dem, was tatsächlich warten würde. Ein vom Nutzer
// angehaltener Download wartet nicht auf WLAN, sondern auf ihn.
if hold.isHolding, item.state == .downloading || item.state == .queued {
Label(
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
if let message = item.failureMessage {
Text(message)
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
.lineLimit(2)
}
}
.padding(.vertical, HaloSpacing.tight)
.listRowBackground(Color.haloSurface)
.swipeActions(edge: .trailing) {
Button("action.delete", role: .destructive, action: onRemove)
}
}
@ViewBuilder
private var action: some View {
switch item.state {
case .downloading:
Button {
Task { await downloads.pause(item) }
} label: {
Image(systemName: "pause.circle").font(.title3)
}
.buttonStyle(.plain)
.foregroundStyle(accent)
.accessibilityLabel(Text("downloads.pause"))
case .paused, .failed:
// **Fortsetzen und erneut versuchen sind nicht dasselbe**, und
// der Knopf hat beides gleich genannt. „Fortsetzen" stand auch
// dort, wo es nichts fortzusetzen gab — eine Zusage, die der Code
// nicht halten konnte: ein gescheiterter Download fing wieder bei
// null an. Jetzt entscheidet der Wiederaufnahme-Datensatz, und er
// entsteht genau dann, wenn eine Übertragung unterbrochen wurde.
Button {
Task { await downloads.resume(item) }
} label: {
Image(systemName: continues ? "play.circle" : "arrow.clockwise.circle")
.font(.title3)
}
.buttonStyle(.plain)
.foregroundStyle(accent)
.accessibilityLabel(Text(continues ? "downloads.resume" : "action.retry"))
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("downloads.available"))
case .queued, .waiting:
Image(systemName: "clock")
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("downloads.queued"))
}
}
private var progress: some View {
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
// Download.
if let value = item.progress {
ProgressView(value: value).tint(accent)
} else {
ProgressView().progressViewStyle(.linear).tint(accent)
}
// **Links die Zeile, rechts die Prozente — und beides steht
// still.**
//
// Vorher war es eine einzige linksbündige Zeile, und sie zappelte
// bei jeder Aktualisierung: in einer Proportionalschrift ist die
// 1 schmal und die 8 breit, also verschob „181,2 → 190,1" alles
// dahinter. Zweimal je Sekunde.
//
// Drei Dinge halten es jetzt ruhig: Ziffern in fester Breite, ein
// gemeinsames Mass für beide Zahlen (siehe `unit`), und die
// Prozente am rechten Rand — die wachsen nach innen und schieben
// nichts.
HStack(alignment: .firstTextBaseline, spacing: HaloSpacing.snug) {
Text(statusLine)
.lineLimit(1)
Spacer(minLength: HaloSpacing.snug)
if let percentage {
Text(percentage)
}
}
.font(.haloCaption)
.monospacedDigit()
.foregroundStyle(Color.haloTextMuted)
}
}
/// Der Fortschritt in Prozent — `nil`, solange die Gesamtgröße fehlt.
///
/// Die Zahl, nach der man eigentlich sieht. „181,2 MB von 890,2 MB" sagt
/// dasselbe, aber man muss es ausrechnen.
private var percentage: String? {
guard item.state != .completed, let anteil = item.progress else { return nil }
return anteil.formatted(.percent.precision(.fractionLength(0)))
}
/// Ob ein Antippen dort weitermacht, wo es aufgehört hat.
///
/// Ein angehaltener Download hat seinen Wiederaufnahme-Datensatz immer;
/// ein gescheiterter nur, wenn ihn das Netz unterbrochen hat und nicht der
/// Server abgelehnt.
private var continues: Bool {
item.state == .paused || item.resumeData != nil
}
/// Eine Byte-Zahl mit **fester** Nachkommastelle.
///
/// **Der letzte Rest des Zappelns.** `.byteCount` lässt die Null hinter
/// dem Komma weg: aus „186,0 MB" wird „186 MB", und die Zeile ist zwei
/// Zeichen kürzer. Gemessen an sechs aufeinanderfolgenden Sekunden war das
/// die einzige, die noch sprang.
///
/// `usage: .asProvided`, damit die Einheit bleibt, die hier gewählt wurde
/// — sonst rechnet die Formatierung eigenmächtig um.
private func size(_ value: Int64) -> String {
let (menge, einheit): (Double, UnitInformationStorage) = switch value {
case 1_000_000_000...: (Double(value) / 1_000_000_000, .gigabytes)
case 1_000_000...: (Double(value) / 1_000_000, .megabytes)
case 1_000...: (Double(value) / 1_000, .kilobytes)
default: (Double(value), .bytes)
}
return Measurement(value: menge, unit: einheit).formatted(
.measurement(
width: .abbreviated,
usage: .asProvided,
numberFormatStyle: .number.precision(
.fractionLength(einheit == .bytes ? 0 : 1)
)
)
)
}
/// Wie schnell es gerade geht — `nil`, solange nichts gemessen ist.
private var speed: String? {
guard let perSecond = downloads.rate(forRatingKey: item.ratingKey)?.bytesPerSecond,
perSecond > 0
else { return nil }
return String(localized: "downloads.perSecond \(size(Int64(perSecond)))")
}
/// Wie lange es bei diesem Tempo noch dauert.
private var remaining: String? {
guard let rate = downloads.rate(forRatingKey: item.ratingKey),
let rest = rate.remaining(received: item.receivedBytes, total: item.totalBytes)
else { return nil }
// Unter einer Minute keine Zahl: „noch 0 Min" wäre falsch und „noch
// 43 Sek" genauer, als die Schätzung ist.
guard rest >= .seconds(60) else {
return String(localized: "downloads.remaining.underMinute")
}
return String(localized: "downloads.remaining \(rest.runtimeLabel)")
}
private var statusLine: String {
switch item.state {
case .downloading where item.totalBytes > 0:
// **Jede Zahl in ihrem eigenen Mass.** Beide auf das des Ziels zu
// zwingen war der naheliegende Weg gegen den Einheitenwechsel —
// und er machte den Anfang unlesbar: bei einem Ziel von 2 GB
// stand dort „0,03 GB / 2 GB". Gegen das Zappeln hilft die feste
// Ziffernbreite; der Wechsel von MB auf GB kommt einmal je
// Download und ist der kleinere Preis.
["\(size(item.receivedBytes)) / \(size(item.totalBytes))", speed, remaining]
.compactMap(\.self).joined(separator: " · ")
case .downloading:
// Ohne bekannte Gesamtgröße gibt es keine Restzeit — das Tempo
// aber schon, und es ist genau dort die einzige Auskunft darüber,
// ob überhaupt etwas durchgeht.
[size(item.receivedBytes), speed].compactMap(\.self).joined(separator: " · ")
case .paused:
String(localized: "downloads.paused")
case .queued, .waiting:
String(localized: "downloads.queued")
case .failed:
String(localized: "downloads.failed")
case .completed:
item.totalBytes.formatted(.byteCount(style: .file))
}
}
}
/// Der Hinweis über der Liste, wenn nichts läuft und nichts kaputt ist.
private struct HoldNotice: View {
let hold: TransferHold
@Environment(\.haloAccent) private var accent
var body: some View {
HStack(alignment: .top, spacing: HaloSpacing.regular) {
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
.font(.title3)
.foregroundStyle(accent)
VStack(alignment: .leading, spacing: 2) {
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
Text(hold == .offline
? "downloads.hold.offline.description"
: "downloads.hold.wifi.description")
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
.padding(.vertical, HaloSpacing.tight)
.listRowBackground(Color.haloSurface)
.accessibilityElement(children: .combine)
}
}
+358
View File
@@ -0,0 +1,358 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
///
/// 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.
///
/// **Ü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.
///
/// Jetzt rutscht das neue Bild nur ein kleines Stück herein und legt sich
/// dabei über das alte. Nichts wandert weit genug, als dass ein Versatz
/// auffiele.
///
/// **Es wird nicht gekreuzt, sondern überdeckt.** Beim Kreuzblenden sind
/// beide Bilder gleichzeitig halbdurchsichtig — und dazwischen scheint der
/// Untergrund durch, was als helles Aufblitzen des vorigen Titels zu sehen
/// war. Hier bleibt das alte Bild voll deckend liegen, bis das neue darüber
/// undurchsichtig geworden ist; erst dann verschwindet es. Zu jedem Zeitpunkt
/// ist die Fläche gedeckt.
struct HeroCarousel: View {
let items: [MediaItem]
/// Wie lange ein Titel stehen bleibt.
///
/// Lang genug, um Titel, Beschreibung und Knöpfe zu lesen — bei drei
/// 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?
/// Der Titel, der noch darunter liegt und gleich verschwindet.
///
/// Er wird voll deckend gezeigt, nicht ausgeblendet — das Ausblenden
/// übernimmt das neue Bild, indem es ihn zudeckt.
@State private var fadingID: String?
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
@State private var cover: CGFloat = 1
/// 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
/// 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(\.haloAccent) private var accent
@Environment(HaloSession.self) private var session
@Environment(\.horizontalSizeClass) private var sizeClass
@Environment(TopChrome.self) private var chrome
@Environment(HomeLayoutSettings.self) private var layout
/// Wie weit der Aufmacher nach oben unter die Insel greift.
///
/// Der Inhalt beginnt unter der Insel — das ist für Reihen und Listen
/// richtig, für den Aufmacher aber nicht: er ist das Bild, auf dem die
/// Insel schweben soll, und ein Bild, das an ihrer Unterkante anfängt,
/// lässt darüber einen schwarzen Streifen stehen, den nie etwas füllt.
///
/// Also nimmt er sich den Platz zurück: um genau diese Strecke höher und
/// um genau diese Strecke größer. Unten ändert sich dadurch nichts, die
/// Reihen bleiben, wo sie waren.
///
/// Auf dem iPhone null — dort schwebt nichts über dem Inhalt.
private var overlap: CGFloat {
sizeClass == .compact ? 0 : chrome.inset
}
/// Der Aufmacher — ein einziger, der sein Bild wechselt.
///
/// **Nicht zwei übereinander.** So war es, und jeder der beiden brachte
/// seinen eigenen Verlauf nach unten mit; zwei halbdurchsichtige Verläufe
/// ergeben zusammen keinen ganzen. Gemessen an derselben Stelle im Bild:
/// im Ruhezustand 68, in der Mitte des Wechsels 137 — sichtbar als
/// Aufhellen genau dort, wo der Titel steht. Jetzt wechselt nur das Bild,
/// Verlauf und Text liegen einmal darüber.
@ViewBuilder
private var heroSection: some View {
if items.indices.contains(currentIndex) {
HeroSection(
item: items[currentIndex],
outgoing: fadingID.flatMap { kennung in
items.first { $0.id == kennung }
},
crossfade: cover,
incomingOffset: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide)
)
// Ohne eigene Identität je Titel: die Ansicht **bleibt**, und nur
// ihre Angaben wechseln. Eine neue Identität hiesse, sie würde
// eingefügt und entfernt — und SwiftUI blendete sie dabei ein,
// zusätzlich zur Überblendung, die hier gemeint ist.
}
}
private var currentIndex: Int {
items.firstIndex { $0.id == shownID } ?? 0
}
var body: some View {
VStack(spacing: HaloSpacing.regular) {
heroSection
// Die Höhe: fast das ganze Fenster.
//
// Ein Aufmacher, der nur ein Drittel hoch ist, sieht aus wie ein
// breites Vorschaubild — einer über die ganze Höhe dagegen wie die
// einzige Seite der App: von den Reihen war nichts mehr zu sehen,
// ohne zu scrollen. Zweiundachtzig Prozent lassen die erste Reihe
// gerade hereinlugen, und damit weiß man, dass es weitergeht.
//
// Auf einem schmalen Gerät nicht: dort rechnet sich der Aufmacher
// seine Höhe aus der Breite aus — ein Viertel breiter als hoch.
.modifier(HeroHeight(fraction: sizeClass == .compact ? nil : layout.heroHeight, extra: overlap))
// **Kein** Seitenverhältnis hier: der Aufmacher bringt sein
// eigenes mit, und eines darüber legte ihn auf die Breite des
// Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste
// greift, blieb dann rechts genau so viel schwarz übrig, wie er
// links gewonnen hatte — auf dem Mac ein Streifen von gut zweihundert
// Punkten.
// **Nicht** beschneiden: der Aufmacher greift bewusst nach links
// über seinen Bereich hinaus, damit sein Bild unter der
// Seitenleiste durchläuft. Ein `.clipped()` schnitt genau das ab —
// und übrig blieb ein schwarzer Streifen neben der Leiste.
//
// Für den Platz ist es auch nicht nötig: das Hereinrutschen läuft
// über `offset`, und das verschiebt nur, was gezeichnet wird.
// 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.
// Wischen blättert weiter — aber erst ab einem ordentlichen Weg
// und nur, wenn es waagerecht gemeint ist.
//
// Vorher stand hier `minimumDistance: 0`. Damit griff die Geste
// sofort und nahm der Seite das senkrechte Scrollen weg: wer auf
// dem Aufmacher nach oben wischte, bei dem passierte einfach
// nichts. Vierundzwanzig Punkte lassen dem Scrollen den Vortritt.
.simultaneousGesture(
DragGesture(minimumDistance: 24)
.onEnded { value in
let width = value.translation.width
guard abs(width) > abs(value.translation.height), abs(width) > 40 else { return }
isAdvancing = false
step(by: width < 0 ? 1 : -1)
}
)
// Das Weiterwechseln hält an, sobald jemand etwas antippt. Getrennt
// von der Wischgeste, weil ein Tippen kein Ziehen ist — und weil
// 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 }
}
// Zieht den ganzen Aufmacher unter die Insel. Zusammen mit der
// zusätzlichen Höhe oben hebt sich das unten auf: er beginnt am
// Bildschirmrand und endet, wo er vorher endete.
.padding(.top, -overlap)
.task(id: shownID) { await advance() }
.onChange(of: items.map(\.id), initial: true) { _, ids in
// Beim ersten Zeichnen und nach jedem Aktualisieren: steht die
// gezeigte Kennung nicht mehr in der Liste, beginnt es von vorn.
if shownID == nil || !ids.contains(shownID ?? "") { shownID = ids.first }
}
}
private var isMac: Bool { HaloPlatform.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, HaloSpacing.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(HaloSpacing.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: isEnlarged ? HaloSpacing.regular : HaloSpacing.snug) {
ForEach(items.indices, id: \.self) { position in
Capsule()
.fill(position == currentIndex ? accent : Color.haloTextMuted.opacity(0.4))
.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, HaloSpacing.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 }
let next = (currentIndex + direction + items.count) % items.count
isMovingForward = direction > 0
fadingID = shownID
shownID = items[next].id
cover = 0
withAnimation(.easeInOut(duration: 0.55)) {
cover = 1
} completion: {
// Erst wenn oben nichts mehr durchscheint, darf unten etwas
// verschwinden. Andersherum gäbe es genau den Moment, in dem man
// durch beide hindurch auf den Untergrund sieht.
fadingID = nil
}
}
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, !items.isEmpty else { return }
let item = items[(currentIndex + 1) % items.count]
let compact = sizeClass == .compact
// Hintergrundbild und Schriftzug zusammen: fehlt der Schriftzug beim
// Überblenden, steht dort erst der gesetzte Titel und das Logo springt
// eine Sekunde später hinterher — zwei Bewegungen, wo eine sein soll.
let urls = [
HeroArtwork.backdropURL(for: item, backend: backend, compact: compact),
HeroArtwork.logoURL(for: item, backend: backend),
]
for url in urls.compactMap(\.self) {
Task { _ = await ArtworkLoader.shared.image(for: url) }
}
}
}
/// Gibt dem Aufmacher einen Anteil an der sichtbaren Höhe — oder gar nichts.
///
/// Eigener Baustein, weil `containerRelativeFrame` sich nicht abschalten lässt:
/// es wirkt, sobald es dasteht. Ein `if` in der Ansicht brächte den beiden
/// Zweigen außerdem verschiedene Identitäten ein, und der Aufmacher würde beim
/// Drehen neu geladen statt mitzuwandern.
private struct HeroHeight: ViewModifier {
let fraction: CGFloat?
/// Was über den Anteil hinaus dazukommt: der Platz unter der Insel, den
/// der Aufmacher mitfüllt, weil er nach oben unter sie greift.
let extra: CGFloat
func body(content: Content) -> some View {
if let fraction {
content.containerRelativeFrame(.vertical) { height, _ in height * fraction + extra }
} else {
content
}
}
}
+99
View File
@@ -0,0 +1,99 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Der Aufmacher oben auf der Startseite.
struct HeroSection: View {
let item: MediaItem
/// Der Titel, dessen Bild gerade verschwindet — nur im Karussell gesetzt.
var outgoing: MediaItem?
/// Wie weit das neue Bild schon deckt.
var crossfade: CGFloat = 1
/// Wie weit es dabei hereinrutscht.
var incomingOffset: CGFloat = 0
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
@Environment(\.haloAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
HeroBanner(
title: item.rowTitle,
metadata: metadata,
summary: item.summary,
artworkURLs: session.backend.map {
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
} ?? [],
outgoingArtworkURLs: session.backend.flatMap { backend in
outgoing.map { HeroArtwork.backdropURLs(for: $0, backend: backend, compact: sizeClass == .compact) }
} ?? [],
outgoingTitle: outgoing?.rowTitle,
outgoingMetadata: outgoing.flatMap(Self.metadata(for:)),
outgoingLogoURL: session.backend.flatMap { backend in
outgoing.flatMap { HeroArtwork.logoURL(for: $0, backend: backend) }
},
crossfade: crossfade,
incomingOffset: incomingOffset,
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
// Auf dem iPhone 4:3 statt 16:9: hochkant ließ 16:9 dem Text so
// wenig Platz, dass Schriftzug und Beschreibung übereinanderlagen.
// Beschnitten statt hochkant — das Bild bleibt ein Querbild.
aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact),
// Auf einem großen Bildschirm nimmt der Aufmacher die Höhe, die
// das Karussell ihm gibt — sonst wäre er so hoch, wie 16:9 es
// vorschreibt, und darüber und darunter bliebe der halbe
// Bildschirm leer.
fillsAvailableSpace: sizeClass != .compact
) {
Button {
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
// neue Folgen als ihre Serie —, schickte der direkte Weg
// deren eigene Kennung an den Player, und der scheiterte mit
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
quickPlay.start(item, backend: session.backend, coordinator: playback)
} label: {
Label(playLabel, systemImage: "play.fill")
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
Label("hero.details", systemImage: "info.circle")
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.bordered)
.tint(Color.haloTextSecondary)
}
}
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
private var playLabel: LocalizedStringKey {
item.watchProgress == nil ? "hero.play" : "hero.resume"
}
private var metadata: String? { Self.metadata(for: item) }
/// Die Zeile unter dem Titel.
///
/// `static`, weil sie während eines Wechsels auch für den weichenden
/// Titel gebraucht wird — und der ist nicht `item`.
static func metadata(for item: MediaItem) -> String? {
var pieces: [String] = []
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
pieces.append(String(localized: "episode.short \(season) \(episode)"))
}
if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
}
+86
View File
@@ -0,0 +1,86 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
///
/// **Nur noch Darstellung.** Was auf der Startseite steht — laden, ordnen,
/// den Aufmacher auslosen — liegt in `HomeFeed`, weil der Fernseher dieselben
/// Reihen zeigt und die Logik sonst zweimal dastünde. Kopiert wäre dabei
/// ausgerechnet der Teil gewesen, an dem schon mehrere Fehler steckten.
struct HomeView: View {
@Environment(HaloSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@State private var feed = HomeFeed()
var body: some View {
Group {
switch feed.state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await feed.load(from: session.backend) } }
}
case .loaded:
let hubs = feed.rows(for: layout)
if hubs.isEmpty {
ContentUnavailableView {
Label("home.empty.title", systemImage: "film.stack")
} description: {
Text("home.empty.description")
}
} else {
content(hubs: hubs)
}
}
}
.background(Color.haloCanvas)
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
// ohnehin mehr.
.navigationTitle("")
.haloInlineTitle()
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
// Aufmacher.
.haloClearToolbarBackground()
// Gar keine Navigationsleiste auf der Startseite.
//
// Sie war leer — kein Titel, keine Knöpfe — und belegte trotzdem ihre
// Höhe. Der Aufmacher fing dadurch tiefer an als die Seitenleiste
// daneben, und genau diese Kante sah man. Auf den geschobenen
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
.toolbar(.hidden, for: .navigationBar)
.task(id: session.serverName) { await feed.load(from: session.backend) }
.refreshable { await feed.load(from: session.backend) }
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
.environment(\.haloItemChanged, { await feed.load(from: session.backend) })
}
private func content(hubs: [MediaRowGroup]) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
let featured = feed.heroItems(for: layout)
if !featured.isEmpty {
HeroCarousel(items: featured)
}
ForEach(hubs) { hub in
MediaRow(row: hub)
}
}
.padding(.bottom, HaloSpacing.section)
}
.scrollIndicators(.never)
.haloRevealsTopChrome()
}
}
+81
View File
@@ -0,0 +1,81 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Eine waagerechte Reihe von Titeln.
struct MediaRow: View {
let row: MediaRowGroup
/// Wird durchgereicht — siehe `QuickPlayTile`.
var opensDetails: Bool = false
@Environment(HaloSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
private var landscapeWidth: CGFloat { 220 * layout.cardScale }
/// Schmaler, weil hochkant: bei gleicher Höhe passen so mehr Plakate
/// nebeneinander, und mehr Auswahl auf einen Blick ist genau der Zweck.
///
/// Beide mit demselben Faktor: ihr Verhältnis zueinander soll stimmen,
/// egal wie groß es insgesamt eingestellt ist.
private var posterWidth: CGFloat { 116 * layout.cardScale }
var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text(row.title)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.screenMargin)
ScrollView(.horizontal) {
LazyHStack(spacing: HaloSpacing.regular) {
ForEach(row.items) { item in
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
// bleibt an beliebiger Stelle stehen.
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.scrollIndicators(.never)
}
}
/// Szenenbild beim Weiterschauen, sonst das Plakat.
///
/// Angefangenes erkennt man an der Szene, an der man aufgehört hat —
/// dafür ist das Querformat richtig. Wer dagegen aussucht, was er als
/// Nächstes sehen will, sucht nach Plakaten.
@ViewBuilder
private func card(for item: MediaItem) -> some View {
if row.isContinueWatching {
LandscapeCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(
path: item.rowArtworkPath,
width: 440,
height: 248
),
progress: item.watchProgress
)
.frame(width: landscapeWidth)
} else {
PosterCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(
path: item.posterArtworkPath,
width: 300,
height: 450
),
progress: item.watchProgress
)
.frame(width: posterWidth)
}
}
}
+167
View File
@@ -0,0 +1,167 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln.
///
/// **Was hier wegfällt, ist ein Zwischenschritt.** Vorher stand an dieser
/// Stelle eine Liste aller Mediatheken; wer Filme sehen wollte, tippte auf
/// „Bibliothek", dann auf „Filme", und bekam erst dann ein Raster. Zurück und
/// in eine andere hiess: zweimal tippen. Der Zwischenbildschirm hat nie etwas
/// erzählt, was die Leiste nicht auch zeigen kann.
///
/// Auf dem iPad und am Mac gibt es ihn ohnehin nicht — dort steht jede
/// Mediathek einzeln in der Seitenleiste, und auf dem Fernseher nebeneinander
/// in der oberen Leiste. Das hier ist dieselbe Idee in der Form, die ein
/// Telefon hergibt.
///
/// **Warum eine zweite Leiste und nicht Plätze in der unteren.** Die hat
/// fünf, und alles darüber schiebt das System in ein „Mehr"-Verzeichnis,
/// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst
/// gezeichnete Leiste kennt diese Grenze nicht.
struct LibraryBrowser: View {
@Environment(HaloSession.self) private var session
/// Was gerade gezeigt wird.
///
/// `nil` heisst „noch nichts gewählt" — dann gilt die erste Mediathek.
/// Eine Vorauswahl beim Anlegen ginge nicht: die Mediatheken kommen vom
/// Server und stehen beim ersten Aufbau noch nicht fest.
@State private var auswahl: Auswahl?
private enum Auswahl: Hashable {
case library(String)
case liveTV
}
var body: some View {
inhalt
.background(Color.haloCanvas)
// **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste
// über der Leiste, statt dahinter zu verschwinden — und das gilt
// auch für die Ladeanzeige und den Fuss „alles geladen".
.safeAreaInset(edge: .bottom, spacing: 0) {
if eintraege.count > 1 { leiste }
}
}
@ViewBuilder
private var inhalt: some View {
switch aktuell {
case .library(let id):
if let library = session.libraries.first(where: { $0.id == id }) {
// **Mit `id`, damit der Wechsel sauber neu aufsetzt.** Ohne
// das behielte die Ansicht die Titel der vorigen Mediathek,
// bis der Server antwortet — und für einen Augenblick stünden
// Filme unter „Serien".
LibraryView(library: library).id(library.id)
}
case .liveTV:
LiveTVView()
case nil:
ContentUnavailableView {
Label("libraryHub.empty", systemImage: "books.vertical")
}
}
}
// MARK: - Die Leiste
private var leiste: some View {
// **So breit wie nötig, nicht wie möglich.** Über die ganze Breite
// gezogen stand sie linksbündig in einer halbleeren Kapsel — eine
// Leiste, die dreimal so lang ist wie ihr Inhalt, sieht aus wie ein
// Fehler im Layout.
//
// `ViewThatFits` nimmt die erste Fassung, die hineinpasst: die
// ungescrollte Reihe, solange sie passt, sonst die scrollbare. Damit
// ist beides richtig, ohne dass irgendwo eine Zahl stünde, ab wann es
// kippt — bei vier Mediatheken passt es, bei acht nicht, und keine
// Fassung muss das wissen.
ViewThatFits(in: .horizontal) {
reihe
ScrollView(.horizontal) { reihe }
.scrollBounceBehavior(.basedOnSize)
.scrollIndicators(.never)
}
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
// kein nachgebautes Aussehen.
.background { HaloSidebar.surface(in: .capsule) }
.clipShape(.capsule)
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
.padding(.horizontal, HaloSpacing.regular)
.padding(.bottom, HaloSpacing.snug)
}
private var reihe: some View {
HStack(spacing: 0) {
ForEach(eintraege, id: \.auswahl) { eintrag in
pille(eintrag)
}
}
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.snug)
}
private func pille(_ eintrag: Eintrag) -> some View {
let gewaehlt = eintrag.auswahl == aktuell
return Button {
auswahl = eintrag.auswahl
} label: {
Label {
Text(eintrag.titel).lineLimit(1)
} icon: {
Image(systemName: eintrag.symbol)
}
// Kleiner als in der Leiste auf dem iPad: dort stehen drei
// Einträge, hier bis zu fünf — und auf einem Telefon.
.font(.haloCaption)
.foregroundStyle(gewaehlt ? Color.haloTextPrimary : Color.haloTextSecondary)
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.snug)
.background {
if gewaehlt { Capsule().fill(Color.haloElevated) }
}
.contentShape(Capsule())
}
.buttonStyle(.plain)
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
}
// MARK: - Was in der Leiste steht
private struct Eintrag {
let auswahl: Auswahl
let titel: String
let symbol: String
}
private var eintraege: [Eintrag] {
var liste = session.libraries.map {
Eintrag(auswahl: .library($0.id), titel: $0.title, symbol: $0.kind.symbol)
}
// Live-TV wohnte im Zwischenbildschirm und bräuchte sonst ein neues
// Zuhause. Hier steht es, wo man es sucht: neben den Mediatheken.
if session.supportsLiveTV {
liste.append(Eintrag(
auswahl: .liveTV,
titel: String(localized: "sidebar.liveTV"),
symbol: MediaLibrary.Kind.liveTV.symbol
))
}
return liste
}
/// Die gültige Auswahl — die gespeicherte, solange es sie noch gibt.
///
/// **Nicht einfach `auswahl`.** Nach einem Serverwechsel zeigt die
/// gespeicherte Kennung auf eine Mediathek, die es nicht mehr gibt; dann
/// stünde ein leerer Bildschirm da, ohne dass irgendetwas erklärt, warum.
private var aktuell: Auswahl? {
if let auswahl, eintraege.contains(where: { $0.auswahl == auswahl }) {
return auswahl
}
return eintraege.first?.auswahl
}
}
+132
View File
@@ -0,0 +1,132 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Filter und Sortierung einer Mediathek.
struct LibraryFilterSheet: View {
@Binding var query: MediaQuery
/// Die Mediathek selbst, nicht nur ihre Art.
///
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
/// Mediathek dieser Art nachgeschlagen — bei zwei Filmmediatheken also
/// über die falsche. Die Sprache je Mediathek braucht die Kennung
/// ohnehin, und damit verschwindet die Suche nach der Art gleich mit.
let library: MediaLibrary
@Environment(\.dismiss) private var dismiss
@Environment(\.haloAccent) private var accent
@Environment(HaloSession.self) private var session
@State private var genres: [MediaFilterOption] = []
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
///
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
@State private var draft: MediaQuery
init(query: Binding<MediaQuery>, library: MediaLibrary) {
self._query = query
self.library = library
self._draft = State(initialValue: query.wrappedValue)
}
var body: some View {
NavigationStack {
Form {
Section("filter.sort") {
Picker("filter.sort", selection: $draft.sort) {
ForEach(MediaQuery.Sort.allCases, id: \.self) { sort in
Text(sort.label).tag(sort)
}
}
.labelsHidden()
.pickerStyle(.inline)
}
Section("filter.filters") {
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
Picker("filter.genre", selection: $draft.genre) {
Text("filter.any").tag(String?.none)
ForEach(genres) { genre in
Text(genre.title).tag(String?.some(genre.id))
}
}
Picker("filter.resolution", selection: $draft.resolution) {
Text("filter.any").tag(String?.none)
Text("4K").tag(String?.some("4k"))
Text("Full HD").tag(String?.some("1080"))
Text("HD").tag(String?.some("720"))
Text("SD").tag(String?.some("sd"))
}
}
// **Kein Filter, und deshalb ein eigener Abschnitt.** Ein
// Filter schränkt ein, was zu sehen ist; das hier bestimmt,
// was zu hören ist. Zwischen die Filter gestellt läse es sich
// als „nur Titel mit deutscher Tonspur zeigen".
Section {
AudioLanguagePicker(
scope: .library(library.id),
title: "languages.forThisLibrary"
)
} header: {
Text("filter.librarySettings")
} footer: {
Text("languages.forThisLibraryExplanation")
}
Section {
Button("filter.reset", role: .destructive) {
draft = MediaQuery(sort: draft.sort)
}
.disabled(!draft.isFiltering)
}
}
// Ausdrücklich, nicht nur geerbt.
//
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent)
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text("filter.title"))
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("action.apply") {
query = draft
dismiss()
}
}
}
.task { await loadGenres() }
}
.haloResizableSheet()
}
private func loadGenres() async {
guard let server = session.backend else { return }
genres = (try? await server.filterOptions(inLibrary: library, field: .genre)) ?? []
}
}
extension MediaQuery.Sort {
var label: LocalizedStringKey {
switch self {
case .titleAscending: "sort.titleAscending"
case .titleDescending: "sort.titleDescending"
case .recentlyAdded: "sort.recentlyAdded"
case .releaseDate: "sort.releaseDate"
case .rating: "sort.rating"
case .lastPlayed: "sort.lastPlayed"
}
}
}
+419
View File
@@ -0,0 +1,419 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Eine Mediathek zum Durchblättern.
struct LibraryView: View {
let library: MediaLibrary
@Environment(HaloSession.self) private var session
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
/// zwischen Raster und Liste.
@Environment(HomeLayoutSettings.self) private var sizing
@AppStorage("halo.library.layout") private var layout: LibraryLayout = .grid
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var items: [MediaItem] = []
@State private var state: LoadState<Void> = .loading
@State private var query = MediaQuery()
@State private var page: MediaPage?
/// Ob der letzte Nachladen misslungen ist.
///
/// Dann steht unten eine Zeile zum Weiterversuchen: von selbst passiert
/// nichts mehr, weil die Kacheln, die das Nachladen auslösen, längst
/// erschienen sind und kein zweites Mal erscheinen.
@State private var loadMoreFailed = false
@State private var isLoadingMore = false
@State private var showsFilters = false
var body: some View {
Group {
switch state {
case .loading where items.isEmpty:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message) where items.isEmpty:
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await reload() } }
}
default:
if items.isEmpty {
ContentUnavailableView {
Label("library.empty.title", systemImage: "line.3.horizontal.decrease.circle")
} description: {
Text("library.empty.description")
} actions: {
if query.isFiltering {
Button("library.clearFilters") {
query = MediaQuery(sort: query.sort)
}
}
}
} else {
contents
}
}
}
.background(Color.haloCanvas)
.navigationTitle(library.title)
.haloInlineTitle()
.toolbar { toolbar }
.sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, library: library)
}
.task(id: library.id) {
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
// `reload()` die Titel der vorigen für die eigenen und fragt den
// Speicher gar nicht erst.
items = []
page = nil
await reload()
}
// Jede Änderung an Filter oder Sortierung verwirft die bisherige
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
// verschiedenen Abfragen vermischen.
.onChange(of: query) { _, _ in Task { await reload() } }
.refreshable { await reload() }
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
.environment(\.haloItemChanged, { await reload() })
}
@ViewBuilder
private var contents: some View {
switch layout {
case .grid: grid
case .list: list
}
}
private var grid: some View {
ScrollView {
LazyVGrid(
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
// ohne dass die Größenklasse abgefragt werden muss.
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: HaloSpacing.regular)],
spacing: HaloSpacing.loose
) {
ForEach(items) { item in
QuickPlayTile(item: item) {
PosterCard(
title: item.title,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.watchProgress
)
}
.task { await loadMoreIfNeeded(reaching: item) }
}
// Was noch kommt, steht schon da — in der Form, die es haben
// wird. Das beantwortet die Frage, die ein aufhörendes Raster
// offen lässt: war das alles?
ForEach(0..<pendingCount, id: \.self) { position in
PlaceholderCard(index: position)
}
}
.padding(HaloSpacing.screenMargin)
moreButton.padding(.bottom, HaloSpacing.section)
footerCount.padding(.bottom, HaloSpacing.section)
}
.haloRevealsTopChrome()
}
private var list: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
LibraryListRow(item: item)
}
.buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) }
Divider().overlay(Color.haloSeparator)
}
}
if pendingCount > 0 {
ProgressView()
.padding(.vertical, HaloSpacing.section)
.frame(maxWidth: .infinity)
}
moreButton.padding(.vertical, HaloSpacing.section)
footerCount.padding(.bottom, HaloSpacing.section)
}
.haloRevealsTopChrome()
}
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
///
/// Beide Server nennen die Gesamtzahl: Jellyfin als `TotalRecordCount`,
/// Plex als `totalSize`. Damit ist die Frage „war das alles?" beantwortbar,
/// statt dass man am Ende des Rasters rät.
///
/// Gedeckelt, weil zweihundert Platzhalter niemandem helfen: ein Dutzend
/// sagt „es geht weiter" genauso gut und hält die Liste flüssig.
private var pendingCount: Int {
guard !loadMoreFailed, let page, page.hasMore else { return 0 }
return min(page.totalCount - items.count, 12)
}
/// Die Zeile am Ende, wenn wirklich alles da ist.
///
/// Ohne sie bleibt der Zweifel: ein Raster, das aufhört, sieht genauso
/// aus wie eines, das noch lädt.
@ViewBuilder
private var footerCount: some View {
if let page, !page.hasMore, !items.isEmpty {
Text("library.allLoaded \(items.count)")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.frame(maxWidth: .infinity)
}
}
/// Der Weg weiter, wenn das Nachladen hängen geblieben ist.
@ViewBuilder
private var moreButton: some View {
if loadMoreFailed, let page, page.hasMore {
VStack(spacing: HaloSpacing.snug) {
Text("library.loadMoreFailed")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Button("library.loadMore") {
Task { await loadMore() }
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity)
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
// Nur auf schmalen Geräten: dort hat die Suche keinen eigenen Eintrag
// in der Leiste unten. Die Begründung dafür war lange, die fünf
// Plätze würden für die Mediatheken gebraucht — seit die in einer
// eigenen Leiste darüber stehen, stimmt das nicht mehr. Geblieben
// ist der bessere Grund: gesucht wird in einer Mediathek, und dort
// steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste,
// und ein zweiter Weg dorthin wäre nur Verdopplung.
if sizeClass == .compact {
ToolbarItem(placement: .haloTrailing) {
NavigationLink(value: SearchRoute()) {
Image(systemName: "magnifyingglass")
}
.accessibilityLabel(Text("library.search"))
}
}
// **Neu laden.** Auf dem Mac und dem iPad gibt es kein Ziehen von
// oben, und selbst dort wo es das gibt, ist es in einem Raster kaum
// zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen
// Knopf und keine Geste.
ToolbarItem(placement: .haloTrailing) {
Button {
Task { await reload() }
} label: {
Image(systemName: "arrow.clockwise")
}
.accessibilityLabel(Text("library.reload"))
.disabled(isLoadingMore)
}
ToolbarItem(placement: .haloTrailing) {
Button {
showsFilters = true
} label: {
Image(systemName: query.isFiltering
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(Text("library.filters"))
}
ToolbarItem(placement: .haloTrailing) {
Button {
layout = layout == .grid ? .list : .grid
} label: {
Image(systemName: layout == .grid ? "list.bullet" : "square.grid.2x2")
}
.accessibilityLabel(Text("library.toggleLayout"))
}
// **Ganz rechts, und deshalb hier unten.** Das Monogramm ist der Weg
// in die Einstellungen, seit es auf dem iPhone keinen Tab dafür mehr
// gibt. Von aussen angehängt stünde es **vor** den übrigen Knöpfen —
// SwiftUI reiht Werkzeugleisten von aussen nach innen. Wo in der
// Reihe etwas steht, kann nur der entscheiden, der die Reihe
// schreibt.
if sizeClass == .compact {
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
}
}
// MARK: - Laden
/// Lädt die erste Seite — erst aus dem Speicher, dann vom Server.
///
/// **Der gespeicherte Stand zuerst, ohne Ladekreis.** Die Plakate liegen
/// ohnehin schon auf der Platte; was fehlte, war die Liste, die sagt,
/// welche davon hierhergehören. Mit ihr steht die Mediathek sofort da.
///
/// **Dann der Server, stillschweigend.** Kein zweiter Ladekreis über einer
/// Liste, die schon zu sehen ist — die Titel werden ersetzt, sobald die
/// Antwort da ist, und in aller Regel sieht man davon nichts, weil sich
/// nichts geändert hat.
///
/// **Und wenn der Server nicht antwortet**, bleibt der gespeicherte Stand
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
/// Fehlerbildschirm.
private func reload() async {
guard let server = session.backend else { return }
let schlüssel = query.cacheKey
let kennung = server.identity
// Nur wenn nichts dasteht. Beim Herunterziehen und beim Filterwechsel
// ist das Gezeigte aktueller als jeder Abzug.
if items.isEmpty,
let stand = await LibraryCache.shared.snapshot(
server: kennung, library: library.id, queryKey: schlüssel
) {
items = stand.items
page = MediaPage(items: stand.items, totalCount: stand.totalCount)
state = .loaded(())
} else if items.isEmpty {
state = .loading
}
do {
let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60)
items = first.items
page = first
loadMoreFailed = false
state = .loaded(())
await LibraryCache.shared.store(
LibraryCache.Snapshot(
queryKey: schlüssel, items: first.items, totalCount: first.totalCount
),
server: kennung,
library: library.id
)
} catch {
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
// sich als Übertragungsfehler, nicht als Abbruch.
if Task.isCancelled { return }
// Steht schon etwas da — aus dem Speicher oder von vorhin —, dann
// bleibt es. Den Fehler über eine brauchbare Liste zu legen
// nähme dem Nutzer etwas weg, statt ihm etwas zu sagen.
guard items.isEmpty else { return }
state = .failed(message: ErrorText.describe(error))
}
}
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
private func loadMoreIfNeeded(reaching item: MediaItem) async {
guard
// Fünf Einträge vor Schluss: früh genug, dass das Nachgeladene da
// ist, bevor jemand am Ende ankommt, und spät genug, dass nicht
// jedes Scrollen eine Anfrage auslöst.
items.suffix(5).contains(where: { $0.id == item.id }),
// Nach einem Fehlschlag **nicht** von selbst: sonst läuft bei
// jedem Scrollen eine Anfrage in dieselbe tote Leitung. Von dort
// übernimmt der Knopf.
!loadMoreFailed
else { return }
await loadMore()
}
/// Holt die nächste Seite — von selbst oder auf Knopfdruck.
private func loadMore() async {
guard let server = session.backend,
let page, page.hasMore,
!isLoadingMore
else { return }
isLoadingMore = true
loadMoreFailed = false
defer { isLoadingMore = false }
do {
let next = try await server.items(
inLibrary: library,
query: query,
offset: page.nextOffset,
limit: 60
)
// Plex kann bei gleichzeitigem Serverscan denselben Titel auf zwei
// Seiten liefern. Ohne diese Prüfung stünde er doppelt im Raster —
// und SwiftUI beschwert sich über doppelte Kennungen.
let known = Set(items.map(\.id))
items.append(contentsOf: next.items.filter { !known.contains($0.id) })
self.page = next
} catch {
// **Nicht `page = nil`.** Das stand hier, und es beendete das
// Nachladen **endgültig**: eine einzige misslungene Anfrage auf
// einer wackeligen Leitung, und die Mediathek blieb für immer bei
// sechzig Titeln stehen — lautlos, ohne dass irgendetwas darauf
// hindeutete. Genau so sieht „es sind nicht alle Filme drin" aus.
//
// Die Seite bleibt also, damit ein zweiter Versuch möglich ist.
// Ein Fehler-Sheet mitten im Scrollen wäre trotzdem störend —
// stattdessen erscheint unten eine Zeile zum Weiterladen.
loadMoreFailed = true
}
}
}
enum LibraryLayout: String, CaseIterable {
case grid, list
}
/// Eine Zeile in der Listenansicht.
private struct LibraryListRow: View {
let item: MediaItem
@Environment(HaloSession.self) private var session
var body: some View {
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
aspectRatio: PosterCard.aspectRatio
)
.frame(width: 44)
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle = item.cardSubtitle {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
}
Spacer()
if item.isWatched {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("item.watched"))
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
.padding(.vertical, HaloSpacing.snug)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,121 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Welche Sender überhaupt angezeigt werden.
///
/// Der Empfänger liefert alles, was die Senderliste hergibt — bei einer
/// Swisscom-Liste sind das mehr als zwanzig, und die meisten sieht man nie an.
/// Jeden davon zu zeichnen und sein Programm zu holen kostet Zeit und macht
/// die Ansicht unübersichtlich.
struct ChannelSelectionSheet: View {
let channels: [LiveChannel]
@Binding var selection: Set<String>
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
ForEach(channels) { channel in
row(for: channel)
}
} footer: {
Text("liveTV.selection.hint")
}
}
.navigationTitle(Text("liveTV.selection"))
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .haloLeading) {
// „Alle" statt „keine": eine leere Auswahl bedeutet
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
// wäre verwirrend.
Button("liveTV.selection.all") { selection.removeAll() }
.disabled(selection.isEmpty)
}
ToolbarItem(placement: .confirmationAction) {
Button("action.done") { dismiss() }
}
}
}
}
private func row(for channel: LiveChannel) -> some View {
Button {
// Beim ersten Antippen aus „alle" eine echte Auswahl machen:
// sonst hakt man einen Sender an und alle anderen bleiben
// trotzdem sichtbar.
if selection.isEmpty {
selection = Set(channels.map(\.id)).subtracting([channel.id])
} else if selection.contains(channel.id) {
selection.remove(channel.id)
} else {
selection.insert(channel.id)
}
} label: {
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
aspectRatio: HaloAspectRatio.channelLogo,
fallbackSymbol: "tv",
contentMode: .fit
)
.frame(width: 48, height: 29)
if let number = channel.number {
Text(number)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.haloTextMuted)
}
Text(channel.title)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
Spacer()
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isShown(channel) ? accent : Color.haloTextMuted)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private func isShown(_ channel: LiveChannel) -> Bool {
ChannelSelection.shows(channel, selection: selection)
}
}
/// Die Regel, nach der ausgewählt wird.
///
/// Eigener Typ, weil sie an zwei Stellen gilt — in der Auswahl und in der
/// Anzeige — und beide dasselbe meinen müssen. Eine **leere** Auswahl bedeutet
/// „alle": so sieht man beim ersten Öffnen sein Programm, ohne vorher etwas
/// angehakt zu haben.
enum ChannelSelection {
static func shows(_ channel: LiveChannel, selection: Set<String>) -> Bool {
selection.isEmpty || selection.contains(channel.id)
}
static func apply(_ selection: Set<String>, to channels: [LiveChannel]) -> [LiveChannel] {
guard !selection.isEmpty else { return channels }
let kept = channels.filter { selection.contains($0.id) }
// Wählt jemand Sender ab, die es nicht mehr gibt, bliebe sonst eine
// leere Liste stehen, ohne dass er es ändern könnte.
return kept.isEmpty ? channels : kept
}
/// Die Auswahl als Text, wie sie in den Einstellungen liegt.
static func decode(_ stored: String) -> Set<String> {
Set(stored.split(separator: "\n").map(String.init))
}
static func encode(_ selection: Set<String>) -> String {
selection.sorted().joined(separator: "\n")
}
}
+469
View File
@@ -0,0 +1,469 @@
import SwiftUI
import HaloDesign
import HaloPlayback
import MediaKit
/// Live-TV mit Programmführer.
struct LiveTVView: View {
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(NetworkConditions.self) private var network
@State private var availability: LiveTVAvailability?
@State private var channels: [LiveChannel] = []
/// Woher die Senderliste kam — steht in der leeren Ansicht.
@State private var channelSource: String?
/// Jede abgeklopfte Adresse mit ihrer Antwort.
@State private var trace: [LiveChannelProbe] = []
@State private var programs: [String: [LiveProgram]] = [:]
@State private var isTuning: String?
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
@State private var tuneFailure: TuneFailure?
/// Raster, Senderliste oder Aufnahmen.
///
/// Alle drei sind berechtigt: das Raster zum Stöbern, die Liste zum
/// schnellen Umschalten, die Aufnahmen für das, was schon da ist. Die
/// Wahl bleibt erhalten, weil niemand sie zweimal treffen will.
///
/// Neuer Schlüssel statt des alten Wahrheitswerts: aus „zeigt das Raster"
/// ist eine Wahl aus dreien geworden, und die beiden ineinander zu
/// überführen wäre mehr Aufwand, als einmal beim Raster zu beginnen.
@AppStorage("liveTV.mode") private var mode: LiveTVMode = .guide
/// Welche Sender gezeigt werden. Leer heißt alle.
@AppStorage("liveTV.selection") private var storedSelection = ""
@State private var showsSelection = false
/// Die Sender nach der Auswahl — überall dieselbe Liste, damit Raster und
/// Liste nicht auseinanderlaufen.
private var shownChannels: [LiveChannel] {
ChannelSelection.apply(ChannelSelection.decode(storedSelection), to: channels)
}
var body: some View {
Group {
switch availability {
case nil:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .notConfigured:
// Der häufigste Fall — Live-TV verlangt eine Fernsehkarte oder
// einen Netzwerk-Tuner am Server. Ein Fehlerdialog wäre hier
// irreführend: es ist nichts kaputt, es ist nur nichts da.
ContentUnavailableView {
Label("liveTV.notConfigured.title", systemImage: "antenna.radiowaves.left.and.right.slash")
} description: {
Text("liveTV.notConfigured.description")
}
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await load() } }
}
case .available where channels.isEmpty && mode != .recordings:
// Empfänger gefunden, aber keine Sender. Ein anderer
// Zustand als „nicht eingerichtet", und die Unterscheidung
// zählt: sie sagt, ob es am Server liegt oder an der App.
ContentUnavailableView {
Label("liveTV.noChannels.title", systemImage: "tv")
} description: {
Text("liveTV.noChannels.description")
} actions: {
VStack(spacing: HaloSpacing.regular) {
Button("action.retry") { Task { await load() } }
searchTrace
}
}
case .available:
switch mode {
case .guide:
ProgramGuide(
channels: shownChannels,
programs: programs,
tuning: isTuning,
artwork: { channel in
session.backend?.artworkURL(path: channel.artworkPath, width: 120, height: 120)
},
onTune: { channel, program in Task { await tune(channel, program: program) } },
// Beide nur, wenn der Server aufnehmen kann. Sonst
// `nil`, und die Knöpfe erscheinen gar nicht erst.
onRecord: session.supportsRecordings
? { program in Task { await record(program, series: false) } }
: nil,
onRecordSeries: session.supportsRecordings
? { program in Task { await record(program, series: true) } }
: nil
)
case .channels:
channelList
case .recordings:
RecordingsView()
}
}
}
.background(Color.haloCanvas)
.navigationTitle(Text("sidebar.liveTV"))
.haloInlineTitle()
.toolbar {
if case .available = availability, !channels.isEmpty {
ToolbarItem(placement: .haloTrailing) {
Button {
showsSelection = true
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(Text("liveTV.selection"))
}
ToolbarItem(placement: .haloTrailing) {
Picker("liveTV.mode", selection: $mode) {
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
Image(systemName: "list.bullet").tag(LiveTVMode.channels)
// Der dritte Schalter fehlt schlicht, wo der Server
// nicht aufnehmen kann — bei Plex etwa.
if session.supportsRecordings {
Image(systemName: "recordingtape").tag(LiveTVMode.recordings)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
}
}
.task {
// Ein gespeicherter Modus, den dieser Server nicht kennt, führte
// sonst auf einen Schalter, den es nicht gibt — und damit auf
// einen leeren Bildschirm ohne Weg zurück.
if mode == .recordings, !session.supportsRecordings { mode = .guide }
await load()
}
.refreshable { await load() }
.sheet(isPresented: $showsSelection) {
ChannelSelectionSheet(
channels: channels,
selection: Binding(
get: { ChannelSelection.decode(storedSelection) },
set: { storedSelection = ChannelSelection.encode($0) }
)
)
}
// Ein Sheet statt eines Dialogs: die Antwort des Servers ist
// mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort
// wieder nur, dass es nicht ging.
.sheet(item: $tuneFailure) { failure in
TuneFailureSheet(failure: failure)
}
}
/// Wo überall gesucht wurde, und was dort stand.
///
/// Unbequem, aber nützlich: die Live-TV-Schnittstelle von Plex hat keine
/// Beschreibung, und „keine Sender" allein ist eine Sackgasse. Hier steht
/// jede abgeklopfte Adresse mit ihrer Antwort — das beantwortet die Frage
/// „liegt es am Server oder an der App" ohne eine weitere Runde.
@ViewBuilder
private var searchTrace: some View {
if !trace.isEmpty {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
if let channelSource {
Text(channelSource)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
ScrollView {
VStack(alignment: .leading, spacing: 3) {
ForEach(trace) { probe in
Text("\(probe.path) → \(probe.result)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Color.haloTextMuted)
.lineLimit(2)
.textSelection(.enabled)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 200)
Button {
HaloClipboard.copy(
trace
.map { "\($0.path) → \($0.result)" }
.joined(separator: "\n")
)
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.haloCaption)
}
.buttonStyle(.bordered)
}
.padding(.horizontal, HaloSpacing.screenMargin)
}
}
private var channelList: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(shownChannels) { channel in
ChannelRow(
channel: channel,
programs: programs[channel.id] ?? [],
isTuning: isTuning == channel.id,
onTune: { Task { await tune(channel) } }
)
Divider().overlay(Color.haloSeparator)
}
}
}
.haloRevealsTopChrome()
}
private func load() async {
guard let liveTV = session.liveTV else { return }
let availability = await liveTV.liveTVAvailability()
self.availability = availability
guard case .available = availability else { return }
// Sender und Programm kommen zusammen: die Senderliste des Anbieters
// antwortet auf manchen Servern gar nicht, und dann tragen die
// Rastereinträge die Sender selbst. Getrennt geholt passten die
// Kennungen zudem nicht zueinander, und das Programm landete in
// keiner Zeile.
// Sechs Stunden: ein ganzer Fernsehabend. Mehr wäre bei einer Liste
// mit fünfhundert Sendern eine Antwort im zweistelligen Megabereich,
// und über Mobilfunk wartet man darauf.
let listing = await liveTV.liveChannels()
channels = listing.channels
channelSource = listing.source
trace = listing.probes
let now = Date()
programs = (try? await liveTV.livePrograms(
channelIDs: listing.channels.map(\.id),
from: now.addingTimeInterval(-3_600),
to: now.addingTimeInterval(6 * 3_600)
)) ?? [:]
}
private func tune(_ channel: LiveChannel, program: LiveProgram? = nil) async {
guard let liveTV = session.liveTV, isTuning == nil else { return }
isTuning = channel.id
defer { isTuning = nil }
// Die laufende Sendung mitgeben: über ihre Adresse beim
// Programmanbieter führt der direkte Weg zum Stream.
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
let policy = settings.policy(for: network)
let result = await liveTV.tune(
channel: channel,
program: running,
maximumBitrate: policy.maximumBitrate
)
guard let stream = result.url else {
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
tuneFailure = TuneFailure(
channel: channel.title,
reason: String(localized: "liveTV.noStream"),
trace: result.trace.map { "\($0.path)\n → \($0.result)" }
)
return
}
// Live-TV läuft über denselben Player wie alles andere, bekommt aber
// die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht.
playback.playLive(
channelID: channel.id,
title: channel.title,
programme: running?.title,
programmeEndsAt: running?.endsAt,
stream: stream,
subscriptionKey: result.subscriptionKey
)
}
/// Merkt eine Sendung vor.
///
/// Ohne Rückmeldung bei Erfolg: der Knopf verschwindet, sobald die
/// Sendung einen Timer trägt, und das ist die Antwort. Eine Meldung
/// obendrauf wäre eine zweite.
private func record(_ program: LiveProgram, series: Bool) async {
guard let recorder = session.recorder else { return }
do {
if series {
try await recorder.scheduleSeriesTimer(for: program)
} else {
try await recorder.scheduleTimer(for: program)
}
await load()
} catch {
tuneFailure = TuneFailure(
channel: program.title,
reason: ErrorText.describe(error),
trace: []
)
}
}
}
/// Was Live-TV gerade zeigt.
enum LiveTVMode: String {
case guide
case channels
case recordings
}
/// Eine Senderzeile mit dem, was gerade und als Nächstes läuft.
private struct ChannelRow: View {
let channel: LiveChannel
let programs: [LiveProgram]
let isTuning: Bool
let onTune: () -> Void
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
private var current: LiveProgram? { programs.first { $0.isRunning() } }
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
var body: some View {
Button(action: onTune) {
HStack(spacing: HaloSpacing.regular) {
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
// fast nie quadratisch, und formatfüllend fehlten links und
// rechts genau die Teile, an denen man den Sender erkennt.
HaloArtwork(
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
aspectRatio: HaloAspectRatio.channelLogo,
fallbackSymbol: "tv",
contentMode: .fit
)
.frame(width: 56, height: 34)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: HaloSpacing.snug) {
if let number = channel.number {
Text(number)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.haloTextMuted)
}
Text(channel.title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
}
if let current {
Text(current.title)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
.lineLimit(1)
// Der Balken zeigt, wie weit die Sendung ist — beim
// Umschalten die nützlichste Angabe überhaupt.
WatchProgressBar(fraction: current.progress())
.padding(.top, 2)
} else {
Text("liveTV.noProgram")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
if let next {
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
Spacer()
if isTuning {
ProgressView().controlSize(.small)
} else {
Image(systemName: "play.circle")
.font(.title3)
.foregroundStyle(accent)
}
}
.padding(.horizontal, HaloSpacing.screenMargin)
.padding(.vertical, HaloSpacing.regular)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityElement(children: .combine)
}
}
/// Was beim Einschalten schiefging, in voller Länge.
struct TuneFailure: Identifiable {
let id = UUID()
let channel: String
let reason: String
/// Jeder versuchte Weg mit der Antwort des Servers.
let trace: [String]
}
/// Zeigt die Antwort des Servers, statt sie zu verschlucken.
///
/// Die Live-Schnittstelle von Plex ist nirgends beschrieben. Ohne den
/// Wortlaut dessen, was der Server abgelehnt hat, bleibt nur Raten — und
/// jede Runde davon kostet Tage.
private struct TuneFailureSheet: View {
let failure: TuneFailure
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text(failure.channel)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
Text(failure.reason)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Color.haloTextMuted)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button {
HaloClipboard.copy(failure.trace.joined(separator: "\n"))
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.haloCaption)
}
.buttonStyle(.bordered)
}
.padding(HaloSpacing.screenMargin)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.haloCanvas)
.navigationTitle(Text("liveTV.tuneFailed"))
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.haloResizableSheet()
}
}
+392
View File
@@ -0,0 +1,392 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
///
/// Warum ein Raster und nicht eine Liste mit „läuft gerade": beim Fernsehen
/// sucht man nicht einen Sender, sondern eine Sendung — und die findet man
/// nur, wenn man sieht, was auf allen Sendern gleichzeitig läuft. Genau das
/// ist die Achse.
///
/// Der Aufbau: die Senderspalte steht **außerhalb** des waagerechten
/// Rollbereichs und bleibt deshalb stehen, während das Programm daran
/// vorbeizieht. Die Zeitachse steht außerhalb des senkrechten und folgt dem
/// waagerechten Versatz von Hand — anders bekommt man zwei Achsen nicht
/// gleichzeitig festgenagelt.
struct ProgramGuide: View {
let channels: [LiveChannel]
let programs: [String: [LiveProgram]]
let tuning: String?
/// Die Adresse des Senderlogos.
///
/// Als Abschluss übergeben statt aus der Umgebung gelesen: so lässt sich
/// das Raster außerhalb der App mit erfundenen Sendern zeichnen und
/// ansehen. Die Senderspalte hat einmal gefehlt, und das war ohne einen
/// Blick darauf nicht zu finden.
let artwork: (LiveChannel) -> URL?
let onTune: (LiveChannel, LiveProgram?) -> Void
/// Eine Sendung vormerken — einmalig oder als ganze Reihe.
///
/// Beide optional: **nicht** jeder Server kann aufnehmen. `nil` heißt,
/// dass die Knöpfe gar nicht erst erscheinen — besser als ein Knopf, der
/// bei Plex nur eine Fehlermeldung auslöst.
var onRecord: ((LiveProgram) -> Void)?
var onRecordSeries: ((LiveProgram) -> Void)?
@Environment(\.haloAccent) private var accent
@State private var scrollX: CGFloat = 0
@State private var selection: GuideSelection?
@State private var now = Date()
private let channelWidth: CGFloat = 116
private let rowHeight: CGFloat = 64
/// Eine halbe Stunde ist gut 130 Punkte breit — schmal genug, dass ein
/// Abend auf den Schirm passt, breit genug für einen lesbaren Titel.
private let pointsPerMinute: CGFloat = 4.4
private let axisHeight: CGFloat = 26
var body: some View {
VStack(spacing: 0) {
axis
Divider().overlay(Color.haloSeparator)
ScrollView(.vertical) {
HStack(alignment: .top, spacing: 0) {
// Ohne Vorrang verliert die Spalte den Platzstreit gegen
// das Raster: dessen Breite ist die volle Zeitachse —
// mehrere tausend Punkte —, und der HStack drückt den
// Nachbarn dafür auf null. Genau so war die Senderspalte
// verschwunden.
channelColumn.layoutPriority(1)
lanes
}
}
.scrollIndicators(.never)
}
.background(Color.haloCanvas)
.sheet(item: $selection) { selection in
ProgramDetailSheet(
channel: selection.channel,
artworkURL: artwork(selection.channel),
program: selection.program,
isRunning: selection.program.isRunning(at: now),
onTune: { onTune(selection.channel, selection.program) },
onRecord: onRecord.map { record in { record(selection.program) } },
onRecordSeries: onRecordSeries.map { record in { record(selection.program) } }
)
}
// Die Fortschrittsbalken und die Jetzt-Linie stimmen sonst nach ein
// paar Minuten nicht mehr. Einmal pro Minute reicht dafür.
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(60))
now = Date()
}
}
}
// MARK: - Zeitfenster
/// Zeit in Punkte. Die Rechnungen dahinter stehen in `GuideLayout` und
/// sind dort geprüft — hier wären sie unsichtbar falsch.
private var layout: GuideLayout {
GuideLayout(
now: now,
latestEnd: programs.values.flatMap { $0 }.map(\.endsAt).max(),
pointsPerMinute: pointsPerMinute
)
}
// MARK: - Teile
private var axis: some View {
HStack(spacing: 0) {
Text("liveTV.channelCount \(channels.count)")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
// Der Abstand gehört in die Spaltenbreite hinein, nicht
// dazu: außen addiert er sich, und die ganze Zeitachse steht
// acht Punkte neben dem Programm darunter.
.padding(.leading, HaloSpacing.snug)
.frame(width: channelWidth, alignment: .leading)
.layoutPriority(1)
// Der Streifen liegt als Auflage über einer durchsichtigen
// Fläche, statt selbst im Stapel zu stehen: eine Auflage zählt
// bei der Größenrechnung nicht mit. Stünde er selbst darin,
// verlangte er die Breite der ganzen Zeitachse und schöbe die
// Beschriftung links aus dem Bild.
Color.clear
.frame(maxWidth: .infinity)
.overlay(alignment: .leading) {
HStack(spacing: 0) {
ForEach(layout.ticks, id: \.self) { tick in
Text(tick.formatted(date: .omitted, time: .shortened))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(Color.haloTextMuted)
.frame(width: layout.tickWidth, alignment: .leading)
}
}
.fixedSize()
.offset(x: -scrollX)
}
.clipped()
}
.frame(height: axisHeight)
.background(Color.haloCanvas)
}
/// Was auf diesem Sender gerade läuft.
private func running(on channel: LiveChannel) -> LiveProgram? {
programs[channel.id]?.first { $0.isRunning(at: now) }
}
private var channelColumn: some View {
VStack(spacing: 0) {
ForEach(channels) { channel in
Button { onTune(channel, running(on: channel)) } label: {
HStack(spacing: HaloSpacing.snug) {
HaloArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
.frame(width: 30, height: 30)
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 1) {
Text(channel.title)
.font(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
if let number = channel.number {
Text(number)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(Color.haloTextMuted)
}
}
Spacer(minLength: 0)
if tuning == channel.id {
ProgressView().controlSize(.mini)
}
}
.padding(.horizontal, HaloSpacing.snug)
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
.overlay(alignment: .bottom) { rowSeparator }
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
.frame(width: channelWidth)
.background(Color.haloElevated.opacity(0.4))
}
private var lanes: some View {
ScrollView(.horizontal) {
ZStack(alignment: .topLeading) {
VStack(spacing: 0) {
ForEach(channels) { channel in
lane(for: channel)
}
}
// Die Jetzt-Linie läuft mit dem Inhalt mit, nicht mit dem
// Blickfeld — sie markiert einen Zeitpunkt, keine Stelle auf
// dem Schirm.
Rectangle()
.fill(accent)
.frame(width: 2)
.offset(x: layout.x(for: now))
.allowsHitTesting(false)
}
.frame(width: layout.totalWidth, alignment: .topLeading)
}
.scrollIndicators(.never)
.frame(height: CGFloat(channels.count) * rowHeight)
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.x
} action: { _, offset in
scrollX = offset
}
}
private func lane(for channel: LiveChannel) -> some View {
let entries = programs[channel.id] ?? []
return ZStack(alignment: .topLeading) {
if entries.isEmpty {
Text("liveTV.noProgram")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.padding(.horizontal, HaloSpacing.regular)
.frame(height: rowHeight, alignment: .leading)
}
ForEach(entries) { program in
block(program, on: channel)
.offset(x: layout.x(for: program.startsAt))
}
}
.frame(width: layout.totalWidth, height: rowHeight, alignment: .topLeading)
.overlay(alignment: .bottom) { rowSeparator }
}
/// Die Trennlinie gehört in die Zeile, nicht zwischen die Zeilen.
///
/// `Divider()` ist auf einem 3x-Schirm keinen ganzen Punkt hoch. Zwischen
/// den Zeilen gestapelt summiert sich dieser Bruchteil, und Senderspalte
/// und Programm stehen nach zwanzig Sendern sichtbar versetzt — die
/// Zeile, die man antippt, gehört dann zum Nachbarsender.
private var rowSeparator: some View {
Rectangle()
.fill(Color.haloSeparator)
.frame(height: 1)
}
private func block(_ program: LiveProgram, on channel: LiveChannel) -> some View {
let running = program.isRunning(at: now)
return Button { selection = GuideSelection(channel: channel, program: program) } label: {
VStack(alignment: .leading, spacing: 2) {
Text(program.title)
.font(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(Color.haloTextMuted)
}
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, 6)
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
.background(
running ? accent.opacity(0.28) : Color.haloElevated,
in: .rect(cornerRadius: HaloRadius.control)
)
.overlay(alignment: .bottomLeading) {
// Der Balken zeigt, wie weit die laufende Sendung ist — beim
// Umschalten die nützlichste Angabe überhaupt.
if running {
WatchProgressBar(fraction: program.progress(at: now))
.padding(.horizontal, 6)
.padding(.bottom, 5)
}
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
/// Die angetippte Sendung samt ihrem Sender.
private struct GuideSelection: Identifiable {
let channel: LiveChannel
let program: LiveProgram
var id: String { "\(channel.id)-\(program.id)" }
}
/// Was zu einer Sendung im Raster gehört, in voller Länge.
private struct ProgramDetailSheet: View {
let channel: LiveChannel
let artworkURL: URL?
let program: LiveProgram
let isRunning: Bool
let onTune: () -> Void
let onRecord: (() -> Void)?
let onRecordSeries: (() -> Void)?
@Environment(\.haloAccent) private var accent
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
.frame(width: 44, height: 44)
.clipShape(.rect(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(channel.title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
Spacer()
}
Text(program.title)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
if let subtitle = program.subtitle {
Text(subtitle)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
}
if isRunning {
Button { onTune(); dismiss() } label: {
Label("liveTV.tune", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
}
// Vormerken steht auch bei einer laufenden Sendung: der
// Rest davon lässt sich noch aufnehmen. Bei einem Server
// ohne Aufnahmen fehlt hier schlicht alles.
if program.seriesTimerID != nil {
Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle")
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
} else if onRecord != nil || onRecordSeries != nil {
HStack(spacing: HaloSpacing.regular) {
if let onRecord {
Button { onRecord(); dismiss() } label: {
Label("liveTV.record", systemImage: "record.circle")
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.bordered)
}
if let onRecordSeries {
Button { onRecordSeries(); dismiss() } label: {
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.bordered)
}
}
}
if let summary = program.summary, !summary.isEmpty {
Text(summary)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
}
}
.padding(HaloSpacing.screenMargin)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.haloCanvas)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.haloResizableSheet()
}
}
+258
View File
@@ -0,0 +1,258 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Aufnahmen und was noch vorgemerkt ist.
///
/// Der dritte Modus neben Raster und Senderliste — und der einzige, den es
/// nicht überall gibt. Wer hierher kommt, hat einen Server, der aufnehmen
/// kann; bei den anderen fehlt schon der Schalter dorthin.
///
/// Beides in **einer** Liste statt in zwei Ansichten: „was liegt da" und „was
/// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine
/// stellt, stellt meist gleich die andere mit.
struct RecordingsView: View {
@Environment(HaloSession.self) private var session
@State private var recordings: [LiveRecording] = []
@State private var timers: [LiveTimer] = []
@State private var seriesTimers: [LiveSeriesTimer] = []
@State private var state: LoadState<Bool> = .loading
/// Was gerade gelöscht werden soll — die Rückfrage hängt daran.
@State private var pendingDeletion: LiveRecording?
var body: some View {
Group {
switch state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await load() } }
}
case .loaded where recordings.isEmpty && timers.isEmpty && seriesTimers.isEmpty:
ContentUnavailableView {
Label("liveTV.recordings.empty.title", systemImage: "recordingtape")
} description: {
Text("liveTV.recordings.empty.description")
}
case .loaded:
list
}
}
.background(Color.haloCanvas)
.task { await load() }
.refreshable { await load() }
.confirmationDialog(
Text("liveTV.deleteRecording.confirm"),
isPresented: Binding(
get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }
),
titleVisibility: .visible
) {
Button("action.delete", role: .destructive) {
if let target = pendingDeletion { Task { await delete(target) } }
}
Button("action.cancel", role: .cancel) { pendingDeletion = nil }
} message: {
// Der Name gehört in die Rückfrage: „Wirklich löschen?" allein
// beantwortet nicht, *was* — und eine Aufnahme ist weg, wenn sie
// weg ist.
if let pendingDeletion { Text(pendingDeletion.title) }
}
}
private var list: some View {
List {
if !recordings.isEmpty {
Section {
ForEach(recordings) { recording in
// Über die Detailseite statt unmittelbar abspielen:
// eine Aufnahme ist auf dem Server ein gewöhnlicher
// Eintrag, und dort steht, in welchen Fassungen sie
// vorliegt. Von hier aus zu starten hieße, das zu
// raten.
NavigationLink(value: MediaRoute(ratingKey: recording.id, title: recording.title)) {
row(recording)
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button(role: .destructive) { pendingDeletion = recording } label: {
Label("action.delete", systemImage: "trash")
}
}
}
.listRowBackground(Color.haloSurface)
} header: {
Text("liveTV.recordings")
}
}
if !timers.isEmpty || !seriesTimers.isEmpty {
Section {
ForEach(seriesTimers) { timer in
seriesRow(timer)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await cancelSeries(timer) }
} label: {
Label("liveTV.cancelTimer", systemImage: "xmark")
}
}
}
ForEach(timers) { timer in
timerRow(timer)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await cancel(timer) }
} label: {
Label("liveTV.cancelTimer", systemImage: "xmark")
}
}
}
.listRowBackground(Color.haloSurface)
} header: {
Text("liveTV.scheduled")
}
.listRowBackground(Color.haloSurface)
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.haloRevealsTopChrome()
}
// MARK: - Zeilen
private func row(_ recording: LiveRecording) -> some View {
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120),
aspectRatio: 16 / 9,
fallbackSymbol: "recordingtape",
contentMode: .fit
)
.frame(width: 64, height: 36)
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
Text(recording.title)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
HStack(spacing: HaloSpacing.tight) {
if recording.isInProgress {
// Eine laufende Aufnahme ist keine fertige: sie wächst
// noch, und wer sie jetzt öffnet, sieht ein Ende, das
// nicht das Ende ist.
Label("liveTV.recording.inProgress", systemImage: "record.circle")
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
}
if let subtitle = subtitle(for: recording) {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
.lineLimit(1)
}
}
}
Spacer(minLength: 0)
}
.contentShape(.rect)
}
private func timerRow(_ timer: LiveTimer) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(timer.title)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
}
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
HStack(spacing: HaloSpacing.snug) {
Image(systemName: "rectangle.stack.badge.record")
.foregroundStyle(Color.haloTextSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(timer.name)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
}
/// Sender und Beginn in einer Zeile — was davon vorhanden ist.
private func subtitle(for recording: LiveRecording) -> String? {
subtitle(channel: recording.channelName, start: recording.startsAt)
}
private func subtitle(channel: String?, start: Date?) -> String? {
var pieces: [String] = []
if let channel { pieces.append(channel) }
if let start { pieces.append(start.formatted(date: .abbreviated, time: .shortened)) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
// MARK: - Laden und Ändern
private func load() async {
guard let recorder = session.recorder else {
state = .loaded(true)
return
}
do {
// Nebenläufig: drei Abfragen, die nichts voneinander wissen.
// Nacheinander wäre die Wartezeit ihre Summe.
async let fetchedRecordings = recorder.recordings()
async let fetchedTimers = recorder.timers()
async let fetchedSeries = recorder.seriesTimers()
recordings = try await fetchedRecordings
timers = try await fetchedTimers
seriesTimers = try await fetchedSeries
state = .loaded(true)
} catch {
state = .failed(message: ErrorText.describe(error))
}
}
private func delete(_ recording: LiveRecording) async {
pendingDeletion = nil
guard let recorder = session.recorder else { return }
try? await recorder.deleteRecording(id: recording.id)
await load()
}
private func cancel(_ timer: LiveTimer) async {
guard let recorder = session.recorder else { return }
try? await recorder.cancelTimer(id: timer.id)
await load()
}
private func cancelSeries(_ timer: LiveSeriesTimer) async {
guard let recorder = session.recorder else { return }
try? await recorder.cancelSeriesTimer(id: timer.id)
await load()
}
}
+107
View File
@@ -0,0 +1,107 @@
import Foundation
import MediaKit
/// Der Ladezustand eines Bildschirms.
///
/// Ein gemeinsamer Typ statt dreier Einzelfelder (`isLoading`, `items`,
/// `errorMessage`): die lassen sich widersprüchlich besetzen — lädt noch und
/// hat gleichzeitig einen Fehler — und jede View müsste selbst entscheiden,
/// was dann gilt.
enum LoadState<Value: Sendable>: Sendable {
case loading
case loaded(Value)
case failed(message: String)
var value: Value? {
if case .loaded(let value) = self { return value }
return nil
}
}
extension LoadState {
/// Führt eine Ladeoperation aus und übersetzt Fehler in Text.
///
/// `keepingPrevious` ist für „Nach unten ziehen zum Aktualisieren": dabei
/// soll die Liste stehen bleiben, statt für die Dauer der Anfrage durch
/// einen Ladekreis ersetzt zu werden.
///
/// Auf dem Hauptakteur, weil sie ausschließlich aus Views heraus gerufen
/// wird. Ohne diese Festlegung müsste der übergebene Ausdruck `Sendable`
/// sein — was er nie ist, weil er den Server aus der Umgebung mitnimmt.
@MainActor
static func load(
keepingPrevious previous: Self? = nil,
_ operation: () async throws -> Value
) async -> Self {
do {
return .loaded(try await operation())
} catch {
// Der Bildschirm wurde verlassen, während geladen wurde. Ein
// Fehlertext wäre hier falsch — es ist niemand mehr da, den er
// interessiert.
//
// Gefragt wird die Aufgabe, nicht der Fehler: eine abgebrochene
// Netzanfrage kommt als Übertragungsfehler zurück und nicht als
// `CancellationError`. Danach zu fangen sah richtig aus und griff
// nie.
if Task.isCancelled { return previous ?? .loading }
return .failed(message: ErrorText.describe(error))
}
}
}
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
///
/// **Eine** Stelle. Es gab davon zwei — hier und in `HaloSession` —, und sie
/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
/// Bildschirm eine andere Erklärung.
enum ErrorText {
static func describe(_ error: any Error) -> String {
// Die neutrale Form zuerst: die Ansichten sehen nur noch sie, und
// jeder Server übersetzt seine eigenen Fehler schon an der Grenze
// seines Moduls dorthin.
if let backendError = error as? MediaBackendError {
return describe(backendError)
}
return error.localizedDescription
}
static func describe(_ error: MediaBackendError) -> String {
switch error {
case .unauthorized: String(localized: "error.unauthorized")
case .notFound: String(localized: "error.notFound")
case .noReachableConnection: String(localized: "error.serverUnreachable")
case .server(let status): String(localized: "error.server \(status)")
case .transport(let description): description
case .decoding: String(localized: "error.unexpectedResponse")
// Die Worte des Servers, nicht unsere: er weiß besser, warum er
// ablehnt, als wir es raten könnten.
case .refused(let reason): reason
}
}
}
/// Wohin ein Tipp auf eine Kachel führt.
///
/// Nur der Schlüssel, nicht der ganze Titel: die Einträge aus den Reihen der
/// Startseite sind unvollständig — ohne Dateiliste, ohne Besetzung. Die
/// Detailseite muss den Titel ohnehin frisch holen.
struct MediaRoute: Hashable {
let ratingKey: String
/// Für die Titelzeile, solange noch geladen wird.
let title: String
}
/// Der Weg zu einer Mediathek.
///
/// Ein Wert statt einer Ansicht, und das ist kein Geschmack: in einem Stapel
/// dürfen nicht beide Arten von Verknüpfung nebeneinander vorkommen. Sobald
/// eine Ansicht über eine **Ansichts**-Verknüpfung geschoben wurde, fanden die
/// **Wert**-Verknüpfungen darin ihr Ziel nicht mehr — jedes Antippen einer
/// Kachel führte ins Leere, ohne Fehlermeldung.
struct LibraryRoute: Hashable {
let id: String
}
/// Der Weg zum Programmführer.
struct LiveTVRoute: Hashable {}
+61
View File
@@ -0,0 +1,61 @@
import SwiftUI
import HaloDesign
/// Der erste Buchstabe auf einer Scheibe.
///
/// **Kein Platzhalterbild.** Jellyfin gibt kein Profilbild heraus, ohne dass
/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein
/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich
/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man
/// angemeldet ist.
///
/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in
/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des
/// Einstellungen-Tabs der Weg dorthin liegt.
struct AccountAvatar: View {
let name: String
var diameter: CGFloat = 26
@Environment(\.haloAccent) private var accent
var body: some View {
Text(name.prefix(1).uppercased())
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
// Kleinschrift auf einer grossen Scheibe sieht aus wie ein
// Fehler.
.font(.system(size: diameter * 0.46, weight: .semibold))
.foregroundStyle(Color.haloCanvas)
.frame(width: diameter, height: diameter)
.background(accent, in: .circle)
}
}
extension EnvironmentValues {
/// Öffnet die Einstellungen.
///
/// **Über die Umgebung, weil der Knopf an mehreren Stellen steht.** Das
/// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs.
/// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst
/// als Argument durchgereicht bekommen.
@Entry var haloOpenSettings: () -> Void = {}
}
/// Das Monogramm als Knopf für eine Werkzeugleiste.
///
/// **Als Ansicht und nicht als Modifikator.** Ein Modifikator hängt seine
/// Einträge aussen an, und SwiftUI reiht aussen zuerst — das Monogramm stand
/// damit **vor** Suche, Filter und Ansicht statt dahinter. Wo in der Reihe es
/// steht, kann nur der entscheiden, der die Reihe schreibt.
struct AccountToolbarButton: View {
@Environment(HaloSession.self) private var session
@Environment(\.haloOpenSettings) private var openSettings
var body: some View {
Button(action: openSettings) {
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("sidebar.settings"))
}
}
+138
View File
@@ -0,0 +1,138 @@
import SwiftUI
import HaloDesign
/// Wer angemeldet ist — unten in der Seitenleiste.
///
/// Der Platz ist nicht zufällig: dort sucht man sein Konto, weil es in jeder
/// App der letzte Eintrag ist. Und von dort führt der Weg in die
/// Einstellungen, statt dass sie als gleichrangiger Eintrag zwischen den
/// Mediatheken stehen.
///
/// **Eine Karte, die über der Zeile aufgeht**, kein Systemmenü: mit denselben
/// Abständen und derselben Schrift wie der Rest der App, statt der grauen
/// Standardliste.
///
/// Sie hängt als Popover an der Zeile und wächst nicht in der Leiste selbst.
/// Das war der erste Versuch, und er ging schief: die Leiste unten hat eine
/// feste Höhe, die Karte wuchs nach unten aus dem Bild heraus, und die Zeile
/// mit dem Namen verschwand dabei.
///
/// Auf dem iPhone gibt es diese Leiste nicht; dort haben die Einstellungen
/// ihren eigenen Eintrag.
struct AccountBar: View {
@Binding var selection: SidebarDestination
@Environment(HaloSession.self) private var session
@State private var isExpanded = false
@State private var showsSignOutConfirmation = false
private var title: String {
session.accountName ?? session.serverName
}
/// Der Server steht nur dann darunter, wenn oben schon der Kontoname
/// steht — sonst stünde zweimal dasselbe.
private var subtitle: String? {
session.accountName == nil ? nil : session.serverName
}
var body: some View {
accountRow
.popover(isPresented: $isExpanded, arrowEdge: .bottom) {
VStack(spacing: 0) {
action("account.settings", symbol: "gearshape") {
isExpanded = false
selection = .settings
}
separator
action(
"action.signOut",
symbol: "rectangle.portrait.and.arrow.right",
isDestructive: true
) {
isExpanded = false
showsSignOutConfirmation = true
}
}
.frame(minWidth: 240)
.background(Color.haloSurface)
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
// steht trotzdem da, damit aus dem Popover dort kein
// bildschirmfüllendes Sheet wird.
.presentationCompactAdaptation(.popover)
}
.confirmationDialog(
Text("settings.signOutConfirm"),
isPresented: $showsSignOutConfirmation,
titleVisibility: .visible
) {
Button("action.signOut", role: .destructive) { session.signOut() }
Button("action.cancel", role: .cancel) {}
} message: {
Text("settings.signOutExplanation")
}
}
private var accountRow: some View {
Button {
isExpanded.toggle()
} label: {
HStack(spacing: HaloSpacing.snug) {
AccountAvatar(name: title)
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
// Ohne Vorrang staucht SwiftUI den Text vor dem Zwischenraum:
// in einer schmalen Leiste stand vom Namen nur noch „S…" da.
.layoutPriority(1)
Spacer(minLength: 0)
// Zeigt nach oben: dorthin geht die Karte auf.
Image(systemName: "chevron.up")
.font(.caption2.weight(.semibold))
.foregroundStyle(Color.haloTextMuted)
}
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.snug)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(title))
.accessibilityHint(Text("account.hint"))
}
private func action(
_ title: LocalizedStringKey,
symbol: String,
isDestructive: Bool = false,
perform: @escaping () -> Void
) -> some View {
Button(action: perform) {
Label(title, systemImage: symbol)
.font(.haloBody)
.foregroundStyle(isDestructive ? Color.haloDestructive : Color.haloTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.regular)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private var separator: some View {
Divider().overlay(Color.haloSeparator)
}
}
@@ -0,0 +1,457 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Wo die Navigation sitzt.
///
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben — deshalb eine
/// eigene, gespeicherte Größe statt eines Zustands, der mit der Ansicht stirbt.
enum NavigationPlacement: String {
case sidebar
case top
}
/// Ein Eintrag in der Navigation.
struct NavigationEntry: Identifiable, Arrangeable {
let destination: SidebarDestination
let title: Text
let symbol: String
/// Der Titel als reiner Text — für die Sortierlogik und für die
/// Bedienungshilfen. `Text` selbst lässt sich nicht auslesen.
let plainTitle: String
var id: SidebarDestination { destination }
var arrangementID: String { destination.storageKey }
var arrangementTitle: String { plainTitle }
}
/// Eine Gruppe von Einträgen, mit oder ohne Überschrift.
struct NavigationGroup: Identifiable {
let id: String
let title: LocalizedStringKey?
let entries: [NavigationEntry]
}
// MARK: - Seitenleiste
/// Die Seitenleiste, wie sie auf dem iPad aussieht — überall.
///
/// Selbst gezeichnet, weil das System sie auf dem Mac angeklebt an den
/// Fensterrand setzt und sich das nicht umstellen lässt. Siehe
/// `TabChromeSuppressor`: das Verhalten kommt weiter vom `TabView`, nur das
/// Kleid ist unseres.
///
/// Eine schwebende Fläche mit Rand ringsum — genau das war die Bitte: „eine
/// eigene Bubble mit einem klaren Rand, der nicht der Fensterrand ist".
///
/// Der Rand kommt von `glassEffect` und nicht von einem gezeichneten Strich.
/// Ein Material mit einer halben Linie Kontur darum sah aus wie eine Fläche
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
/// „umrandet" und „aus Glas".
struct HaloSidebar: View {
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
let groups: [NavigationGroup]
@Binding var selection: SidebarDestination
@Binding var placement: NavigationPlacement
let layout: NavigationLayoutSettings
@Environment(\.haloAccent) private var accent
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
@State private var collapsed: Set<String> = []
@State private var isEditing = false
/// Die Fläche, auf der die Leiste liegt.
///
/// Von Hand gebaut und nicht mit `glassEffect`. Der zeichnet an seiner
/// Kante einen Lichtsaum, und über einem hellen Aufmacher wurde daraus ein
/// heller Balken von mehreren Punkten Breite, der neben der Leiste stand —
/// kein Rand, sondern ein Streifen. Gleichzeitig war von einer Kante nichts
/// zu erkennen.
///
/// Drei Lagen: ein trübes Material, damit die Leiste eine eigene Fläche
/// ist; ein Schleier, damit die durchziehende Startseite nicht mitgelesen
/// wird; und ein Haarstrich, der oben links aufhellt und unten rechts
/// nachlässt — so fängt Glas das Licht. Dazu ein Schatten, denn was
/// schwebt, wirft einen.
@ViewBuilder
static func surface(in shape: some InsettableShape) -> some View {
shape
.fill(.ultraThinMaterial)
.overlay { shape.fill(Color.haloCanvas.opacity(0.55)) }
.overlay {
shape.strokeBorder(
LinearGradient(
stops: [
.init(color: .white.opacity(0.30), location: 0),
.init(color: .white.opacity(0.05), location: 0.5),
.init(color: .white.opacity(0.16), location: 1),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
}
}
static let width: CGFloat = 235
/// Der Abstand zum Fensterrand. Er ist der eigentliche Punkt der Sache.
static let margin: CGFloat = 12
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
// **Doch mit Bildlaufansicht.** Hier stand „die Einträge sind
// wenige und passen" — und das stimmte auf einem iPad und im
// Hochformat. Auf einem grossen iPhone **quer** ist der
// Bildschirm vierhundertvierzig Punkte hoch, und die Leiste
// braucht mit Kopf, Gruppen, Trenner und Kontozeile gut
// fünfhundertdreissig.
//
// Eine `VStack` schrumpft dann nicht, sie **überragt**: der
// `HStack` daneben nimmt das Maximum, und der ganze Wurzelbaum
// wurde hundertachtunddreissig Punkte höher als der Bildschirm.
// Was darin als Überlagerung liegt — der Player — ging oben und
// unten hinaus, und Kopfzeile wie Zeitangaben waren weg. Der
// Fehler sass also in der Navigation und zeigte sich im Player.
//
// `basedOnSize`: es wird nur gescrollt, wenn es nötig ist. Wo die
// Einträge passen, sieht man keinen Unterschied.
//
// Nachgerechnet, damit es nicht wiederkommt: scrollbarer Teil
// 421 Punkte (acht Zeilen à 36,33, eine Gruppenüberschrift
// 22,33, acht Zwischenräume à 12, unten 12), starrer Rest
// 137,6 (Kopfzeile 48,33, Trenner 1, Kontozeile 64,27, aussen
// zweimal 12). Zusammen 558,6 — gemessen wurden 558,67. Nach dem
// Umbau bleiben 137,6 starr, bei 420 verfügbaren: auch mit
// doppelt so vielen Mediatheken passt das.
//
// Beim Bearbeiten braucht es eine Liste — nur die bringt das
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
if isEditing {
Text("sidebar.editHint")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.padding(.horizontal, HaloSpacing.regular)
.padding(.bottom, HaloSpacing.snug)
List {
ForEach(groups) { group in
Section {
ForEach(editable(group)) { entry in
editRow(entry)
}
.onMove { move(in: group, from: $0, to: $1) }
} header: {
if let title = group.title {
Text(title)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, .constant(.active))
} else {
ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
ForEach(groups) { group in
section(visible(group))
}
}
.padding(.horizontal, HaloSpacing.snug)
.padding(.bottom, HaloSpacing.regular)
}
.scrollBounceBehavior(.basedOnSize)
.scrollIndicators(.never)
}
Spacer(minLength: 0)
Divider().overlay(Color.haloSeparator)
AccountBar(selection: $selection)
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.snug)
}
.frame(width: Self.width)
.background { Self.surface(in: .rect(cornerRadius: HaloRadius.panel, style: .continuous)) }
.clipShape(.rect(cornerRadius: HaloRadius.panel, style: .continuous))
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
// und Leiste. Der schwebende Eindruck kommt von den übrigen drei
// Seiten und vom Schatten.
.padding(.leading, Self.margin)
.padding(.vertical, Self.margin)
}
private var header: some View {
HStack(spacing: HaloSpacing.snug) {
Button {
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
} label: {
Text(isEditing ? "action.done" : "sidebar.edit")
.font(.haloCaption)
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.tight)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.foregroundStyle(isEditing ? accent : Color.haloTextSecondary)
Spacer(minLength: 0)
if !isEditing {
Button {
placement = .top
} label: {
Image(systemName: "rectangle.topthird.inset.filled")
.font(.haloRowHeader)
.padding(HaloSpacing.tight)
// Ohne eigene Klickfläche reicht das Ziel nur so weit
// wie das Zeichen selbst — auf dem Mac trifft man das
// mit dem Zeiger kaum.
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(Color.haloTextSecondary)
.accessibilityLabel(Text("sidebar.moveToTop"))
}
}
.padding(.horizontal, HaloSpacing.regular)
.padding(.top, HaloSpacing.regular)
.padding(.bottom, HaloSpacing.snug)
}
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
/// Reihenfolge, auch die ausgeblendeten.
private func editable(_ group: NavigationGroup) -> [NavigationEntry] {
layout.ordered(group.entries)
}
/// Verschiebt einen Eintrag innerhalb seiner Gruppe.
///
/// Gespeichert wird die Reihenfolge **aller** Gruppen hintereinander.
/// Innerhalb einer Gruppe zu sortieren genügt für die Anzeige, aber eine
/// Liste je Gruppe zu speichern hieße, beim Hinzukommen einer Gruppe
/// wieder eine Sonderregel zu haben.
private func move(in group: NavigationGroup, from source: IndexSet, to destination: Int) {
var entries = editable(group)
entries.move(fromOffsets: source, toOffset: destination)
var order: [String] = []
for other in groups {
let ids = other.id == group.id
? entries.map(\.arrangementID)
: editable(other).map(\.arrangementID)
order.append(contentsOf: ids)
}
layout.setOrder(order)
}
@ViewBuilder
private func section(_ group: NavigationGroup) -> some View {
if let title = group.title {
Button {
withAnimation(.snappy(duration: 0.25)) {
if collapsed.contains(group.id) {
collapsed.remove(group.id)
} else {
collapsed.insert(group.id)
}
}
} label: {
HStack {
Text(title)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Spacer()
Image(systemName: "chevron.down")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
}
.padding(.horizontal, HaloSpacing.regular)
.padding(.top, HaloSpacing.snug)
}
.buttonStyle(.plain)
}
if !collapsed.contains(group.id) {
ForEach(group.entries) { entry in
row(entry)
}
}
}
/// Eine Gruppe, wie sie im Ruhezustand dasteht: ausgeblendete raus,
/// Reihenfolge angewandt.
private func visible(_ group: NavigationGroup) -> NavigationGroup {
NavigationGroup(id: group.id, title: group.title, entries: layout.apply(to: group.entries))
}
private func editRow(_ entry: NavigationEntry) -> some View {
let isHidden = layout.isHidden(entry.arrangementID)
return HStack(spacing: HaloSpacing.snug) {
Button {
layout.setHidden(!isHidden, for: entry.arrangementID)
} label: {
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
.foregroundStyle(isHidden ? Color.haloTextMuted : accent)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text(isHidden ? "sidebar.show" : "sidebar.hide"))
Image(systemName: entry.symbol)
.frame(width: 22)
entry.title
.lineLimit(1)
Spacer(minLength: 0)
}
.font(.haloBody)
.foregroundStyle(isHidden ? Color.haloTextMuted : Color.haloTextPrimary)
}
private func row(_ entry: NavigationEntry) -> some View {
let isSelected = selection == entry.destination
return Button {
selection = entry.destination
} label: {
HStack(spacing: HaloSpacing.snug) {
Image(systemName: entry.symbol)
.font(.haloBody)
.frame(width: 22, alignment: .center)
entry.title
.font(.haloBody)
// Ein langer Mediathekenname darf die Leiste nicht
// breiter machen; sie hat eine feste Breite.
.lineLimit(1)
Spacer(minLength: 0)
}
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.snug)
.background {
if isSelected {
Capsule().fill(Color.haloElevated)
}
}
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
// MARK: - Leiste oben
/// Dieselben Einträge als schwebende Leiste oben.
///
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
/// hinauswächst, wäre schlechter als ein Menü.
struct HaloTopBar: View {
let groups: [NavigationGroup]
@Binding var selection: SidebarDestination
@Binding var placement: NavigationPlacement
@Environment(\.haloAccent) private var accent
var body: some View {
HStack(spacing: HaloSpacing.tight) {
Button {
placement = .sidebar
} label: {
Image(systemName: "sidebar.leading")
.font(.haloBody)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.snug)
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
// das Zeichen; auf dem Mac war der Umschalter damit
// praktisch nicht zu treffen.
.contentShape(Capsule())
}
.buttonStyle(.plain)
.foregroundStyle(Color.haloTextSecondary)
.accessibilityLabel(Text("sidebar.moveToSide"))
ForEach(groups) { group in
if group.title == nil {
ForEach(group.entries) { entry in
pill(entry)
}
} else {
menu(group)
}
}
}
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.snug)
.background { HaloSidebar.surface(in: .capsule) }
.clipShape(.capsule)
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
.padding(.top, HaloSpacing.snug)
}
private func pill(_ entry: NavigationEntry) -> some View {
let isSelected = selection == entry.destination
return Button {
selection = entry.destination
} label: {
entry.title
.font(.haloBody)
.lineLimit(1)
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
.background {
if isSelected { Capsule().fill(Color.haloElevated) }
}
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
private func menu(_ group: NavigationGroup) -> some View {
let containsSelection = group.entries.contains { $0.destination == selection }
return Menu {
ForEach(group.entries) { entry in
Button { selection = entry.destination } label: {
Label { entry.title } icon: { Image(systemName: entry.symbol) }
}
}
} label: {
HStack(spacing: HaloSpacing.tight) {
if let title = group.title {
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
// Grund, an einem Wort zu sparen.
Text(title).fixedSize()
}
Image(systemName: "chevron.down").font(.haloCaption)
}
.font(.haloBody)
.foregroundStyle(containsSelection ? accent : Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
.background {
if containsSelection { Capsule().fill(Color.haloElevated) }
}
.contentShape(Capsule())
}
.menuStyle(.button)
.buttonStyle(.plain)
}
}
+49
View File
@@ -0,0 +1,49 @@
import SwiftUI
import MediaKit
/// Ein Navigationsstapel mit allen Zielen — und einem Weg, von innen zu
/// schieben.
///
/// **Warum der Pfad hier liegt und nicht beim System.** Ein `NavigationLink`
/// schiebt selbst; wer stattdessen aus einem Knopf heraus schieben will,
/// braucht eine Bindung. Genau das verlangt die Kachel, seit Antippen
/// abspielt: der Weg auf die Detailseite führt dann über das Kontextmenü, und
/// ein Menüeintrag ist kein Link.
///
/// **Die Ziele stehen hier und nur hier.** Sie je Bildschirm anzumelden hat
/// schon einmal nicht funktioniert: liegen zwei Bildschirme übereinander,
/// entscheidet sonst die Reihenfolge, und ein Tippen landete im Nichts.
struct MediaStack<Content: View>: View {
@ViewBuilder let content: () -> Content
@Environment(HaloSession.self) private var session
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
content()
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
.navigationDestination(for: LibraryRoute.self) { route in
if let library = session.libraries.first(where: { $0.id == route.id }) {
LibraryView(library: library)
}
}
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
.navigationDestination(for: SearchRoute.self) { _ in SearchView() }
}
.environment(\.haloPush) { ziel in path.append(ziel) }
}
}
extension EnvironmentValues {
/// Schiebt ein Ziel auf den Stapel, in dem man gerade steht.
///
/// **Über die Umgebung, weil der Weg sonst lang ist.** Eine Kachel steckt
/// in einer Reihe, die in der Startseite steckt, die im Stapel steckt.
/// Eine Bindung durch jede Ebene zu reichen hiesse, drei Ansichten um ein
/// Argument zu erweitern, das sie nur weitergeben.
///
/// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts
/// zu schieben, und ein Absturz wäre die schlechtere Antwort.
@Entry var haloPush: (any Hashable) -> Void = { _ in }
}
+641
View File
@@ -0,0 +1,641 @@
import SwiftUI
import HaloDesign
import MediaKit
import HaloDownloads
import HaloPlayback
/// Die Wurzelansicht.
///
/// Entscheidet anhand des Sitzungszustands, ob überhaupt etwas zu zeigen ist.
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
struct RootView: View {
@Environment(HaloSession.self) private var session
@Environment(HaloTheme.self) private var theme
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(LanguagePreferences.self) private var languages
@Environment(QuickPlay.self) private var quickPlay
@Environment(SubtitleAppearance.self) private var subtitleAppearance
@Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackDiagnostics.self) private var diagnostics
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
///
/// Damit überlebt die Wiedergabe den Wechsel zwischen Kachel und
/// Vollbild. Läge das Modell im Player selbst, stürbe es mit ihm, und
/// genau das ist vorher passiert.
@State private var player: PlayerModel?
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selection: SidebarDestination = .home
/// Was der Nutzer an der Navigation umgestellt hat.
///
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
/// zum Aufbewahren.
@AppStorage("halo.navigation.customization")
private var customization = TabViewCustomization()
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
///
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
@AppStorage("halo.navigation.placement")
private var storedPlacement: NavigationPlacement = .sidebar
/// Dieselbe Wahl, aber als Zustand dieser Ansicht.
///
/// Der Umweg ist nötig, damit das Umschalten überhaupt zu sehen ist:
/// `@AppStorage` schreibt in die Voreinstellungen und meldet die Änderung
/// erst danach zurück. Sie kommt also außerhalb der Animation an, und die
/// Leiste sprang von einer Stellung in die andere, statt zu wandern.
///
/// Hier ändert sich der Zustand sofort und im selben Zug; gespeichert wird
/// hinterher.
@State private var placement: NavigationPlacement = .sidebar
/// Wie die Leiste umzieht. Kurz, aber sichtbar.
private static let placementAnimation: Animation = .snappy(duration: 0.32)
/// Was der Nutzer an der Navigation umgestellt hat: Reihenfolge und was
/// überhaupt dastehen soll.
@State private var navigationLayout = NavigationLayoutSettings()
/// Die Insel oben: wie hoch sie baut und ob sie gerade zu sehen ist.
@State private var topChrome = TopChrome()
/// Wie hoch die Statusleiste baut.
///
/// Gemessen und nicht angenommen: es sind nicht überall dieselben Punkte,
/// und beim Drehen ändert sich der Wert. Gemessen wird **außerhalb** des
/// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb
/// wäre er längst null.
@State private var statusBarHeight: CGFloat = 0
/// Ob die Einstellungen gerade offen sind.
///
/// Nur auf dem iPhone: dort gibt es den Tab nicht mehr, und der Weg führt
/// über das Monogramm oben rechts.
@State private var showsSettings = false
var body: some View {
Group {
switch session.phase {
case .launching:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.haloCanvas)
case .signedOut:
SignInView()
case .connecting(let name):
ConnectingView(serverName: name)
case .failed(let message):
ConnectionFailureView(
message: message,
onRetry: { Task { await session.retry() } },
onSignOut: { session.signOut() }
)
case .ready:
library
}
}
.background(Color.haloCanvas)
.environment(topChrome)
// **Aus dem Fenster, nicht aus dem Layout.**
//
// Hier stand `proxy.safeAreaInsets.top` — ein Wert, der beim Drehen
// erst eine Runde später stimmt. Nachgemessen sah man das genau
// einmal: quer war die Seitenleiste zuerst 620,67 Punkte hoch und
// dann 558,67. Die Differenz sind zweiundsechzig, und die kamen von
// `.padding(.top, statusBarHeight)` an der Leiste weiter unten, das
// noch den Wert aus dem Hochformat trug.
//
// Das Fenster kennt seinen Rand ohne Umweg über das Layout, das er
// selbst beeinflusst. Die Grösse bleibt der Anlass zum Nachfragen,
// der **Wert** kommt von aussen — und stimmt damit im ersten
// Durchgang.
//
// **Nicht** die Ursache des Players, der oben und unten hinausging:
// das war die Seitenleiste selbst, siehe `HaloSidebar`. Diese Zeile
// spart eine Layoutrunde, mehr nicht.
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { _ in statusBarHeight = ScreenSafeArea.current.top }
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
// zuverlässig neu aus, ein Scene-Body nicht.
.haloAccent(theme)
.tint(theme.accentColor)
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
.alert(
Text("quickPlay.failed"),
isPresented: Binding(
get: { quickPlay.failure != nil },
set: { if !$0 { quickPlay.failure = nil } }
)
) {
Button("action.close", role: .cancel) { quickPlay.failure = nil }
} message: {
if let grund = quickPlay.failure { Text(grund) }
}
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
// Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und
// die Bildfläche müsste bei jedem Wechsel zwischen Kachel und
// Vollbild umziehen. Als Schicht im selben Baum bleibt sie, wo sie
// ist, und der Übergang ist ruhig.
.overlay {
if playback.presentation == .full, let player {
// **Bildfüllend, und der Player passt drinnen selbst auf.**
//
// Ohne diese Zeile verliert die Bildfläche ihr
// `ignoresSafeArea` mit — das Bild wäre im Querformat um die
// Notch-Breite schmaler. Wo der Schirm aufhört, holt sich der
// Player deshalb aus dem Fenster; siehe `ScreenSafeArea`.
PlayerView(model: player)
.ignoresSafeArea()
.transition(.move(edge: .bottom))
}
}
.animation(.snappy(duration: 0.3), value: playback.presentation)
.onChange(of: playback.request) { _, request in
Task { await handOver(to: request) }
}
.task { await session.restore() }
// **Kommt das Netz zurück, geht es weiter.**
//
// Ein Download, den ein Funkloch unterbrochen hat, lag bisher da, bis
// ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer
// Stunde wieder stand.
//
// Hier und nicht im Koordinator: `HaloDownloads` hängt bewusst nicht
// an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht
// (siehe `TransferHold`). Diese Ansicht kennt beide.
.onChange(of: network.isReachable) { _, erreichbar in
guard erreichbar else { return }
Task { await downloads.resumeInterrupted() }
}
.onChange(of: session.phase) { _, newPhase in
// Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt
// bleiben, die es auf dem neuen Server gar nicht gibt.
if newPhase == .ready, case .library = selection { selection = .home }
}
.onChange(of: sizeClass) { _, _ in
// Beim Drehen oder Teilen des Fensters wechselt die Form der
// Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine
// Auswahl, die auf der anderen Seite nicht vorkommt, zeigte sonst
// einen leeren Bereich.
switch selection {
// `.settings` gehört dazu, seit es den Tab auf dem iPhone nicht
// mehr gibt: wer am iPad in den Einstellungen steht und das
// Fenster schmal zieht, stünde sonst vor einem Bereich, den die
// Leiste gar nicht führt.
case .library, .libraryHub, .liveTV, .settings: selection = .home
default: break
}
}
}
/// Wechselt den laufenden Titel.
///
/// Das Neue wird zuerst aufgebaut, das Alte **nebenher** beendet. Anders
/// herum säße man nach dem Antippen sekundenlang vor einem leeren Bild:
/// das Abräumen spricht mit dem Server — Transcoder anhalten, beim
/// Live-TV den Tuner freigeben —, und das darf den neuen Titel nicht
/// aufhalten. Ein kurzzeitig doppelt belegter Tuner ist der kleinere
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
/// abgestimmt, bevor es hier ankommt.
private func handOver(to request: PlaybackRequest?) async {
// Zuerst und ohne Warten: den alten Engine stilllegen. Das Aufräumen am
// Server darf dauern, das Abschalten nicht — läuft der alte Engine
// weiter, während der neue startet, streiten sich zwei um dieselbe
// Audiositzung. Zu hören war das als alter Ton, der weiterlief, und
// als neue Wiedergabe, die stumm blieb.
let previous = player
previous?.silence()
player = nil
if let previous { Task { await previous.releaseOnServer() } }
guard let request else { return }
let fresh = PlayerModel(
request: request,
backend: session.backend,
liveTV: session.liveTV,
downloads: downloads,
diagnostics: diagnostics,
policy: settings.policy(for: network),
settings: settings,
languages: languages,
subtitles: subtitleAppearance,
// **Vom Server erfragt, nicht aus der Adresse geschlossen.** Wer
// über einen Reverse Proxy geht, erreicht seinen Server auch von
// der Couch aus unter einem öffentlichen Namen — ihn deshalb als
// entfernt zu führen hiesse, „Auto" würde ein 67-Mbit-Remux
// zuhause am Kabel ohne Not herunterrechnen.
isLocalConnection: session.isLocalConnection,
connectionDescription: session.connectionDescription,
unavailableMessage: session.playbackUnavailableMessage
)
// Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in
// der Kachel — dort kam man vorher gar nicht mehr heraus.
fresh.onPictureInPictureRestore { playback.expand() }
player = fresh
await fresh.start()
}
/// Die Navigation.
///
/// Sie kommt vom System, nicht von uns. `sidebarAdaptable` ist genau die
/// Form, die man aus JellyTV kennt: auf dem iPad eine Seitenleiste aus
/// Glas, über deren Kopfzeile sich mit einem Knopf auf eine Leiste **oben**
/// umschalten lässt, auf dem iPhone die gewohnte Leiste unten. Dazu gibt
/// es „Bearbeiten" mit Ziehen, Ein- und Ausblenden — alles vom System,
/// übersetzt, barrierefrei und mit der Gestaltung, die Apple in diesem
/// Jahr vorgibt.
///
/// Vorher standen hier drei selbstgebaute Formen nebeneinander: ein
/// ausfahrbares Sheet, 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 tabs: some View {
TabView(selection: $selection) {
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
// **Innerhalb des Stapels, nicht darum herum.** Als Überzug
// über `mediaStack` lag er auch über jedem **geschobenen**
// Bildschirm — auf einer Detailseite also über deren
// Drei-Punkte-Menü, zwei Knöpfe übereinander. Hier gilt er
// für die Startseite und endet mit ihr.
//
// Dort ist ein Überzug der einzige Platz: die Startseite
// schaltet ihre Navigationsleiste ab, ein `ToolbarItem`
// erschiene gar nicht.
mediaStack {
HomeView()
.overlay(alignment: .topTrailing) { profileButton }
}
}
.customizationID("halo.tab.home")
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
// die Leiste unten hat fünf Plätze, und alles darüber schiebt das
// System in ein „Mehr"-Verzeichnis — dessen Einträge dann zwei
// Navigationsleisten übereinander tragen, eine vom System und eine
// von uns.
if sizeClass == .regular {
TabSection {
ForEach(session.libraries) { library in
Tab(
value: SidebarDestination.library(key: library.id, title: library.title)
) {
mediaStack { LibraryView(library: library) }
} label: {
sidebarLabel(Text(library.title), symbol: library.kind.symbol)
}
.customizationID("halo.tab.library.\(library.id)")
}
} header: {
Text("sidebar.libraries")
}
.customizationID("halo.tab.libraries")
if session.supportsLiveTV {
Tab(value: SidebarDestination.liveTV) {
mediaStack { LiveTVView() }
} label: {
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
}
.customizationID("halo.tab.liveTV")
}
} else {
Tab(value: SidebarDestination.libraryHub) {
mediaStack { LibraryBrowser() }
} label: {
tabLabel("sidebar.library", symbol: "books.vertical")
}
.customizationID("halo.tab.libraryHub")
}
Tab(value: SidebarDestination.downloads) {
mediaStack { DownloadsView() }
} label: {
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
}
.customizationID("halo.tab.downloads")
// **Nicht auf dem iPhone.** Dort führt der Weg über das
// Monogramm oben rechts — siehe `profileButton`. Einstellungen
// sind nichts, was man im Wechsel mit der Mediathek benutzt; ein
// Platz in der Leiste unten unterstellt genau das. In der
// Seitenleiste bleibt der Eintrag, dort ist Platz.
if sizeClass == .regular {
Tab(value: SidebarDestination.settings) {
mediaStack { SettingsView() }
} label: {
tabLabel("sidebar.settings", symbol: "gearshape")
}
.customizationID("halo.tab.settings")
}
// Die Suche nur dort, wo Platz für sie ist.
//
// Die Leiste unten hat fünf Plätze. Mit der Suche wäre einer davon
// für etwas belegt, das man in einer Mediathek sucht und nicht in
// einer Leiste — deshalb sitzt sie auf dem iPhone über der
// Mediathek, neben Filter und Ansicht.
if sizeClass == .regular {
Tab(value: SidebarDestination.search, role: .search) {
mediaStack { SearchView() }
}
.customizationID("halo.tab.search")
}
}
.tabViewStyle(.sidebarAdaptable)
.tabViewCustomization($customization)
}
/// Die Navigation, wie **wir** sie zeichnen.
///
/// Der `TabView` darunter bleibt stehen und macht weiter, was er kann:
/// Auswahl, ein eigener Navigationsstapel je Eintrag, erhaltener
/// Scrollstand beim Wechseln. Nur sein Kleid ist ausgeblendet — siehe
/// `TabChromeSuppressor`. Gezeichnet wird darüber.
///
/// Der Grund steht dort ausführlich: die System-Seitenleiste sitzt auf dem
/// iPad als schwebende Fläche und auf dem Mac angeklebt am Fensterrand,
/// und das lässt sich nicht umstellen. Da beides gleich aussehen soll,
/// zeichnen wir es selbst.
///
/// Auf dem iPhone bleibt alles beim System: dort gibt es keine
/// Seitenleiste, sondern die Leiste unten, und die ist überall dieselbe.
private var library: some View {
Group {
#if os(iOS)
if sizeClass == .regular {
HStack(spacing: 0) {
if placement == .sidebar {
HaloSidebar(
groups: navigationGroups,
selection: $selection,
placement: $placement.animation(Self.placementAnimation),
layout: navigationLayout
)
// Sie klappt nach **oben** weg und kommt von oben
// zurück — und die Leiste oben schiebt sich seitwärts
// herein beziehungsweise hinaus. Zusammen sieht es aus,
// als zöge dieselbe Leiste um: erst zusammenklappen,
// dann hinüberschieben.
.transition(.move(edge: .top).combined(with: .opacity))
// Die Statusleiste gehört ihr nicht. Seit der Baum den
// Sicherheitsrand ignoriert, säße ihre Oberkante sonst
// unter der Uhrzeit.
.padding(.top, statusBarHeight)
}
tabs
// Der Platz oben als **Sicherheitsrand**, nicht als
// Abstand.
//
// Der Unterschied ist der schwarze Balken, über den du
// dich zu Recht gewundert hast: mit einem Abstand hört
// der Inhalt dort auf und die Fläche darüber bleibt für
// immer leer. Als Sicherheitsrand fängt er im
// Ruhezustand ebenso darunter an — aber beim Scrollen
// wandert er **hindurch**, bis an den Bildschirmrand.
// Die Leiste ist eine Insel; hinter einer Insel läuft
// das Wasser durch.
//
// Anders als `safeAreaPadding` erreicht das auch die
// geschobenen Bildschirme: deren Navigationsleisten
// richten sich danach.
//
// Die Statusleiste ist mit eingerechnet, weil der Baum
// sie gleich unten ignoriert. Ohne sie begänne der
// Inhalt hinter der Uhrzeit.
.safeAreaInset(edge: .top, spacing: 0) {
Color.clear.frame(height: topInset)
}
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
.overlay(alignment: .top) {
if placement == .top {
HaloTopBar(
groups: navigationGroups.map(visible),
selection: $selection,
placement: $placement.animation(Self.placementAnimation)
)
.transition(.move(edge: .leading).combined(with: .opacity))
.padding(.top, statusBarHeight)
// Beim Scrollen fährt sie nach oben weg.
//
// Nur sie: der Sicherheitsrand darunter bleibt,
// wie er ist. Schrumpfte er mit, rückte der
// Inhalt bei jedem Ein- und Ausblenden nach —
// man scrollt, und die Seite ruckt einem unter
// dem Finger weg.
.offset(y: topChrome.isRevealed ? 0 : -topInset)
.opacity(topChrome.isRevealed ? 1 : 0)
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
}
}
}
// Hier reißt der Baum bis an den Bildschirmrand auf.
//
// Das ist der eigentliche Griff. Vorher saß alles — Leiste,
// Bildschirme, Bildlaufansichten — innerhalb des
// Sicherheitsrands, und deshalb konnte kein noch so gut
// gesetzter Rand weiter unten den schwarzen Streifen über der
// Insel erreichen: Inhalt kam dort nie hin. Jetzt kommt er.
.ignoresSafeArea(.container, edges: .top)
} else {
tabs
}
#else
tabs
#endif
}
// **Als Blatt, nicht als Bereich.** `SettingsView` ist ein Formular
// aus geschobenen Unterbildschirmen und braucht nur einen
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
// statt in einem Bereich, den man erst wieder verlassen muss.
.environment(\.haloOpenSettings, { showsSettings = true })
.sheet(isPresented: $showsSettings) {
NavigationStack {
SettingsView()
// Ein Blatt lässt sich nach unten wegziehen — aber nicht
// jeder weiss das, und wer in einem Unterbildschirm steht,
// zieht damit gleich alles weg. Ein Knopf sagt es.
.toolbar {
ToolbarItem(placement: .haloTrailing) {
Button("action.close") { showsSettings = false }
}
}
}
}
.miniPlayer(player, isVisible: playback.presentation == .mini)
.onAppear { placement = storedPlacement }
.onChange(of: placement) { _, new in
storedPlacement = new
// Zieht die Navigation um, soll die neue Leiste nicht
// ausgeblendet ankommen.
topChrome.isRevealed = true
}
// Weitergereicht, damit der Aufmacher weiß, worunter er greifen muss.
.onChange(of: topInset, initial: true) { _, new in topChrome.inset = new }
}
/// Der Weg in die Einstellungen auf dem iPhone.
///
/// **Ein Überzug, kein Werkzeugleisten-Knopf.** Die Startseite schaltet
/// ihre Navigationsleiste ganz ab — sie war leer und belegte trotzdem
/// Höhe —, ein `ToolbarItem` erschiene dort also gar nicht. Hier liegt er
/// einmal über allen Bildschirmen statt dreimal in deren Leisten, und er
/// sitzt überall an derselben Stelle.
///
/// Er blendet sich beim Scrollen aus wie die Insel auf dem iPad: über
/// einem Film, den man gerade sucht, hat ein Kontoknopf nichts verloren.
@ViewBuilder
private var profileButton: some View {
Button {
showsSettings = true
} label: {
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
.padding(HaloSpacing.snug)
// Damit er auch über einem hellen Aufmacher steht.
.background(.black.opacity(0.28), in: .circle)
}
.buttonStyle(.plain)
.padding(.trailing, HaloSpacing.regular)
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
// Knopf um ihre Höhe nach unten, mitten ins Bild.
.padding(.top, HaloSpacing.snug)
.opacity(topChrome.isRevealed ? 1 : 0)
.allowsHitTesting(topChrome.isRevealed)
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
.accessibilityLabel(Text("sidebar.settings"))
}
/// Wie viel Platz die Leiste oben braucht.
private static let topBarHeight: CGFloat = 52
/// Wie tief der Inhalt unter dem oberen Bildschirmrand beginnt.
///
/// Die Statusleiste plus das, was die Navigation dort belegt — bei der
/// Seitenleiste nur ihr Rand, bei der Leiste oben deren volle Höhe.
///
/// Auf dem iPhone null: dort sitzt die Leiste unten, und über dem Inhalt
/// schwebt nichts, worunter man greifen müsste.
private var topInset: CGFloat {
guard sizeClass == .regular else { return 0 }
return statusBarHeight + (placement == .sidebar ? HaloSidebar.margin : Self.topBarHeight)
}
private var navigationGroups: [NavigationGroup] {
var groups: [NavigationGroup] = [
NavigationGroup(id: "main", title: nil, entries: [
NavigationEntry(destination: .home, title: Text("sidebar.home"), symbol: "house", plainTitle: String(localized: "sidebar.home")),
NavigationEntry(destination: .downloads, title: Text("sidebar.downloads"), symbol: "arrow.down.circle", plainTitle: String(localized: "sidebar.downloads")),
NavigationEntry(destination: .settings, title: Text("sidebar.settings"), symbol: "gearshape", plainTitle: String(localized: "sidebar.settings")),
NavigationEntry(destination: .search, title: Text("sidebar.search"), symbol: "magnifyingglass", plainTitle: String(localized: "sidebar.search")),
])
]
var media = session.libraries.map { library in
NavigationEntry(
destination: .library(key: library.id, title: library.title),
title: Text(library.title),
symbol: library.kind.symbol,
plainTitle: library.title
)
}
if session.supportsLiveTV {
media.append(NavigationEntry(
destination: .liveTV,
title: Text("sidebar.liveTV"),
symbol: "dot.radiowaves.left.and.right",
plainTitle: String(localized: "sidebar.liveTV")
))
}
if !media.isEmpty {
groups.append(NavigationGroup(id: "libraries", title: "sidebar.libraries", entries: media))
}
return groups
}
/// Eine Gruppe so, wie sie dastehen soll — ausgeblendete Einträge raus,
/// Reihenfolge angewandt. Die Seitenleiste macht das selbst, weil sie zum
/// Bearbeiten auch die ausgeblendeten braucht.
private func visible(_ group: NavigationGroup) -> NavigationGroup {
NavigationGroup(
id: group.id,
title: group.title,
entries: navigationLayout.apply(to: group.entries)
)
}
/// Ein Navigationsstapel, in dem Detailseiten aufgehen.
///
/// Das Ziel wird **einmal je Stapel** angemeldet, an seiner Wurzel. Vorher
/// tat es jeder Bildschirm für sich — und in dem Moment, in dem zwei davon
/// im selben Stapel übereinanderlagen (die Mediathek in der Bibliothek),
/// führte kein Antippen einer Kachel mehr irgendwohin. SwiftUI meldet das
/// nicht; es passiert einfach nichts.
private func mediaStack<Content: View>(@ViewBuilder content: @escaping () -> Content) -> some View {
MediaStack(content: content)
}
/// Die Breite der Seitenleiste auf dem Mac.
///
/// Sie richtet sich nach ihrem Inhalt. Ohne Untergrenze fällt sie bei
/// kurzen Wörtern wie „Start" so weit zusammen, dass der Kontoname darunter
/// abgeschnitten wird; **ohne Obergrenze** ließ sie sich über das halbe
/// Fenster ziehen. Beide Zahlen sind nötig, und die obere entspricht dem,
/// was eine Seitenleiste auf dem iPad breit ist.
#if os(macOS) || targetEnvironment(macCatalyst)
private static let sidebarWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat) =
(min: 190, ideal: 230, max: 320)
#endif
/// Die Beschriftung eines Eintrags.
///
/// **Ein** Aufruf, nicht zwei. Es gab hier eine zweite Fassung für Titel,
/// die vom Server kommen — mit `String` statt `LocalizedStringKey`. Swift
/// darf sich bei einem Literal für beide entscheiden, und es entschied sich
/// nicht überall gleich: in der Seitenleiste stand plötzlich
/// „sidebar.downloads" statt „Downloads". Wer einen fertigen Text hat,
/// baut sein `Text` jetzt selbst.
private func tabLabel(_ title: LocalizedStringKey, symbol: String) -> some View {
sidebarLabel(Text(title), symbol: symbol)
}
private func sidebarLabel(_ title: Text, symbol: String) -> some View {
let label = Label { title } icon: { Image(systemName: symbol) }
#if os(macOS)
return label.frame(
minWidth: Self.sidebarWidth.min - 40,
maxWidth: Self.sidebarWidth.max - 40,
alignment: .leading
)
#else
return label
#endif
}
}
@@ -0,0 +1,77 @@
import Foundation
/// Was in der Seitenleiste steht.
///
/// Die Mediatheken kommen erst zur Laufzeit vom Server, deshalb trägt
/// `library` den Schlüssel des Abschnitts statt eines festen Falls.
enum SidebarDestination: Hashable {
case home
case library(key: String, title: String)
/// Alle Mediatheken zusammen — auf schmalen Bildschirmen an ihrer Stelle.
case libraryHub
case search
case liveTV
case downloads
case settings
/// Eine feste Kennung zum Speichern.
///
/// **Ohne** den Titel: benennt jemand eine Mediathek auf dem Server um,
/// soll sie ihren Platz in der Seitenleiste behalten und nicht als neuer
/// Eintrag ganz unten auftauchen.
var storageKey: String {
switch self {
case .home: "home"
case .library(let key, _): "library:\(key)"
case .libraryHub: "libraryHub"
case .search: "search"
case .liveTV: "liveTV"
case .downloads: "downloads"
case .settings: "settings"
}
}
/// Der Titel in der Navigationsleiste.
///
/// Auf der Startseite steht dort der Name der App — in der Seitenleiste
/// wäre das nur eine Dopplung der Überschrift, deshalb hat sie mit
/// `sidebarLabel` einen eigenen Text.
var title: String {
switch self {
case .home: Branding.appName
case .library(_, let title): title
case .libraryHub: String(localized: "sidebar.library")
case .search: String(localized: "search.title")
case .liveTV: String(localized: "sidebar.liveTV")
case .downloads: String(localized: "sidebar.downloads")
case .settings: String(localized: "sidebar.settings")
}
}
/// Die Beschriftung in der Seitenleiste.
var sidebarLabel: String {
switch self {
case .home: String(localized: "sidebar.home")
default: title
}
}
var symbol: String {
switch self {
case .home: "house"
case .library: "film"
case .libraryHub: "books.vertical"
case .search: "magnifyingglass"
case .liveTV: "dot.radiowaves.left.and.right"
case .downloads: "arrow.down.circle"
case .settings: "gearshape"
}
}
}
/// Der Weg zur Suche, wenn sie kein eigener Eintrag ist.
///
/// Auf dem iPhone hat die Leiste unten fünf Plätze, und einer davon für die
/// Suche ist einer zu viel — sie sitzt dort, wo man sie sucht: über der
/// Mediathek, neben Filter und Ansicht.
struct SearchRoute: Hashable {}
+81
View File
@@ -0,0 +1,81 @@
#if os(iOS)
import SwiftUI
import UIKit
/// Blendet die vom System gezeichnete Seitenleiste und Tableiste aus.
///
/// Der Grund ist das Aussehen, nicht das Verhalten. `UITabBarController`
/// zeichnet seine Seitenleiste auf dem iPad als schwebende Fläche und auf dem
/// Mac angeklebt am Fensterrand — und das lässt sich nicht umstellen.
/// `preferredLayout = .overlap` ist die dafür vorgesehene Bitte; macOS verwirft
/// sie stillschweigend. Gemessen, nicht vermutet.
///
/// Statt deshalb die ganze Navigation selbst zu bauen, bleibt der
/// `TabView` **stehen** und verliert nur sein Kleid: Auswahl, getrennte
/// Navigationsstapel je Eintrag, erhaltener Scrollstand beim Wechseln,
/// Zustandspflege — all das macht weiter das System. Gezeichnet wird darüber.
///
/// Dass das geht, war kein Ratschluss: derselbe Griff hat die Leiste
/// versuchsweise schon einmal verschwinden lassen.
struct TabChromeSuppressor: 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)
suppress()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Ein zweites Mal: beim ersten Anhängen steht der Tab-Controller
// noch nicht zwingend im Baum.
suppress()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// Und bei jedem Umbau: das System stellt die Leisten wieder her,
// wenn sich die Größenklasse ändert — beim Drehen etwa.
suppress()
}
private func suppress() {
guard let controller = Self.tabBarController(from: self) else { return }
// Erst die Form festlegen, dann ausblenden — und zwar auf die
// Tableiste, nicht auf die Seitenleiste.
//
// Nur `sidebar.isHidden = true` reichte nicht: auf dem Mac stand
// die System-Seitenleiste danach wieder da, mit eigenem Pfeil und
// eigenem Knopf, hinter unserer. Solange `mode` auf `tabSidebar`
// steht, gehört die Seitenleiste zur Darstellung und das System
// stellt sie wieder her. Steht `mode` auf `tabBar`, gibt es keine
// Seitenleiste mehr, die zurückkommen könnte — und die Tableiste
// ist ausgeblendet.
if controller.mode != .tabBar { controller.mode = .tabBar }
if !controller.isTabBarHidden { controller.isTabBarHidden = true }
if !controller.sidebar.isHidden { controller.sidebar.isHidden = true }
}
/// Sucht vom eigenen Platz aus nach oben bis zur Wurzel und von dort
/// nach unten: die Leiste liegt in der SwiftUI-Sicht *über* uns, die
/// reine Elternkette führt an ihr vorbei.
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
+68
View File
@@ -0,0 +1,68 @@
import SwiftUI
import Observation
/// Was oben über dem Inhalt schwebt — und wie viel Platz es einnimmt.
///
/// Zwei Dinge, die zusammengehören und deshalb an einer Stelle liegen: die
/// Höhe, die Statusleiste und Insel zusammen belegen, und ob die Insel gerade
/// zu sehen ist.
///
/// Der Umweg über ein geteiltes Objekt ist nötig, weil beide Enden weit
/// auseinanderliegen. Die Höhe kennt nur `RootView` — sie hängt am Gerät und
/// daran, ob die Navigation an der Seite oder oben sitzt. Brauchen tut sie der
/// Aufmacher, der unter ihr durchgreifen soll, und der sitzt drei Ebenen
/// tiefer in einem eigenen Navigationsstapel. Umgekehrt weiß nur die
/// Bildlaufansicht des gerade sichtbaren Bildschirms, ob man oben steht —
/// entscheiden muss es `RootView`.
@MainActor
@Observable
final class TopChrome {
/// Wie hoch Statusleiste und Insel zusammen bauen.
///
/// Der Wert, um den der Inhalt nach unten beginnt. Wer ihn überspringen
/// und bis an den Bildschirmrand greifen will, zieht sich um genau diese
/// Strecke nach oben.
var inset: CGFloat = 0
/// Ob die Insel gerade zu sehen ist.
///
/// Steht man ganz oben, ist sie da. Beim Scrollen fährt sie weg — der
/// Inhalt ist dann das Wichtigere, und die Navigation kommt zurück, sobald
/// man wieder oben ankommt.
var isRevealed = true
}
extension View {
/// Meldet dieser Bildlaufansicht an, ob sie ganz oben steht.
///
/// Gehört an die **äußere, senkrechte** Bildlaufansicht eines Bildschirms.
/// Nicht an die waagerechten Reihen darin: `onScrollGeometryChange` meldet
/// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht
/// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite
/// weit unten steht.
func haloRevealsTopChrome() -> some View {
modifier(RevealsTopChrome())
}
}
private struct RevealsTopChrome: ViewModifier {
@Environment(TopChrome.self) private var chrome
func body(content: Content) -> some View {
content
// Der Sicherheitsrand gehört dazugerechnet: `contentOffset` allein
// beginnt bei einer Ansicht mit Rand oben im Negativen, und „ganz
// oben" wäre dann nie null.
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y + geometry.contentInsets.top
} action: { _, offset in
// Eine Schwelle statt genau null: das Gummiband am oberen Ende
// liefert auch im Ruhezustand Werte, die um Bruchteile eines
// Punktes schwanken, und die Insel flackerte sonst.
chrome.isRevealed = offset <= 1
}
// Wer einen Bildschirm verlässt, während er weit unten steht, käme
// sonst auf dem nächsten ohne Navigation an.
.onDisappear { chrome.isRevealed = true }
}
}
@@ -0,0 +1,23 @@
import SwiftUI
import HaloDesign
/// Ein noch nicht gebauter Bereich.
///
/// Gibt es nur in Phase 0, damit die Navigation vollständig begehbar ist,
/// bevor es Inhalte gibt. Jede Phase ersetzt einen dieser Bildschirme durch
/// den echten.
struct PlaceholderScreen: View {
let destination: SidebarDestination
var body: some View {
ContentUnavailableView {
Label(destination.title, systemImage: destination.symbol)
} description: {
Text("placeholder.description")
}
.background(Color.haloCanvas)
.navigationTitle(destination.title)
.haloInlineTitle()
}
}
+50
View File
@@ -0,0 +1,50 @@
import AVKit
import SwiftUI
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
/// Der Knopf, der die Empfängerliste öffnet.
///
/// Kein eigener Knopf mit eigener Liste: `AVRoutePickerView` ist die Stelle,
/// die das System dafür vorsieht, und sie kennt Geräte, die eine App nie
/// aufzählen könnte — Apple TVs, AirPlay-Lautsprecher, HomePods im Verbund.
/// Sie nachzubauen hieße, eine unvollständige Liste zu zeigen.
#if canImport(UIKit)
struct AirPlayButton: UIViewRepresentable {
let tint: Color
func makeUIView(context: Context) -> AVRoutePickerView {
let view = AVRoutePickerView()
view.tintColor = .white
view.activeTintColor = UIColor(tint)
// Bild vor Ton: bei einem Film soll die Liste die Fernseher zuerst
// zeigen, nicht die Lautsprecher.
view.prioritizesVideoDevices = true
view.setContentHuggingPriority(.required, for: .horizontal)
return view
}
func updateUIView(_ view: AVRoutePickerView, context: Context) {
view.activeTintColor = UIColor(tint)
}
}
#else
struct AirPlayButton: NSViewRepresentable {
let tint: Color
func makeNSView(context: Context) -> AVRoutePickerView {
let view = AVRoutePickerView()
view.isRoutePickerButtonBordered = false
view.setRoutePickerButtonColor(.white, for: .normal)
view.setRoutePickerButtonColor(NSColor(tint), for: .active)
return view
}
func updateNSView(_ view: AVRoutePickerView, context: Context) {
view.setRoutePickerButtonColor(NSColor(tint), for: .active)
}
}
#endif
+38
View File
@@ -0,0 +1,38 @@
import UIKit
/// Das Fenster auf dem Mac ins Vollbild und zurück.
///
/// **Und warum das umständlich ist.** Für eine „für iPad angepasste" App gibt
/// es dafür keine öffentliche Schnittstelle. UIKit kennt `isFullScreen` nur
/// lesend und nur für Mac Catalyst; einen Umschalter gibt es nirgends. Das
/// Fenster gehört AppKit, und an AppKit kommt diese App nicht heran.
///
/// Was bleibt, ist die **Antwortkette**: `toggleFullScreen:` ist die Aktion,
/// die auch der grüne Knopf und ⌃⌘F auslösen, und UIKit reicht unbeantwortete
/// Aktionen an das tragende AppKit-Fenster weiter. Ob es das tut, ist
/// nirgends zugesagt.
///
/// **Deshalb wird vorher gefragt, nicht hinterher gehofft.**
/// `target(forAction:)` sagt, ob die Aktion jemand entgegennimmt — **ohne sie
/// auszulösen**. Erst wenn es einen Empfänger gibt, erscheint der Knopf. Ein
/// Knopf, der nichts tut, ist schlimmer als keiner: er lässt den Nutzer an
/// sich zweifeln statt an der Plattform.
@MainActor
enum MacFullScreen {
private static let action = NSSelectorFromString("toggleFullScreen:")
/// Ob es hier überhaupt jemanden gibt, der umschalten kann.
///
/// Bei jedem Zeichnen gefragt statt einmal gemerkt: die Antwortkette
/// hängt am gerade aktiven Fenster, und ein einmal ermittelter Wert wäre
/// falsch, sobald es keins gibt — etwa vor dem ersten Erscheinen.
static var isAvailable: Bool {
UIApplication.shared.target(forAction: action, withSender: nil) != nil
}
/// Schaltet um. Gibt zurück, ob jemand zugehört hat.
@discardableResult
static func toggle() -> Bool {
UIApplication.shared.sendAction(action, to: nil, from: nil, for: nil)
}
}
+248
View File
@@ -0,0 +1,248 @@
import SwiftUI
import HaloDesign
/// Die schwebende Videokachel.
///
/// Damit läuft weiter, was man gerade sieht, während man nach dem Nächsten
/// stöbert — der Grund, warum es sie gibt. Plex' eigene Handy-Apps reißen die
/// Wiedergabe beim Zurückgehen ab, und genau in dem Moment will man
/// weiterschauen und nebenher nachsehen, was sonst noch läuft.
///
/// Bewusst schwebend statt als Leiste am unteren Rand: eine Leiste nimmt jeder
/// Liste Platz weg und müsste in jedem Bildschirm berücksichtigt werden. Eine
/// Kachel schwebt darüber, und wem sie im Weg ist, der zieht sie weg.
struct MiniPlayer: View {
let model: PlayerModel
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.horizontalSizeClass) private var sizeClass
/// Seite und Höhe bleiben über Starts hinweg erhalten. Wer die Kachel
/// einmal nach links unten gezogen hat, will sie dort wiederfinden.
@AppStorage("miniPlayer.side") private var storedSide = MiniPlayerPlacement.Side.trailing.rawValue
/// Als Anteil, nicht in Punkten: beim Drehen bleibt sie so an derselben
/// Stelle, statt unten herauszufallen.
@AppStorage("miniPlayer.height") private var storedFraction = 0.85
/// Verschiebung während des Ziehens, noch nicht eingerastet.
@State private var drag: CGSize = .zero
private var side: MiniPlayerPlacement.Side {
MiniPlayerPlacement.Side(rawValue: storedSide) ?? .trailing
}
/// Die selbst eingestellte Breite. Null heißt „noch nie angefasst".
@AppStorage("miniPlayer.width") private var storedWidth: Double = 0
/// Vergrößerung während des Zusammen- oder Auseinanderziehens.
@State private var zoom: CGFloat = 1
/// Die zuletzt tatsächlich gezeigte Breite — schon begrenzt.
///
/// Ohne sie würde beim Loslassen der ungebremste Wert gespeichert, und
/// eine einmal zu weit gezogene Kachel bliebe für immer zu groß.
@State private var lastWidth: CGFloat = 0
/// Auf dem iPad größer voreingestellt — dort ist genug Fläche, und bei
/// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts.
private var defaultWidth: CGFloat { sizeClass == .regular ? 280 : 180 }
private func tileSize(in bounds: CGSize) -> CGSize {
let chosen = storedWidth > 0 ? CGFloat(storedWidth) : defaultWidth
// Nach oben durch den Bildschirm begrenzt: eine Kachel, die breiter
// ist als das Fenster, ließe sich nicht mehr wegziehen.
let upper = max(160, min(bounds.width * 0.75, 560))
let width = min(max(chosen * zoom, 140), upper)
return CGSize(width: width, height: (width / HaloAspectRatio.landscape).rounded())
}
var body: some View {
GeometryReader { geometry in
let tileSize = tileSize(in: geometry.size)
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
let resting = placement.center(side: side, verticalFraction: storedFraction)
let position = placement.clamped(
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
)
tile
.frame(width: tileSize.width, height: tileSize.height)
.onChange(of: tileSize.width, initial: true) { _, width in
lastWidth = width
}
.position(position)
.gesture(dragGesture(in: placement, from: resting))
// Gleichzeitig, damit das Zusammenziehen nicht erst nach dem
// Loslassen greift — beides passiert mit denselben Fingern.
.simultaneousGesture(zoomGesture(in: geometry.size))
// Nur während des Ziehens ohne Animation, sonst würde die
// Kachel dem Finger hinterherfedern statt an ihm zu kleben.
.animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position)
}
.transition(.scale(scale: 0.8).combined(with: .opacity))
}
private var tile: some View {
ZStack {
Color.black
VideoSurface(
engine: model.engine,
surface: model.surface,
isActive: playback.presentation == .mini
)
if model.isBuffering {
ProgressView().tint(.white)
}
corners
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
// es gibt kein Ende, auf das er zulaufen könnte.
if !model.isLive, let fraction = progress {
VStack {
Spacer()
WatchProgressBar(fraction: fraction)
}
}
}
.clipShape(.rect(cornerRadius: HaloRadius.card))
.overlay {
RoundedRectangle(cornerRadius: HaloRadius.card)
.strokeBorder(Color.haloSeparator, lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
.contentShape(.rect)
// Antippen vergrößert, sofort. Vorher brauchte es zwei Berührungen:
// die erste holte die Bedienelemente hervor, die zweite sollte
// öffnen — traf dabei aber den Abspielknopf, der genau in der Mitte
// lag. Vergrößern war damit überhaupt nicht möglich.
.onTapGesture { playback.expand() }
.accessibilityElement(children: .contain)
.accessibilityLabel(Text(model.title))
}
/// Die beiden Knöpfe in den oberen Ecken.
///
/// Dauerhaft sichtbar statt auf Antippen hin. Die Fläche in der Mitte
/// gehört dem Vergrößern — sie ist das, was man an einer solchen Kachel
/// am häufigsten tut, und sie darf nicht mit einem Knopf belegt sein.
/// Unten bleibt frei für den Fortschrittsbalken.
private var corners: some View {
VStack {
HStack {
tileButton(
model.isPlaying ? "pause.fill" : "play.fill",
label: model.isPlaying ? "player.pause" : "player.play"
) {
model.togglePlayPause()
}
Spacer()
tileButton("xmark", label: "miniPlayer.close") {
playback.close()
}
}
Spacer()
HStack {
Spacer()
// Ein eigener Knopf, obwohl auch das Antippen der Fläche
// vergrößert. Beim Ziehen und Aufziehen liegen auf derselben
// Fläche schon zwei Gesten, und was daneben geht, sieht aus
// wie „reagiert nicht". Ein Knopf geht nicht daneben.
tileButton(
"arrow.up.left.and.arrow.down.right",
label: "miniPlayer.expand"
) {
playback.expand()
}
}
}
.padding(HaloSpacing.tight)
// Über dem Fortschrittsbalken, nicht darauf.
.padding(.bottom, HaloSpacing.tight)
}
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
///
/// Die dunkle Scheibe darunter ist nicht Zierde: ein weißes Symbol auf
/// einer hellen Szene verschwindet, und die Kachel zeigt Filmbilder, deren
/// Helligkeit sich jede Sekunde ändert.
private func tileButton(
_ symbol: String,
label: LocalizedStringKey,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.frame(width: 26, height: 26)
.background(.black.opacity(0.55), in: .circle)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(label))
}
/// Größer und kleiner durch Zusammen- und Auseinanderziehen.
///
/// Kein Griff mehr: der saß zwangsläufig irgendwo auf dem Bild, war klein
/// und stritt sich mit dem Verschieben um dieselbe Geste.
private func zoomGesture(in bounds: CGSize) -> some Gesture {
MagnifyGesture()
.onChanged { value in
zoom = value.magnification
}
.onEnded { _ in
// Die **begrenzte** Breite merken, nicht die gezogene: eine
// einmal zu weit aufgezogene Kachel bliebe sonst für immer zu
// groß, auch auf einem kleineren Bildschirm.
storedWidth = Double(lastWidth)
zoom = 1
}
}
private var progress: Double? {
guard let duration = model.duration, duration.seconds > 0 else { return nil }
return model.currentTime.seconds / duration.seconds
}
private func dragGesture(
in placement: MiniPlayerPlacement,
from resting: CGPoint
) -> some Gesture {
DragGesture()
.onChanged { value in
drag = value.translation
}
.onEnded { value in
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
// dem Finger: sonst springt eine Kachel, die man am Rand
// angefasst hat, auf die falsche Seite.
let center = placement.clamped(center: CGPoint(
x: resting.x + value.predictedEndTranslation.width,
y: resting.y + value.predictedEndTranslation.height
))
// Seitlich einrasten, in der Höhe **nicht** — dort bleibt sie,
// wohin man sie gezogen hat.
storedSide = placement.nearestSide(toCenter: center).rawValue
storedFraction = placement.verticalFraction(forCenter: center)
drag = .zero
}
}
}
extension View {
/// Legt die schwebende Kachel über diesen Bildschirm.
///
/// Bewusst hier und nicht ganz oben in der Wurzel: auf dem iPhone soll die
/// Seitenleiste die Kachel verdecken, wenn sie aufgeht, und nicht
/// umgekehrt. Deshalb liegt sie **innerhalb** des Inhalts.
func miniPlayer(_ model: PlayerModel?, isVisible: Bool) -> some View {
overlay {
if let model, isVisible {
MiniPlayer(model: model)
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
import SwiftUI
import HaloDesign
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
///
/// **Warum nicht `Slider`.** Ein `Slider` hat genau eine Spur, und die gehört
/// dem Fortschritt. Was bereits geladen ist, lässt sich dort nicht
/// unterbringen — und das ist die Auskunft, um die es hier geht: wie weit
/// kann ich springen, ohne zu warten. Dahinter etwas zu zeichnen und auf
/// gleiche Ausrichtung zu hoffen ginge so lange gut, bis Apple die
/// Innenabstände ändert.
///
/// Drei Schichten übereinander, alle aus derselben Rechnung:
///
/// Grund ── die ganze Länge, gedämpft
/// Geladen ── bis wohin Daten da sind, heller
/// Gespielt ── bis zur Abspielstelle, in der Akzentfarbe
///
/// **Der Griff ist grösser als der Strich.** Vier Punkte hoch trifft niemand
/// mit dem Finger; der unsichtbare Bereich darüber ist deshalb so hoch wie
/// eine Zeile.
struct PlaybackScrubber: View {
/// Wo abgespielt wird.
let position: Duration
/// Wie lang das Ganze ist.
let duration: Duration
/// Bis wohin geladen ist — `nil`, wenn es niemand weiss.
let buffered: Duration?
/// Ob gerade gezogen wird; dann bleibt der Griff gross.
@Binding var scrubbing: Double?
/// Beim Loslassen, mit dem Ziel.
let onCommit: (Duration) -> Void
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
let onActivity: () -> Void
@Environment(\.haloAccent) private var accent
private static let trackHeight: CGFloat = 5
private static let thumbSize: CGFloat = 13
/// So hoch, dass ein Finger ihn trifft.
private static let hitHeight: CGFloat = 34
var body: some View {
GeometryReader { geometry in
let breite = geometry.size.width
let gespielt = anteil(of: angezeigt) * breite
let geladen = buffered.map { anteil(of: $0) * breite } ?? 0
ZStack(alignment: .leading) {
Capsule()
.fill(.white.opacity(0.22))
.frame(height: Self.trackHeight)
// Nur wenn er weiter reicht als das Gespielte. Sonst sähe man
// einen Balken, der hinter der Abspielstelle zurückbleibt —
// und das behauptete etwas Falsches.
if geladen > gespielt {
Capsule()
.fill(.white.opacity(0.45))
.frame(width: geladen, height: Self.trackHeight)
}
Capsule()
.fill(accent)
.frame(width: gespielt, height: Self.trackHeight)
Circle()
.fill(accent)
.frame(width: scrubbing == nil ? Self.thumbSize : Self.thumbSize * 1.5)
.offset(x: gespielt - Self.thumbSize / 2)
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
}
.frame(height: Self.hitHeight)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { wert in
scrubbing = sekunden(at: wert.location.x, width: breite)
onActivity()
}
.onEnded { wert in
let ziel = sekunden(at: wert.location.x, width: breite)
scrubbing = nil
onCommit(.seconds(ziel))
onActivity()
}
)
}
.frame(height: Self.hitHeight)
.accessibilityElement()
.accessibilityLabel(Text("player.position"))
.accessibilityValue(Text(angezeigt.timecode))
.accessibilityAdjustableAction { richtung in
let schritt = Duration.seconds(15)
switch richtung {
case .increment: onCommit(min(angezeigt + schritt, duration))
case .decrement: onCommit(max(.zero, angezeigt - schritt))
@unknown default: break
}
}
}
/// Was der Regler zeigt: beim Ziehen der Finger, sonst die Wiedergabe.
private var angezeigt: Duration {
scrubbing.map(Duration.seconds) ?? position
}
private func anteil(of zeit: Duration) -> CGFloat {
let gesamt = duration.seconds
guard gesamt > 0 else { return 0 }
return min(max(CGFloat(zeit.seconds / gesamt), 0), 1)
}
private func sekunden(at x: CGFloat, width: CGFloat) -> Double {
guard width > 0 else { return 0 }
return min(max(Double(x / width), 0), 1) * duration.seconds
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
import SwiftUI
import HaloDesign
import HaloPlayback
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
///
/// Ein Sheet statt eines Dialogs: die Leiter hat je nach Quelle bis zu
/// dreizehn Stufen, und `confirmationDialog` zeigt nur eine Liste von Knöpfen
/// ohne Ordnung. Hier stehen sie nach Auflösung gruppiert — und ganz oben,
/// was gerade über die Leitung kommt.
struct QualitySheet: View {
let model: PlayerModel
@Environment(\.haloAccent) private var accent
/// **Als Fragezeichen**, nicht als Behauptung.
///
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
/// an, wenn der Gegenstand nicht im Baum steht — und ein Sheet hängt an
/// einem eigenen Baum. Genau daran ist der Player eingefroren, und zu
/// sehen war nichts davon. Der Player reicht die Einstellungen jetzt
/// ausdrücklich herein; das Fragezeichen sorgt dafür, dass ein Vergessen
/// künftig nur eine fehlende Zeile kostet statt der ganzen App.
@Environment(PlaybackSettings.self) private var settings: PlaybackSettings?
@Environment(\.dismiss) private var dismiss
@State private var showsEveryRung = false
private var everyday: [PlayerQuality] {
PlayerQuality.everyday(forSourceHeight: model.sourceHeight)
}
private var options: [PlayerQuality] {
PlayerQuality.available(forSourceHeight: model.sourceHeight)
}
var body: some View {
NavigationStack {
List {
current
Section {
ForEach(everyday) { option in
row(for: option)
}
} footer: {
Text("quality.audioOnly.hint")
}
// Aufgeklappt nur, wer es wirklich will. Für den Alltag
// reicht „eine Stufe kleiner"; die volle Leiter braucht, wer
// eine bestimmte Bitrate erzwingen muss.
Section {
DisclosureGroup(isExpanded: $showsEveryRung) {
ForEach(groupedHeights, id: \.self) { height in
ForEach(rungs(height: height), id: \.self) { rung in
row(for: .limited(rung), detail: "\(height)p")
}
}
} label: {
Text("quality.everyRung")
}
}
}
.navigationTitle(Text("player.quality"))
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.done") { dismiss() }
}
}
}
.haloResizableSheet()
}
/// Was gerade läuft — gemessen, nicht gewünscht.
@ViewBuilder
private var current: some View {
if settings?.showsBitrate == true, let statistics = model.statistics {
Section {
if let observed = statistics.observedBitrate {
LabeledContent("quality.current.bitrate", value: bitrateText(observed))
}
if let resolution = statistics.resolution {
LabeledContent("quality.current.resolution", value: resolution)
}
if let dropped = statistics.droppedFrames, dropped > 0 {
LabeledContent("quality.current.dropped", value: "\(dropped)")
}
// **Ob der eigene Vorrat läuft, und wie weit.** Von aussen ist
// das nicht zu sehen: läuft er nicht, spielt es trotzdem, nur
// ohne Vorsprung — und genau so sieht auch ein Vorrat aus, der
// läuft und nichts bekommt.
if let vorrat = model.prefetchStatus {
LabeledContent("quality.current.prefetch", value: vorrat)
}
// **Die Zahl, die alles entscheidet.** Eine Playlist mit
// einer Stufe hat nichts zum Umschalten — dann greift jedes
// Nachregeln ins Leere, ganz gleich was bestellt wurde.
if let variants = statistics.variantCount {
LabeledContent("quality.current.variants", value: "\(variants)")
}
// Der Puffer ist der einzige echte Frühwarner: schrumpft er
// schneller, als die Zeit vergeht, kommt weniger herein als
// hinausgeht.
if let buffer = statistics.bufferedAhead {
LabeledContent("quality.current.buffer", value: secondsText(buffer))
}
if statistics.stallCount > 0 {
LabeledContent(
"quality.current.stalls",
value: String(
localized: "quality.stalls \(statistics.stallCount) \(secondsText(statistics.stalledTime))"
)
)
}
} header: {
Text("quality.current")
} footer: {
// Der Unterschied ist keine Haarspalterei: die Obergrenze sagt,
// was erlaubt ist, die gemessene Rate, was ankommt. Ruckelt es
// trotz hoher Stufe, steht die Antwort hier.
//
// Und wenn es nur eine Stufe gibt, gehört auch das gesagt:
// sonst sucht man den Fehler beim Player, während in
// Wahrheit nie eine Leiter bestellt wurde.
if statistics.variantCount == 1 {
Text("quality.current.noLadder")
} else {
Text("quality.current.hint")
}
}
}
}
private var groupedHeights: [Int] {
var seen = Set<Int>()
return options.compactMap { option -> Int? in
guard case .limited(let rung) = option else { return nil }
return seen.insert(rung.height).inserted ? rung.height : nil
}
}
private func rungs(height: Int) -> [PlayerQuality.Rung] {
options.compactMap { option in
guard case .limited(let rung) = option, rung.height == height else { return nil }
return rung
}
}
private func row(for option: PlayerQuality, detail: String? = nil) -> some View {
Button {
Task { await model.select(quality: option) }
dismiss()
} label: {
HStack {
Text(label(for: option))
.foregroundStyle(Color.haloTextPrimary)
if let detail {
Text(detail)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
Spacer()
if option == model.quality {
Image(systemName: "checkmark").foregroundStyle(accent)
}
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private func label(for option: PlayerQuality) -> String {
switch option {
case .automatic: String(localized: "quality.automatic")
case .original: String(localized: "quality.original")
case .audioOnly: String(localized: "quality.audioOnly")
// In der kurzen Liste zählt die Auflösung, nicht die Zahl dahinter:
// „720p" sagt mehr als „3 Mbps", wenn man nur kleiner will.
case .limited(let rung): "\(rung.height)p · \(bitrateText(rung.bitrate))"
}
}
/// Sekunden mit einer Nachkommastelle — für Puffer und Wartezeit.
private func secondsText(_ duration: Duration) -> String {
let seconds = Double(duration.components.seconds)
+ Double(duration.components.attoseconds) / 1e18
return String(localized: "quality.seconds \(String(format: "%.1f", seconds))")
}
/// Kilobit unterhalb eines Megabits, darüber Megabit mit einer Stelle.
///
/// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps"
/// schlechter als „64 kbps".
private func bitrateText(_ kilobits: Int) -> String {
guard kilobits >= 1_000 else {
return String(localized: "quality.kilobits \(kilobits)")
}
let megabits = Double(kilobits) / 1_000
let rounded = megabits.rounded() == megabits
? String(Int(megabits))
: String(format: "%.1f", megabits)
return String(localized: "quality.megabits \(rounded)")
}
}
+152
View File
@@ -0,0 +1,152 @@
import SwiftUI
import HaloDesign
import HaloPlayback
import MediaKit
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
///
/// Getrennt, weil es zwei verschiedene Fragen sind: die Tonspur wechselt man
/// einmal am Anfang, die Untertitel mitten im Film. Beide in einem Sheet
/// bedeutete jedes Mal Scrollen an der anderen Liste vorbei.
struct TrackPickerSheet: View {
enum Kind: Identifiable {
case audio
case subtitles
var id: Self { self }
var title: LocalizedStringKey {
switch self {
case .audio: "player.audioTracks"
case .subtitles: "player.subtitleTracks"
}
}
}
let model: PlayerModel
let kind: Kind
@Environment(\.dismiss) private var dismiss
@Environment(\.haloAccent) private var accent
var body: some View {
NavigationStack {
List {
switch kind {
case .audio where model.isOffline:
// **Offline zählt, was in der Datei liegt.** Die Liste des
// Servers gibt es hier nicht: `chooseAudioStream` läuft nur
// auf dem Online-Weg. Solange sie trotzdem gefragt wurde,
// blieb die Auswahl für heruntergeladene Titel leer — der
// Knopf war da, das Sheet zeigte nichts.
ForEach(model.audioTracks) { track in
row(title: track.displayName, isSelected: model.selectedAudioTrack == track) {
model.selectAudioTrack(track)
dismiss()
}
}
case .audio:
// **Die Spuren der Datei, nicht die des Players.** Rechnet
// der Server um, kennt der Player genau eine — die, für
// die er sich entschieden hat. Zur Auswahl steht, was in
// der Datei liegt.
ForEach(model.audioStreams) { stream in
row(
title: stream.name,
isSelected: model.selectedAudioStream == stream
) {
Task { await model.selectAudioStream(stream) }
dismiss()
}
}
if let gemerkt = model.rememberedTrackName {
Section {
Button(role: .destructive) {
model.forgetRememberedTrack()
} label: {
Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward")
}
.listRowBackground(Color.haloSurface)
} header: {
Text("player.rememberedTrack")
} footer: {
// Eine automatische Handlung, die man nicht sehen
// kann, ist von einem Fehler nicht zu
// unterscheiden.
Text("player.rememberedTrackExplanation \(gemerkt)")
}
}
case .subtitles where model.isOffline:
row(
title: String(localized: "player.subtitlesOff"),
isSelected: model.selectedSubtitleTrack == nil
) {
model.selectSubtitleTrack(nil)
dismiss()
}
ForEach(model.subtitleTracks) { track in
row(title: track.displayName, isSelected: model.selectedSubtitleTrack == track) {
model.selectSubtitleTrack(track)
dismiss()
}
}
case .subtitles:
// „Aus" gehört in dieselbe Liste wie die Spuren, nicht als
// eigener Schalter daneben: es ist eine Auswahl unter
// mehreren, keine getrennte Eigenschaft.
row(
title: String(localized: "player.subtitlesOff"),
isSelected: model.selectedSubtitleStream == nil
) {
Task { await model.selectSubtitleStream(nil) }
dismiss()
}
// Wie beim Ton die Spuren der Datei: ein umgerechneter
// Stream bringt genau einen mit, und der heisst „CC".
ForEach(model.subtitleStreams) { stream in
row(
title: stream.name,
isSelected: model.selectedSubtitleStream == stream
) {
Task { await model.selectSubtitleStream(stream) }
dismiss()
}
}
}
}
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text(kind.title))
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.haloMediumSheet()
}
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text(title)
.foregroundStyle(Color.haloTextPrimary)
Spacer()
if isSelected {
Image(systemName: "checkmark").foregroundStyle(accent)
}
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.listRowBackground(Color.haloSurface)
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
}
}
+217
View File
@@ -0,0 +1,217 @@
import AVKit
import SwiftUI
import HaloPlayback
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
/// Die Ansichtsklasse der jeweiligen Plattform.
///
/// UIKit und AppKit heißen verschieden und meinen dasselbe. Ein Name dafür
/// spart in dieser Datei ein Dutzend `#if`.
#if canImport(UIKit)
typealias PlatformView = UIView
#else
typealias PlatformView = NSView
#endif
/// Die Bildfläche der laufenden Engine.
struct VideoSurface: View {
let engine: (any VideoPlayerEngine)?
/// Die Bildebene des Systemplayers, gehalten vom Modell.
///
/// **Nicht** hier gebaut. Eine `AVPlayerLayer`, die in `makeUIView`
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
let surface: PlayerLayerView
/// Ob **diese** Ansicht die Bildfläche gerade haben soll.
///
/// **Der Kern des Schwarzbilds.** Kachel und Vollbild bestehen beim
/// Wechsel kurz gleichzeitig, und beide holen sich die Fläche in ihre
/// Hülle. Eine Ansicht hat aber nur einen Vorgesetzten — wer zuletzt
/// aktualisiert, gewinnt, und das war nicht verlässlich die richtige.
/// Blieb die Fläche bei der Kachel, die gleich darauf verschwand, lief
/// im Vollbild der Ton weiter und das Bild blieb schwarz.
///
/// Die Hülle allein genügte dagegen nicht: sie verhindert, dass das
/// Abräumen der einen der anderen etwas wegnimmt, entscheidet aber
/// nicht, wer sie bekommen **soll**. Das steht hier.
var isActive = true
/// Wie viel unten frei bleibt, damit Untertitel nicht abgeschnitten werden.
///
/// Hier steht nur die Zahl; wann sie ungleich null ist, entscheidet
/// `PlayerView.surface(for:)`.
var bottomInset: CGFloat = 0
var body: some View {
if engine is AVPlayerEngine {
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
} else {
Color.black
}
}
}
/// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
///
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet,
/// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das
/// UIKit.
#if canImport(UIKit)
final class PlayerLayerView: UIView {
override static var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
/// Die Fläche, auf der wirklich Bild steht — ohne die schwarzen Ränder.
///
/// Vom Player ausgerechnet, aus dem, was gerade ankommt. Vor dem ersten
/// Bild ist sie leer; wer sie benutzt, muss das prüfen.
var videoRect: CGRect { playerLayer.videoRect }
}
#else
/// Auf dem Mac gibt es kein `layerClass`: eine `NSView` bekommt ihre Ebene
/// zugewiesen, statt ihre Art vorzugeben.
final class PlayerLayerView: NSView {
let playerLayer = AVPlayerLayer()
/// Siehe die UIKit-Fassung oben.
var videoRect: CGRect { playerLayer.videoRect }
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layer = playerLayer
// Ohne das bleibt die Ebene beim Vergrößern des Fensters stehen, wo
// sie war, und das Bild sitzt in der Ecke.
layer?.needsDisplayOnBoundsChange = true
autoresizingMask = [.width, .height]
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("nicht aus einem Storyboard") }
}
#endif
/// Hängt eine bestehende `UIView` in den SwiftUI-Baum.
///
/// Umhängen statt neu bauen: dieselbe `UIView` wandert zwischen Kachel und
/// Vollbild hin und her, und die Wiedergabe merkt davon nichts.
///
/// Sie wird dabei **nicht** unmittelbar zurückgegeben, sondern in eine eigene
/// Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es beide
/// Ansichten kurz gleichzeitig gibt — beim Wechsel zwischen Kachel und
/// Vollbild: eine `UIView` hat nur einen Vorgesetzten, und wer sie zuletzt
/// bekommt, gewinnt. Gab SwiftUI sie unmittelbar heraus, nahm das Abräumen der
/// einen Ansicht der anderen die Bildfläche weg, und übrig blieb eine schwarze
/// Kachel — in einer fast schwarzen Oberfläche also scheinbar gar keine.
///
/// Mit eigener Hülle gehört jeder Ansicht ihre eigene, und `updateUIView` holt
/// die Fläche zurück, falls sie zwischenzeitlich woanders eingehängt wurde.
#if canImport(UIKit)
private struct SurfaceContainer: UIViewRepresentable {
let view: PlatformView
let isActive: Bool
let bottomInset: CGFloat
func makeUIView(context: Context) -> SurfaceHostView {
let host = SurfaceHostView()
host.backgroundColor = .black
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
if isActive { host.adopt(view) }
return host
}
func updateUIView(_ host: SurfaceHostView, context: Context) {
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
// Wer nicht dran ist, greift auch nicht zu. Ohne diese Zeile holen
// sich beide Ansichten dieselbe Fläche, und die Reihenfolge
// entscheidet — mal richtig, mal schwarz.
guard isActive, view.superview !== host else { return }
host.adopt(view)
}
}
#else
private struct SurfaceContainer: NSViewRepresentable {
let view: PlatformView
let isActive: Bool
let bottomInset: CGFloat
func makeNSView(context: Context) -> SurfaceHostView {
let host = SurfaceHostView()
host.wantsLayer = true
host.layer?.backgroundColor = NSColor.black.cgColor
host.bottomInset = bottomInset
if isActive { host.adopt(view) }
return host
}
func updateNSView(_ host: SurfaceHostView, context: Context) {
host.bottomInset = bottomInset
guard isActive, view.superview !== host else { return }
host.adopt(view)
}
}
#endif
/// Die Hülle, die der Bildfläche ihren Platz zuweist.
///
/// **Warum nicht mehr `autoresizingMask`.** Bisher bekam die Fläche die
/// Grösse der Hülle und eine Maske, die sie mitwachsen liess. Eine Maske hält
/// Ränder aber **verhältnismässig**: ein Einzug von zwanzig Punkten wüchse
/// beim Drehen mit und wäre im Querformat plötzlich vierzig. Ein fester Rand
/// verlangt echtes Layout — und das sind genau diese vier Zeilen.
#if canImport(UIKit)
final class SurfaceHostView: UIView {
/// Was ringsum frei bleibt. Heute nur unten, siehe `VideoSurface`.
var contentInset: UIEdgeInsets = .zero {
didSet {
guard contentInset != oldValue else { return }
setNeedsLayout()
}
}
/// Hängt die Fläche ein — ohne Maske, die Grösse kommt aus `layoutSubviews`.
func adopt(_ view: UIView) {
view.translatesAutoresizingMaskIntoConstraints = true
view.autoresizingMask = []
addSubview(view)
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
subviews.first?.frame = bounds.inset(by: contentInset)
}
}
#else
final class SurfaceHostView: NSView {
var bottomInset: CGFloat = 0 {
didSet {
guard bottomInset != oldValue else { return }
needsLayout = true
}
}
func adopt(_ view: NSView) {
view.translatesAutoresizingMaskIntoConstraints = true
view.autoresizingMask = []
addSubview(view)
needsLayout = true
}
override func layout() {
super.layout()
// Auf dem Mac liegt der Ursprung unten links: einzurücken heisst
// hier, den Ursprung anzuheben und die Höhe zu kürzen.
var rahmen = bounds
rahmen.origin.y += bottomInset
rahmen.size.height -= bottomInset
subviews.first?.frame = rahmen
}
}
#endif
+92
View File
@@ -0,0 +1,92 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Sucht über alle Mediatheken des Servers.
///
/// **Nur noch Darstellung.** Entprellen, fragen und merken liegen in
/// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server
/// stellt. Siehe dort, warum das kein Beiwerk ist.
struct SearchView: View {
@Environment(HaloSession.self) private var session
@State private var text = ""
@State private var feed = SearchFeed()
var body: some View {
Group {
if text.isEmpty {
recentSearches
} else if feed.hubs.isEmpty && !feed.isSearching {
ContentUnavailableView.search(text: text)
} else {
results
}
}
.background(Color.haloCanvas)
.navigationTitle(Text("search.title"))
.haloInlineTitle()
.searchable(text: $text)
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
// von denen elf verworfen werden.
.task(id: text) { await feed.search(text, from: session.backend) }
// **Hier fehlte er.** Startseite und Mediathek hinterlegen das längst;
// in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus
// dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah
// aus, als wäre nichts passiert.
.environment(\.haloItemChanged, { await feed.search(text, from: session.backend) })
}
private var results: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
ForEach(feed.hubs) { hub in
// **Antippen führt auf die Seite, nicht in den Film.** Die
// Suche benutzt dieselbe Reihe wie die Startseite — und
// damit war sie bisher auch dieselbe Geste. Wer sucht,
// weiß aber noch nicht, was er sehen will; der erste
// Treffer ist eine Vermutung des Servers. Starten geht
// weiter, über langes Drücken.
MediaRow(row: hub, opensDetails: true)
}
}
.padding(.vertical, HaloSpacing.loose)
}
.haloRevealsTopChrome()
}
@ViewBuilder
private var recentSearches: some View {
if feed.recent.isEmpty {
ContentUnavailableView {
Label("search.title", systemImage: "magnifyingglass")
} description: {
Text("search.hint")
}
} else {
List {
Section {
ForEach(feed.recent, id: \.self) { entry in
Button {
text = entry
} label: {
Label(entry, systemImage: "clock.arrow.circlepath")
.foregroundStyle(Color.haloTextSecondary)
}
}
} header: {
Text("search.recent")
} footer: {
Button("search.forgetRecent", role: .destructive) {
feed.forgetRecent()
}
.font(.haloCaption)
}
.listRowBackground(Color.haloSurface)
}
.scrollContentBackground(.hidden)
.haloRevealsTopChrome()
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import SwiftUI
import HaloDesign
/// Die Farbeinstellungen.
///
/// Kuratierte Kacheln plus eine freie Farbe. Die kuratierten sind so gewählt,
/// dass sie auf dem dunklen Grund ohne Nachhilfe lesbar sind; bei einer freien
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
/// stillschweigend zu verändern.
struct AppearanceView: View {
@Environment(HaloTheme.self) private var theme
@Environment(HomeLayoutSettings.self) private var layout
@State private var customHex: String = ""
@State private var showsCustomField = false
private let columns = [GridItem(.adaptive(minimum: 64), spacing: HaloSpacing.regular)]
var body: some View {
Form {
Section {
LazyVGrid(columns: columns, spacing: HaloSpacing.regular) {
ForEach(AccentOption.palette) { option in
swatch(option)
}
}
.padding(.vertical, HaloSpacing.snug)
} header: {
Text("appearance.accent")
} footer: {
Text("appearance.accentExplanation")
}
// Die Kachelgröße steht bei der Darstellung und nicht bei der
// Startseite: sie gilt für Reihen **und** Mediathek, und es geht
// ums Aussehen, nicht um den Aufbau einer bestimmten Seite.
Section {
@Bindable var layout = layout
LabeledContent {
Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0))))
.foregroundStyle(Color.haloTextSecondary)
.monospacedDigit()
} label: {
Text("appearance.cardScale")
}
Slider(
value: $layout.cardScale,
in: HomeLayoutSettings.cardScaleRange,
step: 0.1
)
.accessibilityLabel(Text("appearance.cardScale"))
} footer: {
Text("appearance.cardScaleExplanation")
}
.listRowBackground(Color.haloSurface)
Section {
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
if showsCustomField {
HStack {
TextField("appearance.hexPlaceholder", text: $customHex)
.haloPlainEntry()
.onSubmit(applyCustomColor)
Button("action.apply", action: applyCustomColor)
.disabled(RGB(string: customHex) == nil)
}
if !customHex.isEmpty, RGB(string: customHex) == nil {
Text("appearance.invalidHex")
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
}
}
if theme.accentWasAdjusted {
// Ohne diesen Hinweis wirkt die Korrektur wie ein Fehler:
// die gewählte Farbe sieht auf dem Bildschirm heller aus
// als im Farbwähler, und niemand wüsste warum.
Label {
Text("appearance.adjusted \(theme.accent.hexString)")
.font(.haloCaption)
} icon: {
Image(systemName: "info.circle")
}
.foregroundStyle(Color.haloTextSecondary)
}
}
Section("appearance.preview") {
PreviewRow()
}
}
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text("appearance.title"))
.haloInlineTitle()
.onAppear {
if theme.selection.id == AccentOption.customIdentifier {
customHex = theme.selection.color.hexString
showsCustomField = true
}
}
}
private func swatch(_ option: AccentOption) -> some View {
Button {
theme.select(option)
} label: {
Circle()
.fill(option.color.swiftUIColor)
.frame(height: 44)
.overlay {
if theme.selection == option {
Image(systemName: "checkmark")
.font(.headline)
// Dunkel auf der Farbe, nicht weiß: die Palette
// enthält helle Töne wie Amber, auf denen Weiß
// verschwinden würde.
.foregroundStyle(Color.haloCanvas)
}
}
.overlay {
Circle()
.strokeBorder(
theme.selection == option ? Color.haloTextPrimary : .clear,
lineWidth: 2
)
.padding(-4)
}
}
.buttonStyle(.plain)
.accessibilityLabel(Text(option.name))
.accessibilityAddTraits(theme.selection == option ? [.isButton, .isSelected] : .isButton)
}
private func applyCustomColor() {
guard let color = RGB(string: customHex) else { return }
theme.select(.custom(color))
}
}
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
private struct PreviewRow: View {
@Environment(\.haloAccent) private var accent
var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
Label("hero.play", systemImage: "play.fill")
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
.background(accent, in: .rect(cornerRadius: HaloRadius.control))
.foregroundStyle(Color.haloCanvas)
Label("sidebar.downloads", systemImage: "arrow.down.circle")
.font(.haloCardTitle)
.foregroundStyle(accent)
}
WatchProgressBar(fraction: 0.45)
}
.padding(.vertical, HaloSpacing.snug)
.listRowBackground(Color.haloSurface)
}
}
@@ -0,0 +1,88 @@
import SwiftUI
import HaloDesign
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
///
/// **Warum ein gemeinsamer Typ.** Die Frage steht einmal auf der Serienseite
/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe:
/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei
/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer
/// bekäme eine Sprache dazu, der andere nicht.
///
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
/// kann. Ein Menü sieht überall gleich aus.
struct AudioLanguagePicker: View {
/// Für welche Ebene gewählt wird.
enum Scope {
case series(String)
case library(String)
}
let scope: Scope
let title: LocalizedStringKey
@Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent
var body: some View {
HStack(spacing: HaloSpacing.regular) {
Text(title)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
Spacer(minLength: HaloSpacing.regular)
Menu {
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
// sollen nichts Eigenes sagen, sondern die Frage nach oben
// durchreichen.
Button {
set(nil)
} label: {
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
}
Divider()
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
Button {
set(code)
} label: {
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
}
}
} label: {
HStack(spacing: HaloSpacing.tight) {
Text(currentLabel)
.font(.haloBody)
Image(systemName: "chevron.up.chevron.down")
.font(.haloCaption)
}
.foregroundStyle(accent)
}
}
}
private var current: String? {
switch scope {
case .series(let id): languages.audioLanguage(forSeries: id)
case .library(let id): languages.audioLanguage(forLibrary: id)
}
}
private var currentLabel: String {
current.map(name(of:)) ?? String(localized: "languages.inherit")
}
private func set(_ code: String?) {
switch scope {
case .series(let id): languages.setAudioLanguage(code, forSeries: id)
case .library(let id): languages.setAudioLanguage(code, forLibrary: id)
}
}
private func name(of code: String) -> String {
Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased()
}
}
+104
View File
@@ -0,0 +1,104 @@
import SwiftUI
import HaloDesign
/// Was beim letzten Abspielversuch passiert ist.
///
/// Gedacht als Antwort auf „es läuft nicht": hier steht, welche Engine gewählt
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
struct DiagnosticsView: View {
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(PlaybackDiagnostics.self) private var diagnostics
var body: some View {
Form {
Section("diagnostics.connection") {
if session.backend != nil {
LabeledContent("settings.serverName", value: session.serverName)
if let address = session.serverAddress {
LabeledContent("settings.address", value: address)
.font(.haloCaption)
}
if let beschreibung = session.connectionDescription {
LabeledContent("settings.connection", value: beschreibung)
.font(.haloCaption)
}
} else {
Text("diagnostics.noServer").foregroundStyle(Color.haloTextMuted)
}
}
if let attempt = diagnostics.lastAttempt {
Section("diagnostics.lastAttempt") {
LabeledContent("diagnostics.title.label", value: attempt.title)
LabeledContent("diagnostics.method", value: attempt.method)
LabeledContent("diagnostics.connectionUsed", value: attempt.connection)
VStack(alignment: .leading, spacing: 2) {
Text("diagnostics.source")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Text(attempt.sourceDescription)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(Color.haloTextSecondary)
}
}
Section("diagnostics.steps") {
ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in
Text(step)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.haloTextSecondary)
}
}
Section {
Button {
HaloClipboard.copy(report(for: attempt))
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
}
} footer: {
Text("diagnostics.copyHint")
}
} else {
Section("diagnostics.lastAttempt") {
Text("diagnostics.nothingYet").foregroundStyle(Color.haloTextMuted)
}
}
Section("settings.app") {
LabeledContent("settings.version", value: Branding.version)
LabeledContent("settings.build", value: Branding.buildStamp)
}
}
// Ausdrücklich, nicht nur geerbt.
//
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent)
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text("diagnostics.title"))
.haloInlineTitle()
}
/// Der Bericht als Text zum Weitergeben.
///
/// Enthält bewusst keinen Token: die Quelladresse ist schon ohne
/// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches.
private func report(for attempt: PlaybackDiagnostics.Attempt) -> String {
"""
Halo \(Branding.version) · Build \(Branding.buildStamp)
Verbindung: \(attempt.connection)
Titel: \(attempt.title)
Weg: \(attempt.method)
Quelle: \(attempt.sourceDescription)
\(attempt.steps.joined(separator: "\n"))
"""
}
}
@@ -0,0 +1,103 @@
import SwiftUI
import HaloDesign
import HaloDownloads
import MediaKit
/// Was beim Herunterladen gilt.
///
/// Die Löschfrist steht bewusst mit ausgeschriebenen Fristen da statt mit
/// einem Schieberegler: „Nach 7 Tagen" ist eine Entscheidung, die man bewusst
/// trifft — beim Löschen von Filmen soll niemand versehentlich auf „sofort"
/// rutschen.
struct DownloadSettingsView: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.haloAccent) private var accent
var body: some View {
@Bindable var downloads = downloads
Form {
SettingsHeader(
symbol: "arrow.down.circle",
colour: SettingsPalette.downloads,
title: "sidebar.downloads",
explanation: "settings.downloadsHeaderExplanation"
)
Section {
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
Picker("settings.storageLimit", selection: storageLimitBinding) {
Text("settings.storageUnlimited").tag(Int64(0))
ForEach([Int64(5), 10, 20, 30, 50, 100], id: \.self) { gigabytes in
Text("\(gigabytes) GB").tag(gigabytes * 1_000_000_000)
}
}
} footer: {
Text("settings.downloadsExplanation")
}
Section {
Stepper(value: $downloads.maximumConcurrentDownloads, in: 1...5) {
LabeledContent(
"settings.concurrent",
value: downloads.maximumConcurrentDownloads.formatted()
)
}
} header: {
Text("settings.concurrentHeader")
} footer: {
// Zwei statt aller auf einmal: eine ganze Staffel parallel zu
// laden teilt die Bandbreite so auf, dass keine Folge fertig
// wird — und unterwegs ist eine fertige Folge mehr wert als
// zehn halbe.
Text("settings.concurrentExplanation")
}
Section {
Picker("settings.deleteWatched", selection: deleteAfterBinding) {
Text("settings.deleteNever").tag(Double(-1))
Text("settings.deleteImmediately").tag(Double(0))
Text("settings.deleteAfterDay").tag(Duration.afterOneDay.seconds)
Text("settings.deleteAfterWeek").tag(Duration.afterOneWeek.seconds)
}
Picker("settings.keepUnwatched", selection: keepUnwatchedBinding) {
Text("settings.keepOff").tag(0)
ForEach([1, 2, 3, 5], id: \.self) { count in
Text("settings.keepCount \(count)").tag(count)
}
}
} header: {
Text("settings.episodes")
}
}
.tint(accent)
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text("sidebar.downloads"))
.haloInlineTitle()
}
private var storageLimitBinding: Binding<Int64> {
Binding(
get: { downloads.storageLimitBytes ?? 0 },
set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil }
)
}
private var deleteAfterBinding: Binding<Double> {
Binding(
get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 },
set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil }
)
}
private var keepUnwatchedBinding: Binding<Int> {
Binding(
get: { downloads.rules.keepUnwatchedEpisodes ?? 0 },
set: { downloads.rules.keepUnwatchedEpisodes = $0 > 0 ? $0 : nil }
)
}
}
+237
View File
@@ -0,0 +1,237 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Reihen der Startseite ein- und ausblenden und umsortieren.
struct HomeLayoutView: View {
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(HomeLayoutSettings.self) private var layout
@State private var rows: [HomeRow] = []
/// Welche Mediatheken überhaupt eine Reihe auf der Startseite stellen.
///
/// **Gemessen, nicht angenommen.** Nur Reihen, die der Server einer
/// Mediathek zuordnet, kann der Aufmacher auseinanderhalten — bei
/// Jellyfin sind das die „Neu in …"-Reihen. Gibt es für eine angehakte
/// Mediathek keine, greift die Auswahl ins Leere, und das sieht aus wie
/// ein kaputter Schalter.
@State private var libraryBackedRows: Set<String> = []
@State private var isLoading = true
var body: some View {
list
.background(Color.haloCanvas)
.navigationTitle(Text("homeLayout.title"))
.haloInlineTitle()
.task { await load() }
}
private var list: some View {
List {
@Bindable var layout = layout
Section {
Picker("home.heroSource", selection: $layout.heroSource) {
ForEach(HomeLayoutSettings.HeroSource.allCases) { source in
Text(source.label).tag(source)
}
}
Picker("home.heroCount", selection: $layout.heroCount) {
ForEach(HomeLayoutSettings.heroCountChoices, id: \.self) { count in
Text(count.formatted()).tag(count)
}
}
// Ein Regler und keine Auswahl aus festen Stufen: es gibt hier
// keinen richtigen Wert, nur den, bei dem die erste Reihe
// gerade so weit hereinlugt, wie man es mag.
LabeledContent {
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
.foregroundStyle(Color.haloTextSecondary)
.monospacedDigit()
} label: {
Text("home.heroHeight")
}
Slider(
value: $layout.heroHeight,
in: HomeLayoutSettings.heroHeightRange,
step: 0.05
)
.accessibilityLabel(Text("home.heroHeight"))
} header: {
Text("home.hero")
} footer: {
Text("home.heroExplanation")
}
.listRowBackground(Color.haloSurface)
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
// den man versehentlich trifft. Hier sind Haken: keiner gesetzt
// ist der Normalfall und bedeutet, dass alles infrage kommt.
if !session.libraries.isEmpty {
Section {
ForEach(session.libraries) { library in
Button {
toggleHeroLibrary(library.id)
} label: {
LabeledContent {
if layout.heroLibraries.contains(library.id) {
Image(systemName: "checkmark")
.font(.body.weight(.semibold))
.foregroundStyle(accent)
}
} label: {
Text(library.title)
.foregroundStyle(Color.haloTextPrimary)
}
// Damit der ganze Streifen trifft und nicht nur
// die Schrift — eine Zeile, bei der nur ein Wort
// reagiert, liest sich als kaputt.
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityAddTraits(
layout.heroLibraries.contains(library.id) ? .isSelected : []
)
}
} header: {
Text("home.heroLibraries")
} footer: {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
Text("home.heroLibrariesExplanation")
// Lieber eine Erklärung als ein Aufmacher, der
// heimlich etwas anderes zeigt: trifft die Auswahl
// keine einzige Reihe, gilt sie als wirkungslos — und
// dann muss dastehen, warum.
if greiftInsLeere {
Text("home.heroLibrariesNoMatch")
.foregroundStyle(Color.haloDestructive)
}
}
}
.listRowBackground(Color.haloSurface)
}
Section {
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
}
.listRowBackground(Color.haloSurface)
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.listRowBackground(Color.clear)
} else if rows.isEmpty {
Text("homeLayout.empty")
.foregroundStyle(Color.haloTextSecondary)
.listRowBackground(Color.haloSurface)
}
Section {
ForEach($rows) { $row in
Toggle(isOn: Binding(
get: { !row.isHidden },
set: {
row.isHidden = !$0
layout.setHidden(!$0, for: row.id)
}
)) {
Text(row.title)
.foregroundStyle(Color.haloTextPrimary)
}
.listRowBackground(Color.haloSurface)
}
.onMove { indices, destination in
rows.move(fromOffsets: indices, toOffset: destination)
layout.setOrder(rows.map(\.id))
}
} header: {
// **Beim Abschnitt, auf den er wirkt.** In der Werkzeugleiste
// stand er über allem — über dem Aufmacher, über den
// Mediatheken — und tat dort nichts. Ein Knopf, der nur für
// einen Teil des Bildschirms gilt, gehört an diesen Teil.
//
// Auf dem Mac gibt es ihn nicht: dort zieht man Zeilen ohne
// vorherigen Moduswechsel.
HStack {
Text("home.rowsHeader")
#if os(iOS)
if !rows.isEmpty {
Spacer()
EditButton()
.font(.haloCaption)
.textCase(nil)
}
#endif
}
} footer: {
Text("homeLayout.explanation")
}
}
// Ausdrücklich, nicht nur geerbt.
//
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent)
.scrollContentBackground(.hidden)
}
/// Ob die Auswahl keine einzige Reihe trifft.
private var greiftInsLeere: Bool {
!layout.heroLibraries.isEmpty
&& !libraryBackedRows.isEmpty
&& layout.heroLibraries.isDisjoint(with: libraryBackedRows)
}
/// Kreuzt eine Mediathek für den Aufmacher an oder ab.
private func toggleHeroLibrary(_ identifier: String) {
if layout.heroLibraries.contains(identifier) {
layout.heroLibraries.remove(identifier)
} else {
layout.heroLibraries.insert(identifier)
}
}
/// Wirft gespeicherte Kennungen weg, die es nicht mehr gibt.
///
/// **Sonst wirken sie unsichtbar weiter.** Nach einem Serverwechsel
/// bleibt in den Voreinstellungen eine Mediathekskennung stehen, die zu
/// nichts mehr passt — sie steht in keiner Zeile, lässt sich also nicht
/// abwählen, und schränkt den Aufmacher trotzdem ein.
///
/// Nur, wenn überhaupt Mediatheken geladen sind: eine leere Liste heisst
/// „noch nicht da", nicht „gibt es nicht".
private func pruneHeroLibraries() {
guard !session.libraries.isEmpty else { return }
let vorhanden = Set(session.libraries.map(\.id))
let bereinigt = layout.heroLibraries.intersection(vorhanden)
guard bereinigt != layout.heroLibraries else { return }
layout.heroLibraries = bereinigt
}
private func load() async {
defer { isLoading = false }
pruneHeroLibraries()
guard let backend = session.backend else { return }
// Die Reihen kommen vom Server — es gibt keine feste Liste, die sich
// vorweg anzeigen ließe. Was hier steht, hängt davon ab, welche
// Mediatheken und Sammlungen angelegt sind.
let hubs = (try? await backend.homeRows()) ?? []
rows = layout.rows(from: hubs)
libraryBackedRows = Set(hubs.compactMap(\.libraryID))
}
}
extension HomeLayoutSettings.HeroSource {
var label: LocalizedStringKey {
switch self {
case .continueFirst: "home.heroSource.continueFirst"
case .latestOnly: "home.heroSource.latestOnly"
}
}
}
+140
View File
@@ -0,0 +1,140 @@
import SwiftUI
import HaloDesign
/// Welche Sprachen der Player von sich aus wählen soll.
///
/// Die Einstellung gab es schon — der Player liest sie bei jeder Wiedergabe
/// aus und wählt die passende Spur. Nur einstellen konnte man sie nirgends:
/// sie stand in den Voreinstellungen und blieb dort für immer leer. Eine
/// Vorliebe, die niemand äußern kann, ist keine.
struct LanguagesView: View {
@Environment(LanguagePreferences.self) private var settings
@Environment(\.haloAccent) private var accent
@State private var isAdding = false
private var codes: [String] { LanguagePreferences.offeredCodes }
var body: some View {
@Bindable var settings = settings
Form {
SettingsHeader(
symbol: "captions.bubble",
colour: SettingsPalette.languages,
title: "languages.title",
explanation: "languages.headerExplanation"
)
Section {
Picker("languages.audio", selection: $settings.audioLanguage) {
Text("languages.serverDefault").tag("")
ForEach(codes, id: \.self) { code in
Text(name(of: code)).tag(code)
}
}
} header: {
Text("languages.audioHeader")
} footer: {
Text("languages.audioExplanation")
}
fallbackSection
Section {
Toggle("languages.avoidAudioDescription", isOn: $settings.avoidsAudioDescription)
} footer: {
Text("languages.avoidAudioDescriptionExplanation")
}
Section {
Picker("languages.subtitles", selection: $settings.subtitleLanguage) {
Text("languages.subtitlesOff").tag("")
ForEach(codes, id: \.self) { code in
Text(name(of: code)).tag(code)
}
}
} header: {
Text("languages.subtitlesHeader")
} footer: {
Text("languages.subtitlesExplanation")
}
}
.tint(accent)
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text("languages.title"))
.haloInlineTitle()
// Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht
// das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form`
// genügt, sobald die Liste bearbeitbar ist.
.environment(\.editMode, .constant(.active))
}
/// Die Rangliste.
///
/// **Was sie ist:** der Boden unter der Wunschsprache. Sie greift nur,
/// wenn ein Titel die gewünschte Sprache gar nicht hat.
///
/// **Was sie nicht ist:** eine zweite Wunschsprache. Steht die
/// Wunschsprache zur Verfügung, wird die Liste nicht einmal angesehen.
@ViewBuilder
private var fallbackSection: some View {
@Bindable var settings = settings
Section {
if settings.fallbacks.isEmpty {
Text("languages.fallbackEmpty")
.foregroundStyle(Color.haloTextSecondary)
} else {
ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in
HStack(spacing: HaloSpacing.regular) {
// Die Nummer, weil die Reihenfolge der ganze Zweck
// ist — eine Liste ohne sichtbaren Rang liest sich
// wie eine Menge.
Text("\(position + 1)")
.font(.haloCaption.monospacedDigit())
.foregroundStyle(Color.haloTextSecondary)
.frame(minWidth: 18, alignment: .trailing)
Text(name(of: code))
.foregroundStyle(Color.haloTextPrimary)
}
}
.onMove { von, nach in
settings.fallbacks.move(fromOffsets: von, toOffset: nach)
}
.onDelete { settings.fallbacks.remove(atOffsets: $0) }
}
Button {
isAdding = true
} label: {
Label("languages.fallbackAdd", systemImage: "plus")
}
.disabled(remaining.isEmpty)
} header: {
Text("languages.fallbackHeader")
} footer: {
Text("languages.fallbackExplanation")
}
.confirmationDialog("languages.fallbackAdd", isPresented: $isAdding, titleVisibility: .visible) {
ForEach(remaining, id: \.self) { code in
Button(name(of: code)) { settings.fallbacks.append(code) }
}
}
}
/// Was noch nicht in der Liste steht.
///
/// Eine Sprache zweimal in einer Rangliste hat keine Bedeutung — und die
/// Liste benutzt den Code als Kennung, was bei Dubletten bräche.
private var remaining: [String] {
codes.filter { !settings.fallbacks.contains($0) }
}
/// Der Name der Sprache in der Sprache des Geräts.
private func name(of code: String) -> String {
Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased()
}
}
@@ -0,0 +1,135 @@
import SwiftUI
import HaloDesign
import HaloPlayback
/// Alles, was die Wiedergabe betrifft.
///
/// Vorher stand das verstreut in der langen Liste der Einstellungen, zwischen
/// Serverangaben und Downloads. Zusammen ergibt es einen Bildschirm, den man
/// einmal durchgeht und dann in Ruhe lässt.
struct PlaybackSettingsView: View {
@Environment(PlaybackSettings.self) private var settings
@Environment(\.haloAccent) private var accent
var body: some View {
@Bindable var settings = settings
Form {
SettingsHeader(
symbol: "play.rectangle",
colour: SettingsPalette.playback,
title: "settings.playback",
explanation: "settings.playbackExplanation"
)
Section {
Picker("settings.qualityWifi", selection: $settings.wifiQuality) {
ForEach(PlaybackSettings.Quality.allCases) { quality in
Text(quality.label).tag(quality)
}
}
Picker("settings.qualityCellular", selection: $settings.cellularQuality) {
ForEach(PlaybackSettings.Quality.allCases) { quality in
Text(quality.label).tag(quality)
}
}
} header: {
Text("settings.quality")
} footer: {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
Text("settings.qualityExplanation")
// **Nur mit Beleg.** Eine Warnung, die immer dasteht,
// liest nach dem zweiten Mal niemand mehr. Sie erscheint,
// wenn „ohne Grenze" eingestellt ist **und** die letzte
// Wiedergabe tatsächlich gehakt hat.
//
// Ohne Grenze kommt die Entscheidung oft bei direkter
// Wiedergabe heraus — und dann gibt es keine Stufe, auf
// die irgendetwas ausweichen könnte. Die Voreinstellung
// bleibt trotzdem, wie sie ist: zuhause am Kabel ist sie
// die richtige, und still umstellen wäre nicht in Ordnung.
if settings.wifiQuality == .original, settings.lastPlaybackStruggled {
Label {
Text("settings.qualityUnlimitedHint")
} icon: {
Image(systemName: "exclamationmark.triangle")
}
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
}
Section {
Toggle("settings.rememberQuality", isOn: $settings.remembersQuality)
Toggle("settings.showBitrate", isOn: $settings.showsBitrate)
} footer: {
Text("settings.rememberQualityExplanation")
}
Section {
Picker("settings.rewind", selection: $settings.rewindSeconds) {
ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in
Text("settings.seconds \(seconds)").tag(seconds)
}
}
Picker("settings.forward", selection: $settings.forwardSeconds) {
ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in
Text("settings.seconds \(seconds)").tag(seconds)
}
}
Picker("settings.defaultRate", selection: $settings.defaultRate) {
ForEach(PlaybackSettings.rateChoices, id: \.self) { rate in
Text(rateLabel(rate)).tag(rate)
}
}
} header: {
Text("settings.controls")
} footer: {
// Zurück und vor getrennt, weil sie verschiedene Zwecke haben:
// zurück, weil man etwas nicht verstanden hat; vor, weil man
// etwas überspringen will.
Text("settings.controlsExplanation")
}
Section {
Toggle("settings.lockLandscape", isOn: $settings.locksLandscape)
} footer: {
Text("settings.lockLandscapeExplanation")
}
Section {
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
} header: {
Text("settings.episodes")
}
}
.tint(accent)
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text("settings.playback"))
.haloInlineTitle()
}
private func rateLabel(_ rate: Double) -> String {
rate == 1
? String(localized: "settings.rateNormal")
: rate.formatted(.number.precision(.fractionLength(0...2))) + "×"
}
}
extension PlaybackSettings.Quality {
var label: LocalizedStringKey {
switch self {
case .original: "quality.original"
case .high: "quality.high"
case .medium: "quality.medium"
case .low: "quality.low"
case .minimal: "quality.minimal"
}
}
}
@@ -0,0 +1,142 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
struct ServerSettingsView: View {
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
var body: some View {
Form {
Section("settings.server") {
LabeledContent("settings.serverName", value: session.serverName)
// Nah oder fern: daran hängt, ob „Auto" gedeckelt anfängt,
// und damit die erste Frage bei jeder Rückmeldung über
// Ruckeln.
if session.backend != nil {
LabeledContent("settings.connection", value: connectionLabel)
}
if let address = session.serverAddress {
LabeledContent("settings.address", value: address)
.font(.haloCaption)
.textSelection(.enabled)
}
if let accountName = session.accountName {
LabeledContent("settings.account", value: accountName)
}
}
Section {
ForEach(session.libraries) { library in
LibraryRow(library: library)
}
} header: {
Text("settings.libraries")
} footer: {
// Der Suchlauf stand einmal im Dreipunktmenü der Mediathek —
// einen Tipp neben „Filter" und „Ansicht wechseln". Er
// beschäftigt den Server minutenlang und gehört nicht in eine
// Reihe mit Dingen, die man im Vorbeigehen antippt.
if session.backend?.capabilities.contains(.libraryScan) == true {
Text("settings.scanExplanation")
}
}
}
.tint(accent)
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text("settings.server"))
.haloInlineTitle()
}
/// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
/// ist das die erste Frage.
private var connectionLabel: String {
session.isLocalConnection
? String(localized: "settings.connectionLocal")
: String(localized: "settings.connectionRemote")
}
}
/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben.
struct LibraryRow: View {
let library: MediaLibrary
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@State private var phase: Phase = .idle
private enum Phase { case idle, running, done }
var body: some View {
LabeledContent {
HStack(spacing: HaloSpacing.regular) {
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
// „Filme" stand sonst als „Filme — Filme" da.
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
Text(library.kind.label)
.foregroundStyle(Color.haloTextSecondary)
}
if session.backend?.capabilities.contains(.libraryScan) == true {
Button {
Task { await scan() }
} label: {
symbol
}
.buttonStyle(.borderless)
.disabled(phase == .running)
.accessibilityLabel(Text("library.scan"))
}
}
} label: {
Text(library.title)
}
}
@ViewBuilder
private var symbol: some View {
switch phase {
case .idle:
Image(systemName: "arrow.triangle.2.circlepath")
case .running:
ProgressView().controlSize(.small)
case .done:
Image(systemName: "checkmark").foregroundStyle(accent)
}
}
/// Stößt einen Suchlauf auf dem Server an.
///
/// Der Server meldet nicht, wann er fertig ist. Deshalb steht hier kein
/// Fortschritt, sondern nur ein Haken dafür, dass der Auftrag angekommen
/// ist — ein Balken, der etwas anderes behauptet, wäre erfunden.
private func scan() async {
guard let backend = session.backend, phase != .running else { return }
phase = .running
try? await backend.refreshLibrary(library)
phase = .done
try? await Task.sleep(for: .seconds(4))
phase = .idle
}
}
extension MediaLibrary.Kind {
/// Wie diese Art von Mediathek heißt.
///
/// Übersetzt statt durchgereicht: die Server liefern „movies" und
/// „tvshows", und das großgeschrieben anzuzeigen war eine englische
/// Beschriftung in einer deutschen Oberfläche.
var label: String {
switch self {
case .movie: String(localized: "library.kind.movie")
case .show: String(localized: "library.kind.show")
case .music: String(localized: "library.kind.music")
case .photo: String(localized: "library.kind.photo")
case .liveTV: String(localized: "library.kind.liveTV")
case .other: String(localized: "library.kind.other")
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import SwiftUI
import HaloDesign
/// Das farbige Symbol vor einer Einstellungszeile.
///
/// Nicht Zierde: in einer Liste aus zehn gleich aussehenden Zeilen findet das
/// Auge nichts wieder. Eine Farbe je Bereich macht „das mit dem blauen Zahnrad"
/// zu einer brauchbaren Beschreibung — und genau so sucht man Einstellungen,
/// die man schon einmal gesehen hat.
///
/// Die Farben sind fest und **nicht** die Akzentfarbe: wenn alles die gleiche
/// Farbe trägt, unterscheidet sie nichts mehr.
struct SettingsIcon: View {
let symbol: String
let colour: Color
var body: some View {
Image(systemName: symbol)
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(colour.gradient, in: .rect(cornerRadius: 7))
}
}
/// Die Kopfkarte eines Einstellungsbildschirms.
///
/// Sagt in einem Satz, worum es hier geht. Das klingt nach Zierde, ist aber
/// der Unterschied zwischen „Wiedergabe" und „hier stelle ich ein, wie weit
/// der Doppeltipp springt" — und erspart das Durchprobieren aller Schalter.
struct SettingsHeader: View {
let symbol: String
let colour: Color
let title: LocalizedStringKey
let explanation: LocalizedStringKey
var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Image(systemName: symbol)
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 52, height: 52)
.background(colour.gradient, in: .rect(cornerRadius: 13))
Text(title)
.font(.haloTitle)
.foregroundStyle(Color.haloTextPrimary)
Text(explanation)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, HaloSpacing.snug)
.listRowBackground(Color.haloSurface)
}
}
/// Die Farben der Bereiche, an einer Stelle.
enum SettingsPalette {
static let server = Color.gray
static let appearance = Color.blue
static let home = Color.green
static let playback = Color.pink
static let languages = Color.teal
static let subtitles = Color.yellow
static let downloads = Color.indigo
static let diagnostics = Color.orange
}
+123
View File
@@ -0,0 +1,123 @@
import SwiftUI
import HaloDesign
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
///
/// Vorher stand alles untereinander auf einem Bildschirm: Serverangaben,
/// Qualität, Wiedergabe, drei Verweise, Downloads, Version, Abmelden. Das
/// war beim Bauen bequem und beim Suchen anstrengend — je mehr Einstellungen
/// dazukamen, desto weiter rutschte alles nach unten.
///
/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört.
struct SettingsView: View {
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@State private var showsSignOutConfirmation = false
var body: some View {
Form {
if session.backend != nil {
Section {
NavigationLink {
ServerSettingsView()
} label: {
LabeledContent {
Text(session.serverName)
.foregroundStyle(Color.haloTextSecondary)
} label: {
row(
"settings.server",
symbol: "externaldrive.connected.to.line.below",
colour: SettingsPalette.server
)
}
}
}
}
Section("settings.section.look") {
link("appearance.title", symbol: "paintpalette", colour: SettingsPalette.appearance) {
AppearanceView()
}
link("homeLayout.title", symbol: "rectangle.stack", colour: SettingsPalette.home) {
HomeLayoutView()
}
}
Section("settings.section.playing") {
link("settings.playback", symbol: "play.rectangle", colour: SettingsPalette.playback) {
PlaybackSettingsView()
}
link("languages.title", symbol: "captions.bubble", colour: SettingsPalette.languages) {
LanguagesView()
}
link("subtitleStyle.title", symbol: "textformat.size", colour: SettingsPalette.subtitles) {
SubtitleAppearanceView()
}
link("sidebar.downloads", symbol: "arrow.down.circle", colour: SettingsPalette.downloads) {
DownloadSettingsView()
}
}
Section {
link("diagnostics.title", symbol: "stethoscope", colour: SettingsPalette.diagnostics) {
DiagnosticsView()
}
}
Section("settings.app") {
LabeledContent("settings.version", value: Branding.version)
LabeledContent("settings.build", value: Branding.buildStamp)
}
Section {
Button("action.signOut", role: .destructive) {
showsSignOutConfirmation = true
}
}
}
// Ausdrücklich, nicht nur geerbt.
//
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent)
.haloForm()
.scrollContentBackground(.hidden)
.haloRevealsTopChrome()
.background(Color.haloCanvas)
.navigationTitle(Text("sidebar.settings"))
.haloInlineTitle()
.confirmationDialog(
Text("settings.signOutConfirm"),
isPresented: $showsSignOutConfirmation,
titleVisibility: .visible
) {
Button("action.signOut", role: .destructive) { session.signOut() }
Button("action.cancel", role: .cancel) {}
} message: {
Text("settings.signOutExplanation")
}
}
private func link<Destination: View>(
_ title: LocalizedStringKey,
symbol: String,
colour: Color,
@ViewBuilder destination: @escaping () -> Destination
) -> some View {
NavigationLink {
destination()
} label: {
row(title, symbol: symbol, colour: colour)
}
}
private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View {
HStack(spacing: HaloSpacing.regular) {
SettingsIcon(symbol: symbol, colour: colour)
Text(title)
}
}
}
@@ -0,0 +1,100 @@
import SwiftUI
import HaloDesign
import HaloPlayback
/// Wie Untertitel aussehen sollen.
struct SubtitleAppearanceView: View {
@Environment(SubtitleAppearance.self) private var appearance
@Environment(\.haloAccent) private var accent
var body: some View {
@Bindable var appearance = appearance
Form {
SettingsHeader(
symbol: "textformat.size",
colour: SettingsPalette.subtitles,
title: "subtitleStyle.title",
explanation: "subtitleStyle.headerExplanation"
)
Section {
preview
} header: {
Text("subtitleStyle.previewHeader")
}
Section {
Picker("subtitleStyle.size", selection: $appearance.size) {
ForEach(SubtitleStyle.Size.allCases) { size in
Text(size.label).tag(size)
}
}
} footer: {
Text("subtitleStyle.sizeExplanation")
}
Section {
Picker("subtitleStyle.palette", selection: $appearance.palette) {
ForEach(SubtitleStyle.Palette.allCases) { palette in
Text(palette.label).tag(palette)
}
}
} header: {
Text("subtitleStyle.paletteHeader")
} footer: {
// Die Grenze gehört hierher, nicht nur in einen Kommentar:
// bei eingebrannten Untertiteln tut jede dieser Einstellungen
// sichtbar nichts, und ohne Erklärung sieht das nach einem
// Fehler aus.
Text("subtitleStyle.paletteExplanation")
}
}
.tint(accent)
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text("subtitleStyle.title"))
.haloInlineTitle()
}
/// Ein Beispielsatz über grauer Fläche.
///
/// **Eine Nachbildung, nicht AVFoundations Renderer.** Sie zeigt die
/// Wirkung der Einstellung, nicht das Bild auf den Punkt genau — wer
/// später zwei Pixel „repariert", repariert die falsche Sache.
private var preview: some View {
ZStack {
// Mittelgrau: auf Weiss wäre weisse Schrift unsichtbar, auf
// Schwarz schwarze. Ein Grauton zeigt beide ehrlich.
LinearGradient(
colors: [Color(white: 0.35), Color(white: 0.18)],
startPoint: .top,
endPoint: .bottom
)
Text("subtitleStyle.previewText")
.font(.system(size: 15 * CGFloat(appearance.size.rawValue) / 100, weight: .medium))
.foregroundStyle(appearance.style.foreground.color)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(appearance.style.background.color)
.shadow(
color: appearance.style.wantsEdgeShadow ? .black.opacity(0.9) : .clear,
radius: 2,
y: 1
)
.padding(HaloSpacing.regular)
}
.frame(height: 96)
.clipShape(.rect(cornerRadius: HaloRadius.card))
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
}
extension SubtitleStyle.RGBA {
var color: Color {
Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha)
}
}
@@ -0,0 +1,40 @@
import SwiftUI
import HaloDesign
/// Während ein Weg zum Server gesucht wird.
struct ConnectingView: View {
let serverName: String
var body: some View {
VStack(spacing: HaloSpacing.loose) {
ProgressView()
Text(serverName.isEmpty
? String(localized: "connecting.searching")
: String(localized: "connecting.to \(serverName)"))
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.haloCanvas)
}
}
/// Wenn etwas schiefgegangen ist.
struct ConnectionFailureView: View {
let message: String
let onRetry: () -> Void
let onSignOut: () -> Void
var body: some View {
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry", action: onRetry).buttonStyle(.borderedProminent)
Button("action.signOut", action: onSignOut)
.foregroundStyle(Color.haloTextSecondary)
}
.background(Color.haloCanvas)
}
}
+279
View File
@@ -0,0 +1,279 @@
import SwiftUI
import HaloDesign
import JellyfinKit
/// Die Anmeldung an einem Jellyfin-Server.
///
/// Drei Bildschirme in einem, weil es drei Fragen sind und nicht mehr: wo
/// steht der Server, wie heißt du, und — wenn der Server es kann — bestätige
/// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort
/// durch diese App läuft.
struct JellyfinSignInView: View {
@Environment(HaloSession.self) private var session
@Environment(\.dismiss) private var dismiss
@Environment(\.haloAccent) private var accent
@State private var model: JellyfinSignInModel?
@FocusState private var focusedField: Field?
private enum Field { case address, userName, password }
var body: some View {
NavigationStack {
ScrollView {
if let model {
content(for: model)
.padding(.horizontal, HaloSpacing.section)
.padding(.vertical, HaloSpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity)
}
}
.scrollDismissesKeyboard(.interactively)
.background(Color.haloCanvas)
.navigationTitle(Text("signIn.jellyfin.title"))
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") {
model?.cancel()
dismiss()
}
}
}
}
.tint(accent)
.task {
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
guard model == nil else { return }
let fresh = JellyfinSignInModel(identity: session.jellyfinDeviceIdentity)
fresh.onAuthenticated = { client, account, serverName in
dismiss()
Task { await session.adoptJellyfin(client: client, account: account, serverName: serverName) }
}
model = fresh
}
.onDisappear {
// Wird das Sheet weggewischt, läuft sonst die Abfrageschleife
// noch minutenlang weiter — unsichtbar und für nichts.
model?.cancel()
}
}
@ViewBuilder
private func content(for model: JellyfinSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: HaloSpacing.section) {
switch model.step {
case .address:
addressStep(model: model)
case .checking:
waiting(text: "signIn.jellyfin.searching")
case .quickConnect(let code):
quickConnectStep(model: model, code: code)
case .credentials:
credentialsStep(model: model)
case .signingIn:
waiting(text: "signIn.jellyfin.signingIn")
}
if let message = model.errorMessage {
Label(message, systemImage: "exclamationmark.triangle")
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
}
}
}
// MARK: - Adresse
@ViewBuilder
private func addressStep(model: JellyfinSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text("signIn.jellyfin.addressPrompt")
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
.textFieldStyle(.plain)
.textContentType(.URL)
.haloPlainEntry(isAddress: true)
.submitLabel(.go)
.focused($focusedField, equals: .address)
.onSubmit { Task { await model.findServer() } }
// Hier statt beim Öffnen des Sheets: so bekommt das Feld den
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
.task { focusedField = .address }
.padding(HaloSpacing.regular)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
.foregroundStyle(Color.haloTextPrimary)
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
// niemandem etwas, „192.168.1.50" jedem.
Text("signIn.jellyfin.addressHint")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Button {
focusedField = nil
Task { await model.findServer() }
} label: {
Text("signIn.jellyfin.connect")
.font(.haloRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.address.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
// MARK: - Code
@ViewBuilder
private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
foundServerHeader(model: model)
Text("signIn.jellyfin.quickConnectHint")
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
Text(code)
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
.tracking(8)
.foregroundStyle(Color.haloTextPrimary)
.textSelection(.enabled)
.padding(.vertical, HaloSpacing.regular)
.frame(maxWidth: .infinity)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
HStack(spacing: HaloSpacing.snug) {
ProgressView().controlSize(.small)
Text("signIn.jellyfin.waitingForApproval")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
Text("signIn.jellyfin.privacy")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Divider().overlay(Color.haloSeparator)
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
.font(.haloCaption)
Button("signIn.jellyfin.otherAddress") { model.startOver() }
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
// MARK: - Benutzername und Passwort
@ViewBuilder
private func credentialsStep(model: JellyfinSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
foundServerHeader(model: model)
TextField("signIn.jellyfin.userName", text: $model.userName)
.textContentType(.username)
.haloPlainEntry()
.submitLabel(.next)
.focused($focusedField, equals: .userName)
.onSubmit { focusedField = .password }
// **Tabulator weiterreichen, von Hand.**
//
// Auf dem Mac ist „Name, Tab, Passwort, Eingabe" der Weg, den
// jeder nimmt, der einen Passwortspeicher benutzt. SwiftUI
// schiebt den Fokus hier aber nicht selbst weiter: die Taste
// kommt im Feld an und bleibt dort liegen.
.onKeyPress(.tab) {
focusedField = .password
return .handled
}
.padding(HaloSpacing.regular)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
SecureField("signIn.jellyfin.password", text: $model.password)
.textContentType(.password)
.submitLabel(.go)
.focused($focusedField, equals: .password)
.onSubmit { Task { await model.signInWithPassword() } }
// Und zurück: sonst wäre der Weg eine Einbahnstraße, und ein
// Vertipper im Namen hieße, zur Maus greifen.
.onKeyPress(.tab) {
focusedField = .userName
return .handled
}
.padding(HaloSpacing.regular)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
Button {
focusedField = nil
Task { await model.signInWithPassword() }
} label: {
Text("signIn.jellyfin.submit")
.font(.haloRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.userName.isEmpty)
// Das Passwort wird für die eine Anfrage gehalten und danach
// verworfen. Gespeichert wird nur der Token, und der im
// Schlüsselbund.
Text("signIn.jellyfin.passwordNote")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Divider().overlay(Color.haloSeparator)
if model.offersQuickConnect {
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
.font(.haloCaption)
}
Button("signIn.jellyfin.otherAddress") { model.startOver() }
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
.foregroundStyle(Color.haloTextPrimary)
}
// MARK: - Bausteine
@ViewBuilder
private func foundServerHeader(model: JellyfinSignInModel) -> some View {
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
Text("signIn.jellyfin.foundServer")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.textCase(.uppercase)
Text(model.serverName ?? model.address)
.font(.haloTitle)
.foregroundStyle(Color.haloTextPrimary)
}
}
@ViewBuilder
private func waiting(text: LocalizedStringKey) -> some View {
HStack(spacing: HaloSpacing.snug) {
ProgressView().controlSize(.small)
Text(text)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, HaloSpacing.section)
}
}
+67
View File
@@ -0,0 +1,67 @@
import SwiftUI
import HaloDesign
/// Der Anmeldebildschirm.
///
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
///
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
struct SignInView: View {
@Environment(\.haloAccent) private var accent
@State private var showsJellyfinSignIn = false
var body: some View {
VStack(spacing: HaloSpacing.section) {
Spacer()
VStack(spacing: HaloSpacing.regular) {
Image(systemName: "play.rectangle.on.rectangle")
.font(.system(size: 52))
.foregroundStyle(accent)
Text(Branding.appName)
.font(.haloHero)
.foregroundStyle(Color.haloTextPrimary)
Text("signIn.tagline")
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
.multilineTextAlignment(.center)
}
Spacer()
VStack(spacing: HaloSpacing.regular) {
Button {
showsJellyfinSignIn = true
} label: {
Text("signIn.withJellyfin")
.font(.haloRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
Text("signIn.privacy")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.multilineTextAlignment(.center)
}
Spacer().frame(height: HaloSpacing.section)
}
.padding(.horizontal, HaloSpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.haloCanvas)
.sheet(isPresented: $showsJellyfinSignIn) {
JellyfinSignInView()
}
}
}