Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1 @@
|
||||
<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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "—"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: " · ")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user