Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -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 „Flaro" zu, sondern immer hierauf. Zu ändern sind dann genau
|
||||
/// zwei Dinge: diese Datei und `project.yml` (Produktname, Bundle-Kennung,
|
||||
/// Anzeigename).
|
||||
///
|
||||
/// Die Typen aus FlaroKit behalten ihre Namen — sie sind Bauteile, keine Marke.
|
||||
enum Branding {
|
||||
/// Wie die App sich selbst nennt.
|
||||
static let appName = "Flaro"
|
||||
|
||||
/// 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.flaro"
|
||||
}
|
||||
|
||||
/// 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 FlaroDesign
|
||||
import FlaroDownloads
|
||||
import FlaroPlayback
|
||||
import JellyfinKit
|
||||
import MediaKit
|
||||
|
||||
@main
|
||||
struct FlaroApp: 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 = FlaroTheme()
|
||||
@State private var session: FlaroSession
|
||||
@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: FlaroSession(
|
||||
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: FlaroPlatform.isMac ? 900 : nil,
|
||||
minHeight: FlaroPlatform.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 FlaroApp {
|
||||
/// 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>Flaro</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>Flaro 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 FlaroDownloads
|
||||
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 FlaroDownloads
|
||||
import FlaroPlayback
|
||||
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 FlaroSession {
|
||||
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 = "flaro.jellyfin.address"
|
||||
static let jellyfinUserID = "flaro.jellyfin.userID"
|
||||
static let jellyfinUserName = "flaro.jellyfin.userName"
|
||||
static let jellyfinServerName = "flaro.jellyfin.serverName"
|
||||
static let jellyfinIsAdministrator = "flaro.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 = "flaro.migration.plexPurged"
|
||||
static let tokenKonten = ["plex.account", "plex.server"]
|
||||
static let schlüssel = [
|
||||
"flaro.backend.kind",
|
||||
"flaro.server.clientIdentifier",
|
||||
"flaro.server.connectionURI",
|
||||
"flaro.server.name",
|
||||
"flaro.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 Flaro 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:
|
||||
/// `-FlaroStubServer 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: "FlaroStubServer"),
|
||||
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 FlaroDesign
|
||||
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 FlaroAspectRatio.poster }
|
||||
return compact ? FlaroAspectRatio.heroCompact : FlaroAspectRatio.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 = "flaro.home.hiddenHubs"
|
||||
static let order = "flaro.home.hubOrder"
|
||||
static let heroCount = "flaro.home.heroCount"
|
||||
static let heroSource = "flaro.home.heroSource"
|
||||
static let heroLibraries = "flaro.home.heroLibraries"
|
||||
static let combinesContinueRows = "flaro.home.combinesContinueRows"
|
||||
static let hidesWatchedInLatest = "flaro.home.hidesWatchedInLatest"
|
||||
static let cardScale = "flaro.home.cardScale"
|
||||
static let heroHeight = "flaro.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 `FlaroSession.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 Flaro 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 FlaroPlayback
|
||||
|
||||
/// 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 = "flaro.playback.audioLanguage"
|
||||
static let subtitles = "flaro.playback.subtitleLanguage"
|
||||
static let fallbacks = "flaro.languages.fallbacks"
|
||||
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
||||
static let perLibrary = "flaro.languages.library"
|
||||
static let perSeries = "flaro.languages.series"
|
||||
static let rememberedTracks = "flaro.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 FlaroDesign, 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 = "flaro.navigation.hidden"
|
||||
static let order = "flaro.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 FlaroPlayback
|
||||
|
||||
/// 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 FlaroPlayback
|
||||
|
||||
/// 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 = "flaro.playback.quality.wifi"
|
||||
static let cellularQuality = "flaro.playback.quality.cellular"
|
||||
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
||||
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
||||
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
||||
static let locksLandscape = "flaro.playback.locksLandscape"
|
||||
static let rewindSeconds = "flaro.playback.rewindSeconds"
|
||||
static let forwardSeconds = "flaro.playback.forwardSeconds"
|
||||
static let defaultRate = "flaro.playback.defaultRate"
|
||||
static let remembersQuality = "flaro.playback.remembersQuality"
|
||||
static let rememberedQuality = "flaro.playback.rememberedQuality"
|
||||
static let showsBitrate = "flaro.playback.showsBitrate"
|
||||
static let lastPlaybackStruggled = "flaro.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 = "flaro.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 FlaroPlayback
|
||||
|
||||
/// 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 = "flaro.subtitles.size"
|
||||
static let palette = "flaro.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 FlaroPlayback
|
||||
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 `Flaro/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.flaro"
|
||||
|
||||
/// 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 flaroItemChanged: () async -> Void = {}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
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 `FlaroDesign`, 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(FlaroSession.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(\.flaroAccent) 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(\.flaroItemChanged) private var itemChanged
|
||||
/// Der Weg auf die Detailseite — siehe `MediaStack`.
|
||||
@Environment(\.flaroPush) private var push
|
||||
|
||||
private var isMac: Bool { FlaroPlatform.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(FlaroSpacing.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 FlaroDesign
|
||||
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(FlaroSession.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 FlaroDesign
|
||||
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(FlaroSession.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.flaroCanvas)
|
||||
.navigationTitle(state.value?.title ?? route.title)
|
||||
.flaroInlineTitle()
|
||||
.toolbar {
|
||||
if let item = state.value {
|
||||
ToolbarItem(placement: .flaroTrailing) {
|
||||
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 FlaroDesign
|
||||
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: FlaroSpacing.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background(Color.flaroElevated, in: .capsule)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.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 FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
||||
/// einzelne Folge.
|
||||
struct PlayableDetailView: View {
|
||||
let item: MediaItem
|
||||
|
||||
@Environment(FlaroSession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(\.flaroAccent) 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: FlaroSpacing.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, FlaroSpacing.screenMargin)
|
||||
}
|
||||
|
||||
if let summary = item.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.padding(.horizontal, FlaroSpacing.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, FlaroSpacing.screenMargin)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, FlaroSpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
|
||||
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(.flaroCardTitle)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.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(.flaroCardTitle)
|
||||
.padding(FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.flaroTextSecondary)
|
||||
}
|
||||
|
||||
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: FlaroSpacing.snug) {
|
||||
Text("detail.versions")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
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: FlaroSpacing.snug) {
|
||||
ForEach(tags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.vertical, FlaroSpacing.tight)
|
||||
.background(Color.flaroElevated, in: .capsule)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Besetzung mit Porträts.
|
||||
struct CastRow: View {
|
||||
let cast: [MediaPerson]
|
||||
@Environment(FlaroSession.self) private var session
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||
Text("detail.cast")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: FlaroSpacing.regular) {
|
||||
ForEach(cast.prefix(20)) { role in
|
||||
VStack(spacing: FlaroSpacing.snug) {
|
||||
FlaroArtwork(
|
||||
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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let character = role.role {
|
||||
Text(character)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(width: 90)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroDownloads
|
||||
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(FlaroSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.flaroAccent) 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: FlaroSpacing.section) {
|
||||
header
|
||||
|
||||
ParentLinks(item: item)
|
||||
|
||||
if let summary = item.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.padding(.horizontal, FlaroSpacing.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, FlaroSpacing.screenMargin)
|
||||
}
|
||||
|
||||
childList
|
||||
|
||||
if !item.cast.isEmpty { CastRow(cast: item.cast) }
|
||||
}
|
||||
.padding(.bottom, FlaroSpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.flaroRevealsTopChrome()
|
||||
.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, FlaroSpacing.section)
|
||||
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
|
||||
case .loaded(let entries):
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.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(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
Spacer()
|
||||
if entries.contains(where: { $0.kind == .episode }) {
|
||||
selectionControls(for: entries)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(entries) { entry in
|
||||
row(for: entry)
|
||||
Divider().overlay(Color.flaroSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: FlaroSpacing.snug) {
|
||||
ChildRow(item: entry)
|
||||
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(gewaehlt ? accent : Color.flaroTextMuted)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, FlaroSpacing.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: FlaroSpacing.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, FlaroSpacing.screenMargin)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func selectionControls(for entries: [MediaItem]) -> some View {
|
||||
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
|
||||
|
||||
HStack(spacing: FlaroSpacing.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.flaroTextSecondary)
|
||||
} else {
|
||||
Button("detail.select") { isSelecting = true }
|
||||
}
|
||||
}
|
||||
.font(.flaroCaption)
|
||||
.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(.flaroCardTitle)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
.disabled(isWorking)
|
||||
.padding(.vertical, FlaroSpacing.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(FlaroSession.self) private var session
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
FlaroArtwork(
|
||||
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(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if item.isWatched {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.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, FlaroSpacing.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 FlaroDesign
|
||||
import FlaroPlayback
|
||||
import MediaKit
|
||||
|
||||
/// Die aufklappbaren technischen Angaben zu einer Datei.
|
||||
///
|
||||
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Flaro 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: FlaroSpacing.snug) {
|
||||
row("technical.playbackMethod", playbackMethodLabel)
|
||||
if let reason = rationaleLabel {
|
||||
row("technical.reason", reason)
|
||||
}
|
||||
|
||||
Divider().overlay(Color.flaroSeparator).padding(.vertical, FlaroSpacing.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.flaroSeparator).padding(.vertical, FlaroSpacing.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.flaroSeparator).padding(.vertical, FlaroSpacing.tight)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("technical.file")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
Text(file)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, FlaroSpacing.snug)
|
||||
} label: {
|
||||
Text("technical.title")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
}
|
||||
.tint(Color.flaroTextSecondary)
|
||||
}
|
||||
|
||||
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(label)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.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 FlaroDesign
|
||||
import FlaroDownloads
|
||||
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(FlaroSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.flaroAccent) 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(.flaroCardTitle)
|
||||
}
|
||||
}
|
||||
// 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(FlaroSpacing.snug)
|
||||
}
|
||||
.disabled(isLoadingDetails)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(existing?.state == .completed ? accent : Color.flaroTextSecondary)
|
||||
.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(FlaroSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.flaroAccent) 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.flaroTextSecondary)
|
||||
.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 FlaroDownloads
|
||||
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 FlaroDesign
|
||||
import FlaroDownloads
|
||||
import FlaroPlayback
|
||||
|
||||
/// 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(\.flaroAccent) 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.flaroCanvas)
|
||||
.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: .flaroTrailing) { AccountToolbarButton() }
|
||||
}
|
||||
}
|
||||
.flaroInlineTitle()
|
||||
// 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.flaroSurface)
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
|
||||
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(\.flaroAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var downloads = downloads
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("downloads.used")
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
Spacer()
|
||||
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
}
|
||||
|
||||
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(\.flaroAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.font(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle = item.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
|
||||
if let message = item.failureMessage {
|
||||
Text(message)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroDestructive)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, FlaroSpacing.tight)
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
.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.flaroTextMuted)
|
||||
.accessibilityLabel(Text("downloads.available"))
|
||||
|
||||
case .queued, .waiting:
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.accessibilityLabel(Text("downloads.queued"))
|
||||
}
|
||||
}
|
||||
|
||||
private var progress: some View {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.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: FlaroSpacing.snug) {
|
||||
Text(statusLine)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: FlaroSpacing.snug)
|
||||
if let percentage {
|
||||
Text(percentage)
|
||||
}
|
||||
}
|
||||
.font(.flaroCaption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(\.flaroAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: FlaroSpacing.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(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
Text(hold == .offline
|
||||
? "downloads.hold.offline.description"
|
||||
: "downloads.hold.wifi.description")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, FlaroSpacing.tight)
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
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(\.flaroAccent) private var accent
|
||||
@Environment(FlaroSession.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: FlaroSpacing.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 { FlaroPlatform.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, FlaroSpacing.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(FlaroSpacing.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 ? FlaroSpacing.regular : FlaroSpacing.snug) {
|
||||
ForEach(items.indices, id: \.self) { position in
|
||||
Capsule()
|
||||
.fill(position == currentIndex ? accent : Color.flaroTextMuted.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, FlaroSpacing.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 FlaroDesign
|
||||
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(FlaroSession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(QuickPlay.self) private var quickPlay
|
||||
@Environment(\.flaroAccent) 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(.flaroCardTitle)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
|
||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||
Label("hero.details", systemImage: "info.circle")
|
||||
.font(.flaroCardTitle)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// „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 FlaroDesign
|
||||
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(FlaroSession.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.flaroCanvas)
|
||||
// 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("")
|
||||
.flaroInlineTitle()
|
||||
// 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.
|
||||
.flaroClearToolbarBackground()
|
||||
// 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(\.flaroItemChanged, { await feed.load(from: session.backend) })
|
||||
}
|
||||
|
||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||
let featured = feed.heroItems(for: layout)
|
||||
if !featured.isEmpty {
|
||||
HeroCarousel(items: featured)
|
||||
}
|
||||
|
||||
ForEach(hubs) { hub in
|
||||
MediaRow(row: hub)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, FlaroSpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// Eine waagerechte Reihe von Titeln.
|
||||
struct MediaRow: View {
|
||||
let row: MediaRowGroup
|
||||
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
||||
var opensDetails: Bool = false
|
||||
|
||||
@Environment(FlaroSession.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: FlaroSpacing.regular) {
|
||||
Text(row.title)
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: FlaroSpacing.regular) {
|
||||
ForEach(row.items) { item in
|
||||
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.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 FlaroDesign
|
||||
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(FlaroSession.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.flaroCanvas)
|
||||
// **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 { FlaroSidebar.surface(in: .capsule) }
|
||||
.clipShape(.capsule)
|
||||
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.bottom, FlaroSpacing.snug)
|
||||
}
|
||||
|
||||
private var reihe: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(eintraege, id: \.auswahl) { eintrag in
|
||||
pille(eintrag)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.snug)
|
||||
.padding(.vertical, FlaroSpacing.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(.flaroCaption)
|
||||
.foregroundStyle(gewaehlt ? Color.flaroTextPrimary : Color.flaroTextSecondary)
|
||||
.padding(.horizontal, FlaroSpacing.snug)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background {
|
||||
if gewaehlt { Capsule().fill(Color.flaroElevated) }
|
||||
}
|
||||
.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 FlaroDesign
|
||||
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(\.flaroAccent) private var accent
|
||||
@Environment(FlaroSession.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)
|
||||
.flaroForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("filter.title"))
|
||||
.flaroInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("action.cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.apply") {
|
||||
query = draft
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await loadGenres() }
|
||||
}
|
||||
.flaroResizableSheet()
|
||||
}
|
||||
|
||||
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 FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// Eine Mediathek zum Durchblättern.
|
||||
struct LibraryView: View {
|
||||
let library: MediaLibrary
|
||||
|
||||
@Environment(FlaroSession.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("flaro.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.flaroCanvas)
|
||||
.navigationTitle(library.title)
|
||||
.flaroInlineTitle()
|
||||
.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(\.flaroItemChanged, { 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: FlaroSpacing.regular)],
|
||||
spacing: FlaroSpacing.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(FlaroSpacing.screenMargin)
|
||||
|
||||
moreButton.padding(.bottom, FlaroSpacing.section)
|
||||
footerCount.padding(.bottom, FlaroSpacing.section)
|
||||
}
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
|
||||
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.flaroSeparator)
|
||||
}
|
||||
}
|
||||
|
||||
if pendingCount > 0 {
|
||||
ProgressView()
|
||||
.padding(.vertical, FlaroSpacing.section)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
moreButton.padding(.vertical, FlaroSpacing.section)
|
||||
footerCount.padding(.bottom, FlaroSpacing.section)
|
||||
}
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
|
||||
/// 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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.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: FlaroSpacing.snug) {
|
||||
Text("library.loadMoreFailed")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
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: .flaroTrailing) {
|
||||
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: .flaroTrailing) {
|
||||
Button {
|
||||
Task { await reload() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.accessibilityLabel(Text("library.reload"))
|
||||
.disabled(isLoadingMore)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .flaroTrailing) {
|
||||
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: .flaroTrailing) {
|
||||
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: .flaroTrailing) { 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(FlaroSession.self) private var session
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
FlaroArtwork(
|
||||
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(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle = item.cardSubtitle {
|
||||
Text(subtitle)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if item.isWatched {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.accessibilityLabel(Text("item.watched"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.contentShape(.rect)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
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(FlaroSession.self) private var session
|
||||
@Environment(\.flaroAccent) 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"))
|
||||
.flaroInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .flaroLeading) {
|
||||
// „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: FlaroSpacing.regular) {
|
||||
FlaroArtwork(
|
||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||
aspectRatio: FlaroAspectRatio.channelLogo,
|
||||
fallbackSymbol: "tv",
|
||||
contentMode: .fit
|
||||
)
|
||||
.frame(width: 48, height: 29)
|
||||
|
||||
if let number = channel.number {
|
||||
Text(number)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
|
||||
Text(channel.title)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(isShown(channel) ? accent : Color.flaroTextMuted)
|
||||
}
|
||||
.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 FlaroDesign
|
||||
import FlaroPlayback
|
||||
import MediaKit
|
||||
|
||||
/// Live-TV mit Programmführer.
|
||||
struct LiveTVView: View {
|
||||
@Environment(FlaroSession.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: FlaroSpacing.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.flaroCanvas)
|
||||
.navigationTitle(Text("sidebar.liveTV"))
|
||||
.flaroInlineTitle()
|
||||
.toolbar {
|
||||
if case .available = availability, !channels.isEmpty {
|
||||
ToolbarItem(placement: .flaroTrailing) {
|
||||
Button {
|
||||
showsSelection = true
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.accessibilityLabel(Text("liveTV.selection"))
|
||||
}
|
||||
ToolbarItem(placement: .flaroTrailing) {
|
||||
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: FlaroSpacing.snug) {
|
||||
if let channelSource {
|
||||
Text(channelSource)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(trace) { probe in
|
||||
Text("\(probe.path) → \(probe.result)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.lineLimit(2)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
|
||||
Button {
|
||||
FlaroClipboard.copy(
|
||||
trace
|
||||
.map { "\($0.path) → \($0.result)" }
|
||||
.joined(separator: "\n")
|
||||
)
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.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.flaroSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
|
||||
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(FlaroSession.self) private var session
|
||||
@Environment(\.flaroAccent) 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: FlaroSpacing.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.
|
||||
FlaroArtwork(
|
||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||
aspectRatio: FlaroAspectRatio.channelLogo,
|
||||
fallbackSymbol: "tv",
|
||||
contentMode: .fit
|
||||
)
|
||||
.frame(width: 56, height: 34)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: FlaroSpacing.snug) {
|
||||
if let number = channel.number {
|
||||
Text(number)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
Text(channel.title)
|
||||
.font(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let current {
|
||||
Text(current.title)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
|
||||
if let next {
|
||||
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isTuning {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
.padding(.vertical, FlaroSpacing.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: FlaroSpacing.regular) {
|
||||
Text(failure.channel)
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
Text(failure.reason)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
|
||||
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
FlaroClipboard.copy(failure.trace.joined(separator: "\n"))
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(FlaroSpacing.screenMargin)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("liveTV.tuneFailed"))
|
||||
.flaroInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.flaroResizableSheet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
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(\.flaroAccent) 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.flaroSeparator)
|
||||
|
||||
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.flaroCanvas)
|
||||
.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
// 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, FlaroSpacing.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.flaroTextMuted)
|
||||
.frame(width: layout.tickWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.fixedSize()
|
||||
.offset(x: -scrollX)
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: axisHeight)
|
||||
.background(Color.flaroCanvas)
|
||||
}
|
||||
|
||||
/// 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: FlaroSpacing.snug) {
|
||||
FlaroArtwork(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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(2)
|
||||
if let number = channel.number {
|
||||
Text(number)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if tuning == channel.id {
|
||||
ProgressView().controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.snug)
|
||||
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
|
||||
.overlay(alignment: .bottom) { rowSeparator }
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(width: channelWidth)
|
||||
.background(Color.flaroElevated.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.padding(.horizontal, FlaroSpacing.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.flaroSeparator)
|
||||
.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.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.flaroElevated,
|
||||
in: .rect(cornerRadius: FlaroRadius.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(\.flaroAccent) private var accent
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
FlaroArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(.rect(cornerRadius: 6))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(channel.title)
|
||||
.font(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(program.title)
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
if let subtitle = program.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
|
||||
if isRunning {
|
||||
Button { onTune(); dismiss() } label: {
|
||||
Label("liveTV.tune", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlaroSpacing.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
} else if onRecord != nil || onRecordSeries != nil {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
if let onRecord {
|
||||
Button { onRecord(); dismiss() } label: {
|
||||
Label("liveTV.record", systemImage: "record.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
if let onRecordSeries {
|
||||
Button { onRecordSeries(); dismiss() } label: {
|
||||
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let summary = program.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(FlaroSpacing.screenMargin)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Color.flaroCanvas)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.flaroResizableSheet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
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(FlaroSession.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.flaroCanvas)
|
||||
.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.flaroSurface)
|
||||
} 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.flaroSurface)
|
||||
} header: {
|
||||
Text("liveTV.scheduled")
|
||||
}
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
|
||||
// MARK: - Zeilen
|
||||
|
||||
private func row(_ recording: LiveRecording) -> some View {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
FlaroArtwork(
|
||||
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(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: FlaroSpacing.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroDestructive)
|
||||
}
|
||||
if let subtitle = subtitle(for: recording) {
|
||||
Text(subtitle)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
|
||||
private func timerRow(_ timer: LiveTimer) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(timer.title)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
|
||||
Text(subtitle)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
|
||||
HStack(spacing: FlaroSpacing.snug) {
|
||||
Image(systemName: "rectangle.stack.badge.record")
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(timer.name)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 `FlaroSession` —, 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 FlaroDesign
|
||||
|
||||
/// 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(\.flaroAccent) 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.flaroCanvas)
|
||||
.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 flaroOpenSettings: () -> 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(FlaroSession.self) private var session
|
||||
@Environment(\.flaroOpenSettings) 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 FlaroDesign
|
||||
|
||||
/// 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(FlaroSession.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.flaroSurface)
|
||||
// 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: FlaroSpacing.snug) {
|
||||
AccountAvatar(name: title)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title)
|
||||
.font(.flaroCardTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.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.flaroTextMuted)
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.vertical, FlaroSpacing.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(.flaroBody)
|
||||
.foregroundStyle(isDestructive ? Color.flaroDestructive : Color.flaroTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.vertical, FlaroSpacing.regular)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Divider().overlay(Color.flaroSeparator)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
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 FlaroSidebar: 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(\.flaroAccent) 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.flaroCanvas.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.bottom, FlaroSpacing.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||
ForEach(groups) { group in
|
||||
section(visible(group))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.snug)
|
||||
.padding(.bottom, FlaroSpacing.regular)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Divider().overlay(Color.flaroSeparator)
|
||||
AccountBar(selection: $selection)
|
||||
.padding(.horizontal, FlaroSpacing.snug)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
}
|
||||
.frame(width: Self.width)
|
||||
.background { Self.surface(in: .rect(cornerRadius: FlaroRadius.panel, style: .continuous)) }
|
||||
.clipShape(.rect(cornerRadius: FlaroRadius.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: FlaroSpacing.snug) {
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
|
||||
} label: {
|
||||
Text(isEditing ? "action.done" : "sidebar.edit")
|
||||
.font(.flaroCaption)
|
||||
.padding(.horizontal, FlaroSpacing.snug)
|
||||
.padding(.vertical, FlaroSpacing.tight)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(isEditing ? accent : Color.flaroTextSecondary)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if !isEditing {
|
||||
Button {
|
||||
placement = .top
|
||||
} label: {
|
||||
Image(systemName: "rectangle.topthird.inset.filled")
|
||||
.font(.flaroRowHeader)
|
||||
.padding(FlaroSpacing.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.flaroTextSecondary)
|
||||
.accessibilityLabel(Text("sidebar.moveToTop"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.top, FlaroSpacing.regular)
|
||||
.padding(.bottom, FlaroSpacing.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.top, FlaroSpacing.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: FlaroSpacing.snug) {
|
||||
Button {
|
||||
layout.setHidden(!isHidden, for: entry.arrangementID)
|
||||
} label: {
|
||||
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
|
||||
.foregroundStyle(isHidden ? Color.flaroTextMuted : 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(.flaroBody)
|
||||
.foregroundStyle(isHidden ? Color.flaroTextMuted : Color.flaroTextPrimary)
|
||||
}
|
||||
|
||||
private func row(_ entry: NavigationEntry) -> some View {
|
||||
let isSelected = selection == entry.destination
|
||||
|
||||
return Button {
|
||||
selection = entry.destination
|
||||
} label: {
|
||||
HStack(spacing: FlaroSpacing.snug) {
|
||||
Image(systemName: entry.symbol)
|
||||
.font(.flaroBody)
|
||||
.frame(width: 22, alignment: .center)
|
||||
entry.title
|
||||
.font(.flaroBody)
|
||||
// Ein langer Mediathekenname darf die Leiste nicht
|
||||
// breiter machen; sie hat eine feste Breite.
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(isSelected ? accent : Color.flaroTextPrimary)
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background {
|
||||
if isSelected {
|
||||
Capsule().fill(Color.flaroElevated)
|
||||
}
|
||||
}
|
||||
.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 FlaroTopBar: View {
|
||||
let groups: [NavigationGroup]
|
||||
@Binding var selection: SidebarDestination
|
||||
@Binding var placement: NavigationPlacement
|
||||
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: FlaroSpacing.tight) {
|
||||
Button {
|
||||
placement = .sidebar
|
||||
} label: {
|
||||
Image(systemName: "sidebar.leading")
|
||||
.font(.flaroBody)
|
||||
.padding(.horizontal, FlaroSpacing.regular)
|
||||
.padding(.vertical, FlaroSpacing.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.flaroTextSecondary)
|
||||
.accessibilityLabel(Text("sidebar.moveToSide"))
|
||||
|
||||
ForEach(groups) { group in
|
||||
if group.title == nil {
|
||||
ForEach(group.entries) { entry in
|
||||
pill(entry)
|
||||
}
|
||||
} else {
|
||||
menu(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.snug)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background { FlaroSidebar.surface(in: .capsule) }
|
||||
.clipShape(.capsule)
|
||||
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||
.padding(.top, FlaroSpacing.snug)
|
||||
}
|
||||
|
||||
private func pill(_ entry: NavigationEntry) -> some View {
|
||||
let isSelected = selection == entry.destination
|
||||
|
||||
return Button {
|
||||
selection = entry.destination
|
||||
} label: {
|
||||
entry.title
|
||||
.font(.flaroBody)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(isSelected ? accent : Color.flaroTextPrimary)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background {
|
||||
if isSelected { Capsule().fill(Color.flaroElevated) }
|
||||
}
|
||||
.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: FlaroSpacing.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(.flaroCaption)
|
||||
}
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(containsSelection ? accent : Color.flaroTextPrimary)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background {
|
||||
if containsSelection { Capsule().fill(Color.flaroElevated) }
|
||||
}
|
||||
.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(FlaroSession.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(\.flaroPush) { 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 flaroPush: (any Hashable) -> Void = { _ in }
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
import FlaroDownloads
|
||||
import FlaroPlayback
|
||||
|
||||
/// 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(FlaroSession.self) private var session
|
||||
@Environment(FlaroTheme.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("flaro.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("flaro.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.flaroCanvas)
|
||||
|
||||
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.flaroCanvas)
|
||||
.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 `FlaroSidebar`. 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.
|
||||
.flaroAccent(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: `FlaroDownloads` 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("flaro.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("flaro.tab.library.\(library.id)")
|
||||
}
|
||||
} header: {
|
||||
Text("sidebar.libraries")
|
||||
}
|
||||
.customizationID("flaro.tab.libraries")
|
||||
|
||||
if session.supportsLiveTV {
|
||||
Tab(value: SidebarDestination.liveTV) {
|
||||
mediaStack { LiveTVView() }
|
||||
} label: {
|
||||
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
.customizationID("flaro.tab.liveTV")
|
||||
}
|
||||
} else {
|
||||
Tab(value: SidebarDestination.libraryHub) {
|
||||
mediaStack { LibraryBrowser() }
|
||||
} label: {
|
||||
tabLabel("sidebar.library", symbol: "books.vertical")
|
||||
}
|
||||
.customizationID("flaro.tab.libraryHub")
|
||||
}
|
||||
|
||||
Tab(value: SidebarDestination.downloads) {
|
||||
mediaStack { DownloadsView() }
|
||||
} label: {
|
||||
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
|
||||
}
|
||||
.customizationID("flaro.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("flaro.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("flaro.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 {
|
||||
FlaroSidebar(
|
||||
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 {
|
||||
FlaroTopBar(
|
||||
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(\.flaroOpenSettings, { 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: .flaroTrailing) {
|
||||
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(FlaroSpacing.snug)
|
||||
// Damit er auch über einem hellen Aufmacher steht.
|
||||
.background(.black.opacity(0.28), in: .circle)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.trailing, FlaroSpacing.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, FlaroSpacing.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 ? FlaroSidebar.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 flaroRevealsTopChrome() -> 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 FlaroDesign
|
||||
|
||||
/// 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.flaroCanvas)
|
||||
.navigationTitle(destination.title)
|
||||
.flaroInlineTitle()
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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 FlaroDesign
|
||||
|
||||
/// 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 / FlaroAspectRatio.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: FlaroRadius.card))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: FlaroRadius.card)
|
||||
.strokeBorder(Color.flaroSeparator, 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(FlaroSpacing.tight)
|
||||
// Über dem Fortschrittsbalken, nicht darauf.
|
||||
.padding(.bottom, FlaroSpacing.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 FlaroDesign
|
||||
|
||||
/// 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(\.flaroAccent) 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 FlaroDesign
|
||||
import FlaroPlayback
|
||||
|
||||
/// 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(\.flaroAccent) 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"))
|
||||
.flaroInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.flaroResizableSheet()
|
||||
}
|
||||
|
||||
/// 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.flaroTextPrimary)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
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 FlaroDesign
|
||||
import FlaroPlayback
|
||||
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(\.flaroAccent) 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.flaroSurface)
|
||||
} 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.flaroCanvas)
|
||||
.navigationTitle(Text(kind.title))
|
||||
.flaroInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.flaroMediumSheet()
|
||||
}
|
||||
|
||||
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
import FlaroPlayback
|
||||
#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 FlaroDesign
|
||||
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(FlaroSession.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.flaroCanvas)
|
||||
.navigationTitle(Text("search.title"))
|
||||
.flaroInlineTitle()
|
||||
.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(\.flaroItemChanged, { await feed.search(text, from: session.backend) })
|
||||
}
|
||||
|
||||
private var results: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: FlaroSpacing.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, FlaroSpacing.loose)
|
||||
}
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
|
||||
@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.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("search.recent")
|
||||
} footer: {
|
||||
Button("search.forgetRecent", role: .destructive) {
|
||||
feed.forgetRecent()
|
||||
}
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.flaroRevealsTopChrome()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
|
||||
/// 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(FlaroTheme.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: FlaroSpacing.regular)]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: FlaroSpacing.regular) {
|
||||
ForEach(AccentOption.palette) { option in
|
||||
swatch(option)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, FlaroSpacing.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.flaroTextSecondary)
|
||||
.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.flaroSurface)
|
||||
|
||||
Section {
|
||||
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
|
||||
|
||||
if showsCustomField {
|
||||
HStack {
|
||||
TextField("appearance.hexPlaceholder", text: $customHex)
|
||||
.flaroPlainEntry()
|
||||
.onSubmit(applyCustomColor)
|
||||
|
||||
Button("action.apply", action: applyCustomColor)
|
||||
.disabled(RGB(string: customHex) == nil)
|
||||
}
|
||||
|
||||
if !customHex.isEmpty, RGB(string: customHex) == nil {
|
||||
Text("appearance.invalidHex")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroDestructive)
|
||||
}
|
||||
}
|
||||
|
||||
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(.flaroCaption)
|
||||
} icon: {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("appearance.preview") {
|
||||
PreviewRow()
|
||||
}
|
||||
}
|
||||
.flaroForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("appearance.title"))
|
||||
.flaroInlineTitle()
|
||||
.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.flaroCanvas)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(
|
||||
theme.selection == option ? Color.flaroTextPrimary : .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(\.flaroAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
Label("hero.play", systemImage: "play.fill")
|
||||
.font(.flaroCardTitle)
|
||||
.padding(.horizontal, FlaroSpacing.loose)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.background(accent, in: .rect(cornerRadius: FlaroRadius.control))
|
||||
.foregroundStyle(Color.flaroCanvas)
|
||||
|
||||
Label("sidebar.downloads", systemImage: "arrow.down.circle")
|
||||
.font(.flaroCardTitle)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
|
||||
WatchProgressBar(fraction: 0.45)
|
||||
}
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
|
||||
/// „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(\.flaroAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
Text(title)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
Spacer(minLength: FlaroSpacing.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: FlaroSpacing.tight) {
|
||||
Text(currentLabel)
|
||||
.font(.flaroBody)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
.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 FlaroDesign
|
||||
|
||||
/// 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(FlaroSession.self) private var session
|
||||
@Environment(\.flaroAccent) 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(.flaroCaption)
|
||||
}
|
||||
if let beschreibung = session.connectionDescription {
|
||||
LabeledContent("settings.connection", value: beschreibung)
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
} else {
|
||||
Text("diagnostics.noServer").foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
Text(attempt.sourceDescription)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("diagnostics.steps") {
|
||||
ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in
|
||||
Text(step)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
FlaroClipboard.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.flaroTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.flaroForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("diagnostics.title"))
|
||||
.flaroInlineTitle()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
"""
|
||||
Flaro \(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 FlaroDesign
|
||||
import FlaroDownloads
|
||||
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(\.flaroAccent) 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)
|
||||
.flaroForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("sidebar.downloads"))
|
||||
.flaroInlineTitle()
|
||||
}
|
||||
|
||||
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 FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
||||
struct HomeLayoutView: View {
|
||||
@Environment(FlaroSession.self) private var session
|
||||
@Environment(\.flaroAccent) 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.flaroCanvas)
|
||||
.navigationTitle(Text("homeLayout.title"))
|
||||
.flaroInlineTitle()
|
||||
.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.flaroTextSecondary)
|
||||
.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.flaroSurface)
|
||||
|
||||
// **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.flaroTextPrimary)
|
||||
}
|
||||
// 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: FlaroSpacing.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.flaroDestructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
||||
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
||||
}
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
} else if rows.isEmpty {
|
||||
Text("homeLayout.empty")
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
}
|
||||
|
||||
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.flaroTextPrimary)
|
||||
}
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
}
|
||||
.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(.flaroCaption)
|
||||
.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 FlaroDesign
|
||||
|
||||
/// 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(\.flaroAccent) 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)
|
||||
.flaroForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("languages.title"))
|
||||
.flaroInlineTitle()
|
||||
// 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.flaroTextSecondary)
|
||||
} else {
|
||||
ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
// Die Nummer, weil die Reihenfolge der ganze Zweck
|
||||
// ist — eine Liste ohne sichtbaren Rang liest sich
|
||||
// wie eine Menge.
|
||||
Text("\(position + 1)")
|
||||
.font(.flaroCaption.monospacedDigit())
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.frame(minWidth: 18, alignment: .trailing)
|
||||
Text(name(of: code))
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
}
|
||||
}
|
||||
.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 FlaroDesign
|
||||
import FlaroPlayback
|
||||
|
||||
/// 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(\.flaroAccent) 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: FlaroSpacing.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.flaroForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("settings.playback"))
|
||||
.flaroInlineTitle()
|
||||
}
|
||||
|
||||
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 FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
||||
struct ServerSettingsView: View {
|
||||
@Environment(FlaroSession.self) private var session
|
||||
@Environment(\.flaroAccent) 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(.flaroCaption)
|
||||
.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)
|
||||
.flaroForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("settings.server"))
|
||||
.flaroInlineTitle()
|
||||
}
|
||||
|
||||
/// 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(FlaroSession.self) private var session
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
@State private var phase: Phase = .idle
|
||||
|
||||
private enum Phase { case idle, running, done }
|
||||
|
||||
var body: some View {
|
||||
LabeledContent {
|
||||
HStack(spacing: FlaroSpacing.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.flaroTextSecondary)
|
||||
}
|
||||
|
||||
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 FlaroDesign
|
||||
|
||||
/// 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: FlaroSpacing.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(.flaroTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
Text(explanation)
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, FlaroSpacing.snug)
|
||||
.listRowBackground(Color.flaroSurface)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 FlaroDesign
|
||||
|
||||
/// 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(FlaroSession.self) private var session
|
||||
@Environment(\.flaroAccent) 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.flaroTextSecondary)
|
||||
} 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)
|
||||
.flaroForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.flaroRevealsTopChrome()
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("sidebar.settings"))
|
||||
.flaroInlineTitle()
|
||||
.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: FlaroSpacing.regular) {
|
||||
SettingsIcon(symbol: symbol, colour: colour)
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import FlaroPlayback
|
||||
|
||||
/// Wie Untertitel aussehen sollen.
|
||||
struct SubtitleAppearanceView: View {
|
||||
@Environment(SubtitleAppearance.self) private var appearance
|
||||
@Environment(\.flaroAccent) 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)
|
||||
.flaroForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("subtitleStyle.title"))
|
||||
.flaroInlineTitle()
|
||||
}
|
||||
|
||||
/// 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(FlaroSpacing.regular)
|
||||
}
|
||||
.frame(height: 96)
|
||||
.clipShape(.rect(cornerRadius: FlaroRadius.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 FlaroDesign
|
||||
|
||||
/// Während ein Weg zum Server gesucht wird.
|
||||
struct ConnectingView: View {
|
||||
let serverName: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: FlaroSpacing.loose) {
|
||||
ProgressView()
|
||||
Text(serverName.isEmpty
|
||||
? String(localized: "connecting.searching")
|
||||
: String(localized: "connecting.to \(serverName)"))
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.flaroCanvas)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.flaroTextSecondary)
|
||||
}
|
||||
.background(Color.flaroCanvas)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
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(FlaroSession.self) private var session
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.flaroAccent) 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, FlaroSpacing.section)
|
||||
.padding(.vertical, FlaroSpacing.section)
|
||||
.frame(maxWidth: 420)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color.flaroCanvas)
|
||||
.navigationTitle(Text("signIn.jellyfin.title"))
|
||||
.flaroInlineTitle()
|
||||
.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: FlaroSpacing.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroDestructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Adresse
|
||||
|
||||
@ViewBuilder
|
||||
private func addressStep(model: JellyfinSignInModel) -> some View {
|
||||
@Bindable var model = model
|
||||
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||
Text("signIn.jellyfin.addressPrompt")
|
||||
.font(.flaroRowHeader)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
|
||||
.textFieldStyle(.plain)
|
||||
.textContentType(.URL)
|
||||
.flaroPlainEntry(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(FlaroSpacing.regular)
|
||||
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
|
||||
// niemandem etwas, „192.168.1.50" jedem.
|
||||
Text("signIn.jellyfin.addressHint")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
|
||||
Button {
|
||||
focusedField = nil
|
||||
Task { await model.findServer() }
|
||||
} label: {
|
||||
Text("signIn.jellyfin.connect")
|
||||
.font(.flaroRowHeader)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlaroSpacing.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: FlaroSpacing.regular) {
|
||||
foundServerHeader(model: model)
|
||||
|
||||
Text("signIn.jellyfin.quickConnectHint")
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
|
||||
Text(code)
|
||||
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
|
||||
.tracking(8)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
.textSelection(.enabled)
|
||||
.padding(.vertical, FlaroSpacing.regular)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||
|
||||
HStack(spacing: FlaroSpacing.snug) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("signIn.jellyfin.waitingForApproval")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
|
||||
Text("signIn.jellyfin.privacy")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
|
||||
Divider().overlay(Color.flaroSeparator)
|
||||
|
||||
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
|
||||
.font(.flaroCaption)
|
||||
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Benutzername und Passwort
|
||||
|
||||
@ViewBuilder
|
||||
private func credentialsStep(model: JellyfinSignInModel) -> some View {
|
||||
@Bindable var model = model
|
||||
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||
foundServerHeader(model: model)
|
||||
|
||||
TextField("signIn.jellyfin.userName", text: $model.userName)
|
||||
.textContentType(.username)
|
||||
.flaroPlainEntry()
|
||||
.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(FlaroSpacing.regular)
|
||||
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.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(FlaroSpacing.regular)
|
||||
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||
|
||||
Button {
|
||||
focusedField = nil
|
||||
Task { await model.signInWithPassword() }
|
||||
} label: {
|
||||
Text("signIn.jellyfin.submit")
|
||||
.font(.flaroRowHeader)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlaroSpacing.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(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
|
||||
Divider().overlay(Color.flaroSeparator)
|
||||
|
||||
if model.offersQuickConnect {
|
||||
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
@ViewBuilder
|
||||
private func foundServerHeader(model: JellyfinSignInModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.tight) {
|
||||
Text("signIn.jellyfin.foundServer")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.textCase(.uppercase)
|
||||
Text(model.serverName ?? model.address)
|
||||
.font(.flaroTitle)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func waiting(text: LocalizedStringKey) -> some View {
|
||||
HStack(spacing: FlaroSpacing.snug) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(text)
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, FlaroSpacing.section)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
|
||||
/// 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(\.flaroAccent) private var accent
|
||||
@State private var showsJellyfinSignIn = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: FlaroSpacing.section) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: FlaroSpacing.regular) {
|
||||
Image(systemName: "play.rectangle.on.rectangle")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(accent)
|
||||
|
||||
Text(Branding.appName)
|
||||
.font(.flaroHero)
|
||||
.foregroundStyle(Color.flaroTextPrimary)
|
||||
|
||||
Text("signIn.tagline")
|
||||
.font(.flaroBody)
|
||||
.foregroundStyle(Color.flaroTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: FlaroSpacing.regular) {
|
||||
Button {
|
||||
showsJellyfinSignIn = true
|
||||
} label: {
|
||||
Text("signIn.withJellyfin")
|
||||
.font(.flaroRowHeader)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlaroSpacing.regular)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
|
||||
Text("signIn.privacy")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer().frame(height: FlaroSpacing.section)
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.section)
|
||||
.frame(maxWidth: 420)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.flaroCanvas)
|
||||
.sheet(isPresented: $showsJellyfinSignIn) {
|
||||
JellyfinSignInView()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user