Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9a1e7b67 | ||
|
|
2fcf6821c8 | ||
|
|
93695dc332 | ||
|
|
24d80e5c95 | ||
|
|
25e6ada0ce | ||
|
|
6b6bc6ad02 | ||
|
|
a432592271 | ||
|
|
6b927c21b0 | ||
|
|
ef2596e2bb | ||
|
|
b1973c6890 |
+4
-3
@@ -9,7 +9,8 @@ xcuserdata/
|
|||||||
# Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine
|
# Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine
|
||||||
# Konflikte in der pbxproj gibt. Nach dem Klonen: xcodegen generate
|
# Konflikte in der pbxproj gibt. Nach dem Klonen: xcodegen generate
|
||||||
#
|
#
|
||||||
# Flixy.xcworkspace daneben ist dagegen von Hand geschrieben und gehört dazu:
|
# Halo.xcworkspace daneben ist dagegen von Hand geschrieben und gehört dazu:
|
||||||
# er verweist nur auf das Projekt und überlebt jedes Neuerzeugen. Warum das
|
# er verweist nur auf das Projekt und überlebt jedes Neuerzeugen. Warum das
|
||||||
# nötig ist, steht in Flixy.xcworkspace/README.md.
|
# nötig ist, steht in Halo.xcworkspace/README.md.
|
||||||
Flixy.xcodeproj/
|
Halo.xcodeproj/
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import ActivityKit
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
|
|
||||||
/// Der Download-Fortschritt im Sperrbildschirm und in der Dynamic Island.
|
/// Der Download-Fortschritt im Sperrbildschirm und in der Dynamic Island.
|
||||||
struct DownloadLiveActivity: Widget {
|
struct DownloadLiveActivity: Widget {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Flixy Downloads</string>
|
<string>Halo Downloads</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"originHash" : "8b778ccd4dce9d26be9db57d4587b19c4a4366fcdaf8b6017e1df36fa1eddb4f",
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "vlckit",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/videolan/vlckit.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "6cbc4e7b248aa51ec0906697abb30ecae47194b7",
|
|
||||||
"version" : "4.0.0-a24"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 3
|
|
||||||
}
|
|
||||||
@@ -1,727 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import JellyfinKit
|
|
||||||
import MediaKit
|
|
||||||
import Observation
|
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// 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 FlixySession {
|
|
||||||
/// Welche Art von Server gerade bedient wird.
|
|
||||||
enum ServerKind: String, Sendable, CaseIterable {
|
|
||||||
case plex
|
|
||||||
case jellyfin
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Phase: Equatable {
|
|
||||||
/// Beim Start: liegt ein Token im Schlüsselbund?
|
|
||||||
case launching
|
|
||||||
case signedOut
|
|
||||||
/// Das Anmeldefenster von Plex ist offen.
|
|
||||||
case authenticating
|
|
||||||
/// Mehrere Server am Konto — einer muss gewählt werden.
|
|
||||||
case choosingServer([PlexResource])
|
|
||||||
/// Ein Weg zum Server wird gesucht.
|
|
||||||
case connecting(serverName: String)
|
|
||||||
case ready
|
|
||||||
case failed(message: String)
|
|
||||||
|
|
||||||
static func == (lhs: Phase, rhs: Phase) -> Bool {
|
|
||||||
switch (lhs, rhs) {
|
|
||||||
case (.launching, .launching), (.signedOut, .signedOut), (.ready, .ready):
|
|
||||||
true
|
|
||||||
case (.authenticating, .authenticating):
|
|
||||||
true
|
|
||||||
case (.choosingServer(let l), .choosingServer(let r)):
|
|
||||||
l.map(\.clientIdentifier) == r.map(\.clientIdentifier)
|
|
||||||
case (.connecting(let l), .connecting(let r)):
|
|
||||||
l == r
|
|
||||||
case (.failed(let l), .failed(let r)):
|
|
||||||
l == r
|
|
||||||
default:
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum Key {
|
|
||||||
/// Welche Serverart zuletzt aktiv war.
|
|
||||||
///
|
|
||||||
/// Fehlt der Eintrag, aber es liegt ein Plex-Token bereit, war es
|
|
||||||
/// Plex — so kam die App bis hierher, und niemand soll durch ein
|
|
||||||
/// Update abgemeldet werden.
|
|
||||||
static let serverKind = "flixy.backend.kind"
|
|
||||||
static let preferredServer = "flixy.server.clientIdentifier"
|
|
||||||
static let cachedConnectionURI = "flixy.server.connectionURI"
|
|
||||||
static let cachedServerName = "flixy.server.name"
|
|
||||||
static let cachedConnectionIsLocal = "flixy.server.connectionIsLocal"
|
|
||||||
static let jellyfinAddress = "flixy.jellyfin.address"
|
|
||||||
static let jellyfinUserID = "flixy.jellyfin.userID"
|
|
||||||
static let jellyfinUserName = "flixy.jellyfin.userName"
|
|
||||||
static let jellyfinServerName = "flixy.jellyfin.serverName"
|
|
||||||
static let jellyfinIsAdministrator = "flixy.jellyfin.isAdministrator"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unter welchem Namen die Server-Token im Schlüsselbund liegen.
|
|
||||||
///
|
|
||||||
/// Nicht in den Voreinstellungen: sie geben vollen Zugriff auf die
|
|
||||||
/// Mediathek. Getrennte Konten je Serverart, damit ein späteres Umschalten
|
|
||||||
/// zwischen beiden nicht jedes Mal eine neue Anmeldung kostet. Das
|
|
||||||
/// ausdrückliche Abmelden räumt trotzdem beide ab — siehe `signOut()`.
|
|
||||||
private static let plexTokenAccount = "plex.server"
|
|
||||||
private static let jellyfinTokenAccount = "jellyfin.server"
|
|
||||||
|
|
||||||
/// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den
|
|
||||||
/// bestätigten Code darüber diesem Gerät zu.
|
|
||||||
var clientIdentifier: String { identity.clientIdentifier }
|
|
||||||
|
|
||||||
/// Der Code der laufenden Anmeldung.
|
|
||||||
///
|
|
||||||
/// Wird nach kurzer Zeit im Anmeldebildschirm eingeblendet — als Ausweg,
|
|
||||||
/// falls die Browser-Seite den Code nicht verknüpft. Das passiert, wenn
|
|
||||||
/// man dort schon angemeldet ist, und ohne diesen Ausweg säße man fest.
|
|
||||||
private(set) var pendingCode: String?
|
|
||||||
private(set) var showsManualCode = false
|
|
||||||
|
|
||||||
/// Ob der Server in **demselben** Netz steht wie wir.
|
|
||||||
///
|
|
||||||
/// Entscheidet, ob „Auto" gedeckelt anfängt: zuhause trägt die Leitung
|
|
||||||
/// das Original, unterwegs nicht. Bei Plex steht es im gewählten Weg; bei
|
|
||||||
/// Jellyfin wird der Server 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
|
|
||||||
/// Welche Serverart gerade bedient wird — `nil`, solange niemand
|
|
||||||
/// angemeldet ist.
|
|
||||||
private(set) var kind: ServerKind?
|
|
||||||
|
|
||||||
/// Der aktive Server hinter dem neutralen Vertrag.
|
|
||||||
///
|
|
||||||
/// Die Ansichten sprechen ausschließlich hierüber. Ob dahinter Plex oder
|
|
||||||
/// Jellyfin steckt, sehen sie nicht — und genau deshalb musste für
|
|
||||||
/// Jellyfin keine einzige Ansicht neu geschrieben werden.
|
|
||||||
private(set) var backend: (any MediaBackend)?
|
|
||||||
|
|
||||||
/// Der Plex-Server, solange Plex aktiv ist.
|
|
||||||
///
|
|
||||||
/// Player, Live-TV und die Diagnose sprechen noch unmittelbar mit ihm.
|
|
||||||
/// Bei Jellyfin ist er `nil` — diese drei Bereiche wandern als Nächstes
|
|
||||||
/// auf den neutralen Vertrag.
|
|
||||||
private(set) var server: PlexServer?
|
|
||||||
|
|
||||||
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
|
|
||||||
private(set) var jellyfinAccount: JellyfinAccount?
|
|
||||||
|
|
||||||
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
|
||||||
///
|
|
||||||
/// Plex führt den Kontonamen am Konto, nicht am Server; dort steht er hier
|
|
||||||
/// noch nicht zur Verfügung, und deshalb bleibt die Zeile leer, statt
|
|
||||||
/// etwas Falsches zu behaupten.
|
|
||||||
var accountName: String? { jellyfinAccount?.userName }
|
|
||||||
|
|
||||||
/// Die Mediatheken, die Flixy 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? {
|
|
||||||
switch kind {
|
|
||||||
case .plex: server?.connection.uri
|
|
||||||
case .jellyfin: defaults.string(forKey: Key.jellyfinAddress)
|
|
||||||
case nil: nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wie die Verbindung zum Server zu beschreiben ist.
|
|
||||||
///
|
|
||||||
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
|
|
||||||
/// die Sitzung. Bei Plex ist es die entscheidende Angabe — über die
|
|
||||||
/// Vermittlung von plex.tv ist alles gedrosselt, und das erklärt die
|
|
||||||
/// Hälfte aller Ruckler, bevor man weitersucht.
|
|
||||||
var connectionDescription: String? {
|
|
||||||
switch kind {
|
|
||||||
case .plex:
|
|
||||||
guard let connection = server?.connection else { return nil }
|
|
||||||
if connection.local { return "Lokal · \(connection.uri)" }
|
|
||||||
if connection.relay { return "Plex-Vermittlung (gedrosselt)" }
|
|
||||||
return "Direkt von außen · \(connection.uri)"
|
|
||||||
case .jellyfin:
|
|
||||||
return serverAddress
|
|
||||||
case nil:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ob der aktive Server Live-TV anbietet.
|
|
||||||
///
|
|
||||||
/// Endlich die Fähigkeit selbst. Hier stand `server != nil` — also „nur
|
|
||||||
/// Plex" —, weil der Programmführer unmittelbar mit Plex sprach; einen
|
|
||||||
/// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine
|
|
||||||
/// Zusage gewesen, die niemand einlöst. Seit Live-TV über den neutralen
|
|
||||||
/// Vertrag läuft, löst sie jeder ein, der sie gibt.
|
|
||||||
var supportsLiveTV: Bool { backend?.capabilities.contains(.liveTV) == true }
|
|
||||||
|
|
||||||
/// Ob er darüber hinaus aufnehmen kann.
|
|
||||||
///
|
|
||||||
/// Getrennt gefragt, weil Plex das eine kann und das andere nicht.
|
|
||||||
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.
|
|
||||||
///
|
|
||||||
/// Bei Jellyfin ist die Anmeldung fertig, die Wiedergabe noch nicht — sie
|
|
||||||
/// ist der nächste Schritt. „Server nicht erreichbar" wäre an dieser
|
|
||||||
/// Stelle schlicht gelogen: er ist erreichbar, die App kann nur noch
|
|
||||||
/// nichts von ihm abspielen.
|
|
||||||
var playbackUnavailableMessage: String {
|
|
||||||
kind == .jellyfin
|
|
||||||
? String(localized: "player.jellyfinPending")
|
|
||||||
: String(localized: "error.serverUnreachable")
|
|
||||||
}
|
|
||||||
|
|
||||||
private let identity: PlexClientIdentity
|
|
||||||
private let jellyfinIdentity: JellyfinIdentity
|
|
||||||
private let tokenStore: any TokenStoring
|
|
||||||
private let authenticator: PlexAuthenticator
|
|
||||||
private let webAuthenticator = PlexWebAuthenticator()
|
|
||||||
private let defaults: UserDefaults
|
|
||||||
private var authorizationTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(
|
|
||||||
identity: PlexClientIdentity,
|
|
||||||
jellyfinIdentity: JellyfinIdentity,
|
|
||||||
tokenStore: any TokenStoring,
|
|
||||||
defaults: UserDefaults = .standard
|
|
||||||
) {
|
|
||||||
self.identity = identity
|
|
||||||
self.jellyfinIdentity = jellyfinIdentity
|
|
||||||
self.tokenStore = tokenStore
|
|
||||||
self.defaults = defaults
|
|
||||||
self.authenticator = PlexAuthenticator(identity: identity, tokenStore: tokenStore)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Start
|
|
||||||
|
|
||||||
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
|
|
||||||
func restore() async {
|
|
||||||
#if DEBUG
|
|
||||||
if await adoptStubServerIfRequested() { return }
|
|
||||||
#endif
|
|
||||||
|
|
||||||
switch storedKind {
|
|
||||||
case .jellyfin:
|
|
||||||
if await restoreJellyfin() { return }
|
|
||||||
phase = .signedOut
|
|
||||||
|
|
||||||
case .plex, nil:
|
|
||||||
guard let token = authenticator.storedToken else {
|
|
||||||
phase = .signedOut
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Wer vor dieser Version angemeldet war, hat keinen Eintrag über
|
|
||||||
// die Serverart — nachtragen, damit „nicht eingetragen" von hier
|
|
||||||
// an eindeutig „abgemeldet" heißt.
|
|
||||||
defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind)
|
|
||||||
|
|
||||||
// Erst der kurze Weg: die Verbindung, die beim letzten Mal
|
|
||||||
// funktioniert hat. Ohne ihn kostet jeder Start eine Anfrage an
|
|
||||||
// plex.tv plus eine neue Verbindungssuche — zusammen mehrere
|
|
||||||
// Sekunden, in denen nur ein Ladekreis zu sehen ist.
|
|
||||||
if await restoreFromRememberedConnection() { return }
|
|
||||||
|
|
||||||
await connect(accountToken: token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var storedKind: ServerKind? {
|
|
||||||
defaults.string(forKey: Key.serverKind).flatMap(ServerKind.init(rawValue:))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nimmt die zuletzt funktionierende Verbindung wieder auf.
|
|
||||||
///
|
|
||||||
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Server nicht
|
|
||||||
/// mehr darüber erreichbar ist — dann übernimmt die vollständige Suche.
|
|
||||||
/// Das ist der Fall, wenn man von zuhause weggeht: die lokale Adresse
|
|
||||||
/// antwortet nicht mehr, und der direkte Weg muss neu gefunden werden.
|
|
||||||
private func restoreFromRememberedConnection() async -> Bool {
|
|
||||||
guard let identifier = defaults.string(forKey: Key.preferredServer),
|
|
||||||
let uri = defaults.string(forKey: Key.cachedConnectionURI),
|
|
||||||
let serverToken = tokenStore.token(for: Self.plexTokenAccount)
|
|
||||||
else { return false }
|
|
||||||
|
|
||||||
let name = defaults.string(forKey: Key.cachedServerName) ?? ""
|
|
||||||
phase = .connecting(serverName: name)
|
|
||||||
|
|
||||||
let connection = PlexConnection(
|
|
||||||
uri: uri,
|
|
||||||
local: defaults.bool(forKey: Key.cachedConnectionIsLocal)
|
|
||||||
)
|
|
||||||
let resource = PlexResource(
|
|
||||||
name: name,
|
|
||||||
clientIdentifier: identifier,
|
|
||||||
accessToken: serverToken,
|
|
||||||
connections: [connection]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Kurze Zeitgrenze: wenn die gemerkte Adresse nicht sofort antwortet,
|
|
||||||
// ist die vollständige Suche schneller als weiteres Warten.
|
|
||||||
let selector = PlexConnectionSelector(identity: identity, probeTimeout: .seconds(2))
|
|
||||||
guard await selector.selectConnection(for: resource) != nil,
|
|
||||||
let server = PlexServer(resource: resource, connection: connection, identity: identity)
|
|
||||||
else { return false }
|
|
||||||
|
|
||||||
let plex = adopt(server: server)
|
|
||||||
do {
|
|
||||||
libraries = try await loadLibraries(from: plex)
|
|
||||||
phase = .ready
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
clearActiveServer()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Anmeldung bei Plex
|
|
||||||
|
|
||||||
/// Öffnet die Anmeldeseite von Plex.
|
|
||||||
///
|
|
||||||
/// Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem
|
|
||||||
/// Plex einen Token herausgibt. Sichtbar ist er nicht mehr.
|
|
||||||
///
|
|
||||||
/// Das Fenster wird geöffnet, aber **nicht abgewartet**: Plex führt den
|
|
||||||
/// Rücksprung über `forwardUrl` nicht zuverlässig aus. Auf dem iPad landet
|
|
||||||
/// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen
|
|
||||||
/// — der Token liegt längst bereit, aber die App wartete auf einen
|
|
||||||
/// Rückruf, der nie kam. Stattdessen wird hier im Sekundentakt beim Server
|
|
||||||
/// nachgefragt und nebenbei geprüft, ob das Fenster geschlossen wurde.
|
|
||||||
func beginSignIn() async {
|
|
||||||
phase = .authenticating
|
|
||||||
|
|
||||||
let pin: PlexPin
|
|
||||||
do {
|
|
||||||
pin = try await authenticator.requestPin()
|
|
||||||
} catch {
|
|
||||||
phase = .failed(message: ErrorText.describe(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let url = pin.linkURL else {
|
|
||||||
phase = .failed(message: String(localized: "signIn.windowFailed"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingCode = pin.code
|
|
||||||
showsManualCode = false
|
|
||||||
webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme)
|
|
||||||
|
|
||||||
// Die Frist entspricht der Gültigkeit des Codes bei Plex.
|
|
||||||
for second in 0..<900 {
|
|
||||||
try? await Task.sleep(for: .seconds(1))
|
|
||||||
|
|
||||||
// Nach einer Viertelminute den Code zeigen. Wer bis dahin nicht
|
|
||||||
// durch ist, hat vermutlich den Fall erwischt, in dem die Seite
|
|
||||||
// nicht verknüpft — dann hilft nur noch das Abtippen.
|
|
||||||
if second == 15 { showsManualCode = true }
|
|
||||||
|
|
||||||
if let token = try? await authenticator.token(for: pin) {
|
|
||||||
webAuthenticator.dismiss()
|
|
||||||
clearPendingCode()
|
|
||||||
await connect(accountToken: token)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fenster geschlossen, ohne dass ein Token da ist.
|
|
||||||
if webAuthenticator.didReturn {
|
|
||||||
switch webAuthenticator.outcome {
|
|
||||||
case .cancelled, nil:
|
|
||||||
// Abgebrochen oder zurückgesprungen, ohne dass der Token
|
|
||||||
// schon bereitlag. Noch ein paar Sekunden nachfragen,
|
|
||||||
// bevor aufgegeben wird — zwischen Rücksprung und Eintrag
|
|
||||||
// bei Plex liegt manchmal eine Sekunde.
|
|
||||||
if let token = try? await graceTokenLookup(pin: pin) {
|
|
||||||
clearPendingCode()
|
|
||||||
await connect(accountToken: token)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Fenster zu, kein Token: nicht abmelden, sondern den Code
|
|
||||||
// stehen lassen. Er gilt weiter, und das Abtippen bei
|
|
||||||
// plex.tv/link ist jetzt der schnellste Weg.
|
|
||||||
showsManualCode = true
|
|
||||||
case .presentationFailed(let description):
|
|
||||||
phase = .failed(message: description)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webAuthenticator.dismiss()
|
|
||||||
clearPendingCode()
|
|
||||||
phase = .failed(message: String(localized: "signIn.timedOut"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearPendingCode() {
|
|
||||||
pendingCode = nil
|
|
||||||
showsManualCode = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach.
|
|
||||||
private func graceTokenLookup(pin: PlexPin) async throws -> String? {
|
|
||||||
for _ in 0..<5 {
|
|
||||||
if let token = try? await authenticator.token(for: pin) { return token }
|
|
||||||
try? await Task.sleep(for: .milliseconds(700))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelSignIn() {
|
|
||||||
authorizationTask?.cancel()
|
|
||||||
authorizationTask = nil
|
|
||||||
webAuthenticator.dismiss()
|
|
||||||
clearPendingCode()
|
|
||||||
phase = .signedOut
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Meldet ab — von allen Servern, nicht nur vom gerade aktiven.
|
|
||||||
///
|
|
||||||
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen". Nur den
|
|
||||||
/// aktiven zu vergessen hieße, dass nach dem Abmelden beim nächsten Start
|
|
||||||
/// wieder eine Mediathek dasteht — die des anderen Servers.
|
|
||||||
func signOut() {
|
|
||||||
authorizationTask?.cancel()
|
|
||||||
authenticator.signOut()
|
|
||||||
forgetRememberedConnection()
|
|
||||||
forgetJellyfin()
|
|
||||||
defaults.removeObject(forKey: Key.serverKind)
|
|
||||||
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 Plex
|
|
||||||
|
|
||||||
private func connect(accountToken: String) async {
|
|
||||||
phase = .connecting(serverName: "")
|
|
||||||
|
|
||||||
let directory = PlexServerDirectory(identity: identity, token: accountToken)
|
|
||||||
let servers: [PlexResource]
|
|
||||||
do {
|
|
||||||
servers = try await directory.servers()
|
|
||||||
} catch PlexError.unauthorized {
|
|
||||||
// Der Token wurde am Konto zurückgezogen. Ihn zu behalten würde
|
|
||||||
// bei jedem Start denselben Fehler erzeugen.
|
|
||||||
authenticator.signOut()
|
|
||||||
phase = .signedOut
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
phase = .failed(message: ErrorText.describe(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !servers.isEmpty else {
|
|
||||||
phase = .failed(message: String(localized: "error.noServers"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beim zweiten Start soll niemand erneut wählen müssen.
|
|
||||||
let remembered = defaults.string(forKey: Key.preferredServer)
|
|
||||||
if let chosen = servers.first(where: { $0.clientIdentifier == remembered }) ?? servers.singleElement {
|
|
||||||
await connect(to: chosen)
|
|
||||||
} else {
|
|
||||||
phase = .choosingServer(servers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func connect(to resource: PlexResource) async {
|
|
||||||
phase = .connecting(serverName: resource.name)
|
|
||||||
|
|
||||||
let selector = PlexConnectionSelector(identity: identity)
|
|
||||||
guard let connection = await selector.selectConnection(for: resource) else {
|
|
||||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let server = PlexServer(
|
|
||||||
resource: resource,
|
|
||||||
connection: connection,
|
|
||||||
identity: identity
|
|
||||||
) else {
|
|
||||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let plex = adopt(server: server)
|
|
||||||
remember(resource: resource, connection: connection)
|
|
||||||
|
|
||||||
do {
|
|
||||||
libraries = try await loadLibraries(from: plex)
|
|
||||||
phase = .ready
|
|
||||||
} catch {
|
|
||||||
phase = .failed(message: ErrorText.describe(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merkt sich, worüber es funktioniert hat.
|
|
||||||
private func remember(resource: PlexResource, connection: PlexConnection) {
|
|
||||||
defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind)
|
|
||||||
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
|
|
||||||
defaults.set(connection.uri, forKey: Key.cachedConnectionURI)
|
|
||||||
defaults.set(connection.local, forKey: Key.cachedConnectionIsLocal)
|
|
||||||
isLocalConnection = connection.local
|
|
||||||
defaults.set(resource.name, forKey: Key.cachedServerName)
|
|
||||||
if let token = resource.accessToken {
|
|
||||||
try? tokenStore.store(token, for: Self.plexTokenAccount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vergisst die gemerkte Verbindung.
|
|
||||||
private func forgetRememberedConnection() {
|
|
||||||
for key in [Key.preferredServer, Key.cachedConnectionURI,
|
|
||||||
Key.cachedConnectionIsLocal, Key.cachedServerName] {
|
|
||||||
defaults.removeObject(forKey: key)
|
|
||||||
}
|
|
||||||
tokenStore.remove(for: Self.plexTokenAccount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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:
|
|
||||||
/// `-FlixyStubServer 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: "FlixyStubServer"),
|
|
||||||
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")
|
|
||||||
|
|
||||||
kind = .jellyfin
|
|
||||||
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()
|
|
||||||
kind = .jellyfin
|
|
||||||
jellyfinAccount = account
|
|
||||||
self.backend = backend
|
|
||||||
isLocalConnection = await backend.isInLocalNetwork()
|
|
||||||
|
|
||||||
do {
|
|
||||||
libraries = try await loadLibraries(from: backend)
|
|
||||||
} catch {
|
|
||||||
self.backend = nil
|
|
||||||
jellyfinAccount = nil
|
|
||||||
kind = 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()
|
|
||||||
kind = .jellyfin
|
|
||||||
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(ServerKind.jellyfin.rawValue, forKey: Key.serverKind)
|
|
||||||
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
|
|
||||||
|
|
||||||
/// Setzt Plex als aktiven Server und gibt die Hülle zurück.
|
|
||||||
///
|
|
||||||
/// Zurückgegeben, damit der Aufrufer sie benutzen kann, ohne sich ein
|
|
||||||
/// zweites Mal eine zu bauen — die trüge eine eigene Live-TV-Sitzung mit
|
|
||||||
/// sich, und dann wären zwei im Umlauf, die sich nichts voneinander
|
|
||||||
/// erzählen.
|
|
||||||
@discardableResult
|
|
||||||
private func adopt(server: PlexServer) -> PlexBackend {
|
|
||||||
let hülle = PlexBackend(server: server)
|
|
||||||
kind = .plex
|
|
||||||
self.server = server
|
|
||||||
backend = hülle
|
|
||||||
jellyfinAccount = nil
|
|
||||||
return hülle
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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() {
|
|
||||||
server = nil
|
|
||||||
backend = nil
|
|
||||||
jellyfinAccount = nil
|
|
||||||
kind = nil
|
|
||||||
libraries = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
|
|
||||||
func retry() async {
|
|
||||||
switch storedKind {
|
|
||||||
case .jellyfin:
|
|
||||||
if await restoreJellyfin() { return }
|
|
||||||
phase = .signedOut
|
|
||||||
case .plex, nil:
|
|
||||||
if let token = authenticator.storedToken {
|
|
||||||
await connect(accountToken: token)
|
|
||||||
} else {
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array {
|
|
||||||
/// Das einzige Element — oder `nil`, wenn es keines oder mehrere gibt.
|
|
||||||
var singleElement: Element? { count == 1 ? first : nil }
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
#if os(tvOS)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// **Auf dem Fernseher gibt es kein Anmeldefenster.**
|
|
||||||
///
|
|
||||||
/// `ASWebAuthenticationSession` steht im tvOS-SDK, aber fast alles daran ist
|
|
||||||
/// als nicht verfügbar markiert: kein `presentationContextProvider`, kein
|
|
||||||
/// `prefersEphemeralWebBrowserSession`, nicht einmal `cancel()`. Ein
|
|
||||||
/// Fernseher hat auch keinen Browser, in dem sich jemand bei Plex anmelden
|
|
||||||
/// würde — und Plex ist in der ersten Fassung ohnehin nicht dabei.
|
|
||||||
///
|
|
||||||
/// **Der Typ bleibt trotzdem stehen.** `FlixySession` hält ihn als Feld und
|
|
||||||
/// ruft ihn an sechs Stellen; ihn herauszuschneiden hiesse, die
|
|
||||||
/// Zustandsmaschine der Anmeldung für eine Plattform zu spalten, die diesen
|
|
||||||
/// Zweig nie betritt. Dieselbe Bauform wie bei `OrientationLock`: derselbe
|
|
||||||
/// Name, dieselben Aufrufe, und nichts passiert.
|
|
||||||
@MainActor
|
|
||||||
final class PlexWebAuthenticator {
|
|
||||||
enum Failure: Error, Equatable {
|
|
||||||
case cancelled
|
|
||||||
case presentationFailed(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immer „ging nicht": wer hier landet, soll nicht warten.
|
|
||||||
private(set) var outcome: Failure?
|
|
||||||
private(set) var didReturn = false
|
|
||||||
|
|
||||||
func present(url: URL, callbackScheme: String) {
|
|
||||||
outcome = .presentationFailed(String(localized: "signIn.windowFailed"))
|
|
||||||
didReturn = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func dismiss() {}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
import AuthenticationServices
|
|
||||||
import Foundation
|
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
|
||||||
#else
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Öffnet die Anmeldeseite von Plex in einem Fenster innerhalb der App.
|
|
||||||
///
|
|
||||||
/// `ASWebAuthenticationSession` ist dafür der vorgesehene Weg: die Seite kommt
|
|
||||||
/// von Plex, das Passwort wird dort eingegeben und erreicht die App nie. Nach
|
|
||||||
/// dem Anmelden schickt Plex den Browser auf eine Adresse mit unserem eigenen
|
|
||||||
/// Schema, das Fenster schließt sich von selbst, und die App weiß, dass sie
|
|
||||||
/// den Token abholen kann.
|
|
||||||
///
|
|
||||||
/// Ein einfacher `SFSafariViewController` täte es nicht: er meldet nicht, wann
|
|
||||||
/// die Rückadresse erreicht wurde, und müsste von Hand geschlossen werden.
|
|
||||||
@MainActor
|
|
||||||
final class PlexWebAuthenticator: NSObject {
|
|
||||||
enum Failure: Error, Equatable {
|
|
||||||
/// Der Nutzer hat das Fenster geschlossen. Kein Fehler im eigentlichen
|
|
||||||
/// Sinn — die Oberfläche soll darauf kommentarlos zurückgehen.
|
|
||||||
case cancelled
|
|
||||||
case presentationFailed(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Das laufende Fenster — damit es sich auch von außen schließen lässt.
|
|
||||||
///
|
|
||||||
/// Nötig, weil Plex den Rücksprung nicht immer ausführt: manchmal landet
|
|
||||||
/// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen.
|
|
||||||
/// Der Token liegt dann längst bereit, nur das Fenster weiß es nicht.
|
|
||||||
private var session: ASWebAuthenticationSession?
|
|
||||||
|
|
||||||
/// Wie das Fenster ausgegangen ist.
|
|
||||||
///
|
|
||||||
/// `nil`, solange es noch offen ist.
|
|
||||||
private(set) var outcome: Failure?
|
|
||||||
private(set) var didReturn = false
|
|
||||||
|
|
||||||
/// Öffnet das Anmeldefenster und kehrt **sofort** zurück.
|
|
||||||
///
|
|
||||||
/// Bewusst nicht abwartend: Plex führt den Rücksprung über `forwardUrl`
|
|
||||||
/// nicht zuverlässig aus. Auf dem iPad landet der Browser nach dem
|
|
||||||
/// Anmelden auf watch.plex.tv und bleibt stehen — der Token liegt längst
|
|
||||||
/// bereit, aber ein abwartender Aufruf hinge auf einen Rückruf, der nie
|
|
||||||
/// kommt. Der Aufrufer fragt deshalb parallel beim Server nach und sieht
|
|
||||||
/// hier nur nach, ob das Fenster zwischendurch geschlossen wurde.
|
|
||||||
func present(url: URL, callbackScheme: String) {
|
|
||||||
outcome = nil
|
|
||||||
didReturn = false
|
|
||||||
|
|
||||||
let session = ASWebAuthenticationSession(
|
|
||||||
url: url,
|
|
||||||
callbackURLScheme: callbackScheme
|
|
||||||
) { [weak self] _, error in
|
|
||||||
// ASWebAuthenticationSession ruft den Abschlussblock auf dem
|
|
||||||
// Hauptthread auf — anders als VLCKit, wo dieselbe Annahme die App
|
|
||||||
// zum Absturz gebracht hat. Hier ist sie dokumentiert.
|
|
||||||
MainActor.assumeIsolated {
|
|
||||||
guard let self else { return }
|
|
||||||
if let error = error as? ASWebAuthenticationSessionError,
|
|
||||||
error.code == .canceledLogin {
|
|
||||||
self.outcome = .cancelled
|
|
||||||
} else if let error {
|
|
||||||
self.outcome = .presentationFailed(error.localizedDescription)
|
|
||||||
}
|
|
||||||
self.didReturn = true
|
|
||||||
self.session = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session.presentationContextProvider = self
|
|
||||||
// `false`, damit eine bestehende Plex-Anmeldung in Safari mitgenutzt
|
|
||||||
// wird: wer dort schon angemeldet ist, braucht nur zu bestätigen.
|
|
||||||
session.prefersEphemeralWebBrowserSession = false
|
|
||||||
|
|
||||||
guard session.start() else {
|
|
||||||
outcome = .presentationFailed(String(localized: "signIn.windowFailed"))
|
|
||||||
didReturn = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.session = session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PlexWebAuthenticator {
|
|
||||||
/// Schließt das Anmeldefenster.
|
|
||||||
///
|
|
||||||
/// Wird gerufen, sobald der Token auf anderem Weg eingetroffen ist — sonst
|
|
||||||
/// bliebe der Browser mit der Plex-Seite offen stehen, obwohl die App
|
|
||||||
/// schon angemeldet ist.
|
|
||||||
func dismiss() {
|
|
||||||
session?.cancel()
|
|
||||||
session = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PlexWebAuthenticator: ASWebAuthenticationPresentationContextProviding {
|
|
||||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
|
||||||
// Das Fenster der App. Gesucht statt gehalten, weil eine SwiftUI-App
|
|
||||||
// keinen festen Verweis darauf hat — und ein frisch erzeugtes Fenster
|
|
||||||
// erschiene hinter dem sichtbaren.
|
|
||||||
#if canImport(UIKit)
|
|
||||||
let scene = UIApplication.shared.connectedScenes
|
|
||||||
.compactMap { $0 as? UIWindowScene }
|
|
||||||
.first { $0.activationState == .foregroundActive }
|
|
||||||
|
|
||||||
return scene?.keyWindow ?? ASPresentationAnchor()
|
|
||||||
#else
|
|
||||||
return NSApplication.shared.keyWindow
|
|
||||||
?? NSApplication.shared.windows.first
|
|
||||||
?? ASPresentationAnchor()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import FlixyDesign
|
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Während ein Weg zum Server gesucht wird.
|
|
||||||
struct ConnectingView: View {
|
|
||||||
let serverName: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: FlixySpacing.loose) {
|
|
||||||
ProgressView()
|
|
||||||
Text(serverName.isEmpty
|
|
||||||
? String(localized: "connecting.searching")
|
|
||||||
: String(localized: "connecting.to \(serverName)"))
|
|
||||||
.font(.flixyBody)
|
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Color.flixyCanvas)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wenn am Konto mehrere Server hängen.
|
|
||||||
struct ServerPickerView: View {
|
|
||||||
let servers: [PlexResource]
|
|
||||||
let onSelect: (PlexResource) -> Void
|
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List(servers) { server in
|
|
||||||
Button {
|
|
||||||
onSelect(server)
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
|
||||||
Image(systemName: server.owned ? "server.rack" : "person.2")
|
|
||||||
.foregroundStyle(accent)
|
|
||||||
.frame(width: 28)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(server.name)
|
|
||||||
.font(.flixyCardTitle)
|
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
|
||||||
Text(subtitle(for: server))
|
|
||||||
.font(.flixyCaption)
|
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.flixyCaption)
|
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
|
||||||
}
|
|
||||||
.contentShape(.rect)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.listRowBackground(Color.flixySurface)
|
|
||||||
}
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.flixyCanvas)
|
|
||||||
.navigationTitle(Text("serverPicker.title"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func subtitle(for server: PlexResource) -> String {
|
|
||||||
var pieces: [String] = []
|
|
||||||
pieces.append(server.owned
|
|
||||||
? String(localized: "serverPicker.owned")
|
|
||||||
: String(localized: "serverPicker.shared"))
|
|
||||||
if let platform = server.platform { pieces.append(platform) }
|
|
||||||
if let version = server.productVersion { pieces.append(version) }
|
|
||||||
return pieces.joined(separator: " · ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.flixyTextSecondary)
|
|
||||||
}
|
|
||||||
.background(Color.flixyCanvas)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import FlixyDesign
|
|
||||||
|
|
||||||
/// Der Anmeldebildschirm.
|
|
||||||
///
|
|
||||||
/// Zwei Wege, weil es zwei Serverarten gibt. **Plex** öffnet seine eigene
|
|
||||||
/// Anmeldemaske in einem Fenster innerhalb der App; das Passwort wird dort
|
|
||||||
/// eingegeben und erreicht Flixy nie. **Jellyfin** hat kein Verzeichnis wie
|
|
||||||
/// plex.tv — dort wird die Adresse des eigenen Servers eingetippt und, wann
|
|
||||||
/// immer der Server es anbietet, ein Code bestätigt statt eines Passworts.
|
|
||||||
///
|
|
||||||
/// Die beiden Wege sind absichtlich nicht zu einem zusammengefasst. Sie haben
|
|
||||||
/// nichts gemeinsam außer dem Ziel, und der Versuch, das zu verstecken, würde
|
|
||||||
/// beide verschlechtern.
|
|
||||||
struct SignInView: View {
|
|
||||||
let phase: FlixySession.Phase
|
|
||||||
/// Der Code der laufenden Plex-Anmeldung, sobald er gezeigt werden soll.
|
|
||||||
let manualCode: String?
|
|
||||||
let onSignIn: () -> Void
|
|
||||||
let onCancel: () -> Void
|
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
|
||||||
@State private var showsJellyfinSignIn = false
|
|
||||||
|
|
||||||
private var isAuthenticating: Bool {
|
|
||||||
if case .authenticating = phase { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: FlixySpacing.section) {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
VStack(spacing: FlixySpacing.regular) {
|
|
||||||
Image(systemName: "play.rectangle.on.rectangle")
|
|
||||||
.font(.system(size: 52))
|
|
||||||
.foregroundStyle(accent)
|
|
||||||
|
|
||||||
Text(Branding.appName)
|
|
||||||
.font(.flixyHero)
|
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
|
||||||
|
|
||||||
Text("signIn.tagline")
|
|
||||||
.font(.flixyBody)
|
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
VStack(spacing: FlixySpacing.regular) {
|
|
||||||
Button(action: onSignIn) {
|
|
||||||
Group {
|
|
||||||
if isAuthenticating {
|
|
||||||
ProgressView().tint(Color.flixyCanvas)
|
|
||||||
} else {
|
|
||||||
Text("signIn.withPlex").font(.flixyRowHeader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(accent)
|
|
||||||
.disabled(isAuthenticating)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showsJellyfinSignIn = true
|
|
||||||
} label: {
|
|
||||||
Text("signIn.withJellyfin")
|
|
||||||
.font(.flixyRowHeader)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.tint(Color.flixyTextSecondary)
|
|
||||||
.disabled(isAuthenticating)
|
|
||||||
|
|
||||||
if let manualCode {
|
|
||||||
manualFallback(code: manualCode)
|
|
||||||
} else {
|
|
||||||
Text("signIn.privacy")
|
|
||||||
.font(.flixyCaption)
|
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer().frame(height: FlixySpacing.section)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, FlixySpacing.section)
|
|
||||||
.frame(maxWidth: 420)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Color.flixyCanvas)
|
|
||||||
.sheet(isPresented: $showsJellyfinSignIn) {
|
|
||||||
JellyfinSignInView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Der Ausweg, wenn die Browser-Seite den Code nicht verknüpft.
|
|
||||||
///
|
|
||||||
/// Das passiert, wenn man dort schon bei Plex angemeldet ist: die Seite
|
|
||||||
/// springt weiter, ohne zu verknüpfen, und die App wartete vergeblich.
|
|
||||||
/// Der Code gilt weiter — abtippen führt immer zum Ziel.
|
|
||||||
@ViewBuilder
|
|
||||||
private func manualFallback(code: String) -> some View {
|
|
||||||
VStack(spacing: FlixySpacing.regular) {
|
|
||||||
Divider().overlay(Color.flixySeparator)
|
|
||||||
|
|
||||||
Text("signIn.manualHint")
|
|
||||||
.font(.flixyCaption)
|
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Text(code)
|
|
||||||
.font(.system(.title, design: .monospaced, weight: .semibold))
|
|
||||||
.tracking(6)
|
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
|
||||||
|
|
||||||
Button("action.cancel", action: onCancel)
|
|
||||||
.font(.flixyCaption)
|
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
|
||||||
}
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Diesen Arbeitsbereich öffnen, nicht das Projekt
|
# Diesen Arbeitsbereich öffnen, nicht das Projekt
|
||||||
|
|
||||||
`Flixy.xcodeproj` wird von XcodeGen aus `project.yml` erzeugt — und dabei
|
`Halo.xcodeproj` wird von XcodeGen aus `project.yml` erzeugt — und dabei
|
||||||
**ersetzt**, nicht bearbeitet: jede Datei darin bekommt einen neuen Inode,
|
**ersetzt**, nicht bearbeitet: jede Datei darin bekommt einen neuen Inode,
|
||||||
auch die, deren Inhalt gleich bleibt. Hat Xcode den Arbeitsbereich **im**
|
auch die, deren Inhalt gleich bleibt. Hat Xcode den Arbeitsbereich **im**
|
||||||
Projekt offen (`Flixy.xcodeproj/project.xcworkspace`), meldet es danach
|
Projekt offen (`Halo.xcodeproj/project.xcworkspace`), meldet es danach
|
||||||
|
|
||||||
> The workspace file … has disappeared. Do you want to re-save the container,
|
> The workspace file … has disappeared. Do you want to re-save the container,
|
||||||
> or close it?
|
> or close it?
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Workspace version = "1.0">
|
<Workspace version = "1.0">
|
||||||
<FileRef location = "group:Flixy.xcodeproj"></FileRef>
|
<FileRef location = "group:Halo.xcodeproj"></FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
Before Width: | Height: | Size: 675 B After Width: | Height: | Size: 675 B |
@@ -9,42 +9,21 @@ import Foundation
|
|||||||
///
|
///
|
||||||
/// Der Name steht noch nicht fest. Damit ein späterer Wechsel nicht zur Suche
|
/// 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
|
/// quer durch den Quelltext wird, greift die App nirgends auf ein hart
|
||||||
/// geschriebenes „Flixy" zu, sondern immer hierauf. Zu ändern sind dann genau
|
/// geschriebenes „Halo" zu, sondern immer hierauf. Zu ändern sind dann genau
|
||||||
/// zwei Dinge: diese Datei und `project.yml` (Produktname, Bundle-Kennung,
|
/// zwei Dinge: diese Datei und `project.yml` (Produktname, Bundle-Kennung,
|
||||||
/// Anzeigename).
|
/// Anzeigename).
|
||||||
///
|
///
|
||||||
/// Die Typen aus FlixyKit behalten ihre Namen — sie sind Bauteile, keine Marke.
|
/// Die Typen aus HaloKit behalten ihre Namen — sie sind Bauteile, keine Marke.
|
||||||
enum Branding {
|
enum Branding {
|
||||||
/// Wie die App sich selbst nennt.
|
/// Wie die App sich selbst nennt.
|
||||||
static let appName = "Flixy"
|
static let appName = "Halo"
|
||||||
|
|
||||||
/// Unter welchem Namen die App in der Geräteliste des Plex-Kontos steht.
|
|
||||||
///
|
|
||||||
/// Wandert in die Kopfzeile `X-Plex-Product`. Eine Änderung hier legt beim
|
|
||||||
/// nächsten Anmelden einen neuen Eintrag an — der alte bleibt als Karteileiche
|
|
||||||
/// stehen, bis er von Hand entfernt wird.
|
|
||||||
static let plexProduct = appName
|
|
||||||
|
|
||||||
/// Unter welchem Namen die Tokens im Schlüsselbund liegen.
|
/// Unter welchem Namen die Tokens im Schlüsselbund liegen.
|
||||||
///
|
///
|
||||||
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
|
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
|
||||||
/// Umbenennung der App soll niemanden abmelden.
|
/// Umbenennung der App soll niemanden abmelden.
|
||||||
static var keychainService: String {
|
static var keychainService: String {
|
||||||
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flixy"
|
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.halo"
|
||||||
}
|
|
||||||
|
|
||||||
/// Das eigene URL-Schema, über das Plex nach der Anmeldung zurückschickt.
|
|
||||||
///
|
|
||||||
/// Die Bundle-Kennung statt eines kurzen Wortes wie „flixy": sie ist
|
|
||||||
/// garantiert eindeutig. Bei einem kurzen Schema könnte eine andere App
|
|
||||||
/// denselben Namen beanspruchen und die Anmeldung abfangen.
|
|
||||||
static var urlScheme: String {
|
|
||||||
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flixy"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wohin Plex nach der Anmeldung zurückschickt.
|
|
||||||
static var authCallbackURL: URL {
|
|
||||||
URL(string: "\(urlScheme)://auth")!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wann dieser Build entstanden ist.
|
/// Wann dieser Build entstanden ist.
|
||||||
@@ -3,15 +3,14 @@ import SwiftUI
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct FlixyApp: App {
|
struct HaloApp: App {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von
|
/// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von
|
||||||
/// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen
|
/// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen
|
||||||
@@ -24,8 +23,8 @@ struct FlixyApp: App {
|
|||||||
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
|
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
|
||||||
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
|
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
|
||||||
/// Downloads zurücksetzen.
|
/// Downloads zurücksetzen.
|
||||||
@State private var theme = FlixyTheme()
|
@State private var theme = HaloTheme()
|
||||||
@State private var session: FlixySession
|
@State private var session: HaloSession
|
||||||
@State private var playback = PlaybackCoordinator()
|
@State private var playback = PlaybackCoordinator()
|
||||||
@State private var playbackSettings = PlaybackSettings()
|
@State private var playbackSettings = PlaybackSettings()
|
||||||
@State private var languages = LanguagePreferences()
|
@State private var languages = LanguagePreferences()
|
||||||
@@ -41,12 +40,7 @@ struct FlixyApp: App {
|
|||||||
private let modelContainer: ModelContainer
|
private let modelContainer: ModelContainer
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let identity = PlexClientIdentity.current(
|
_session = State(initialValue: HaloSession(
|
||||||
product: Branding.plexProduct,
|
|
||||||
version: Branding.version
|
|
||||||
)
|
|
||||||
_session = State(initialValue: FlixySession(
|
|
||||||
identity: identity,
|
|
||||||
jellyfinIdentity: JellyfinIdentity.current(
|
jellyfinIdentity: JellyfinIdentity.current(
|
||||||
client: Branding.appName,
|
client: Branding.appName,
|
||||||
version: Branding.version
|
version: Branding.version
|
||||||
@@ -80,8 +74,8 @@ struct FlixyApp: App {
|
|||||||
// Notizzettelgröße, und darin fällt der Aufmacher in sich
|
// Notizzettelgröße, und darin fällt der Aufmacher in sich
|
||||||
// zusammen, weil seine Abstände dehnbar sind.
|
// zusammen, weil seine Abstände dehnbar sind.
|
||||||
.frame(
|
.frame(
|
||||||
minWidth: FlixyPlatform.isMac ? 900 : nil,
|
minWidth: HaloPlatform.isMac ? 900 : nil,
|
||||||
minHeight: FlixyPlatform.isMac ? 620 : nil
|
minHeight: HaloPlatform.isMac ? 620 : nil
|
||||||
)
|
)
|
||||||
// `defaultSize` gibt es hier nicht; die Größe hängt an der
|
// `defaultSize` gibt es hier nicht; die Größe hängt an der
|
||||||
// Szene.
|
// Szene.
|
||||||
@@ -139,7 +133,7 @@ struct FlixyApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
extension FlixyApp {
|
extension HaloApp {
|
||||||
/// Gibt dem Fenster eine vernünftige Mindestgröße.
|
/// Gibt dem Fenster eine vernünftige Mindestgröße.
|
||||||
///
|
///
|
||||||
/// Auf dem Mac öffnet das System sonst ein Fenster von der Größe eines
|
/// Auf dem Mac öffnet das System sonst ein Fenster von der Größe eines
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Flixy</string>
|
<string>Halo</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -18,17 +18,6 @@
|
|||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleURLTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>com.scarriffleservices.flixy.auth</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
|
||||||
<array>
|
|
||||||
<string>com.scarriffleservices.flixy</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
@@ -39,7 +28,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Plex-Server zu finden und Medien direkt von ihm zu laden.</string>
|
<string>Halo benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen und Medien direkt von ihm zu laden.</string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
@@ -888,39 +888,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"diagnostics.liveTV": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Live-TV-Antwort ansehen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Inspect Live TV response"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics.liveTVHint": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Zeigt den Aufbau der Serverantwort — nur Schlüssel, Anzahl und Sendernamen, keine Zugangsdaten. Hilft dabei zu finden, wo die Sender stehen."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Shows the structure of the server's response — keys, counts and channel names only, no credentials. Helps locate where the channels are."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics.method": {
|
"diagnostics.method": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -970,23 +937,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"diagnostics.relayWarning": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Via the Plex relay. Heavily throttled — large files barely start over it."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics.source": {
|
"diagnostics.source": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -1087,6 +1037,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"downloads.audio.hint": {
|
||||||
|
"comment": "Erklärt, warum die Wahl jetzt fällt",
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Beim Umpacken kommt genau eine mit — danach lässt sie sich nicht mehr wechseln."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Repackaging keeps exactly one — it cannot be changed afterwards."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads.audio.recommended %@": {
|
||||||
|
"comment": "Markiert den Vorschlag",
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%@ · empfohlen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%@ · recommended"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads.audio.title": {
|
||||||
|
"comment": "Überschrift der Tonspurwahl beim Herunterladen",
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Welche Tonspur?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Which audio track?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloads.available": {
|
"downloads.available": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -1747,23 +1751,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error.noServers": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "An deinem Plex-Konto hängt kein Server."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "There's no server on your Plex account."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error.notFound": {
|
"error.notFound": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -1855,13 +1842,13 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Die Antwort des Servers war unerwartet. Möglicherweise ist eine neuere Plex-Version installiert."
|
"value": "Die Antwort des Servers war unerwartet. Möglicherweise läuft dort eine neuere Fassung, als diese App kennt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "The server's response was unexpected. It may be running a newer version of Plex."
|
"value": "The server's response was unexpected. It may be running a newer version than this app knows."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3317,13 +3304,13 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Plex-Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier."
|
"value": "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Live TV needs a tuner card or network tuner on your Plex server. Set one up in the server settings and channels will appear here."
|
"value": "Live TV needs a tuner card or network tuner on your server. Set one up in the server settings and channels will appear here."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3968,6 +3955,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.pictureInPicture": {
|
||||||
|
"comment": "Knopf im Player: das Bild in ein kleines Fenster legen",
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Bild im Bild"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Picture in Picture"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.play": {
|
"player.play": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -4086,24 +4091,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.relayWarning": {
|
|
||||||
"comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft",
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"player.rememberedTrack": {
|
"player.rememberedTrack": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -4346,13 +4333,13 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Plex kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus."
|
"value": "Der Server kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Plex has no audio-only stream for video. This setting sends the picture at the smallest size and hides it."
|
"value": "The server has no audio-only stream for video. This setting sends the picture at the smallest size and hides it."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5070,56 +5057,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serverPicker.owned": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Eigener Server"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Your server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"serverPicker.shared": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Geteilt"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Shared"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"serverPicker.title": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Server wählen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Choose a server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings.account": {
|
"settings.account": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -5323,23 +5260,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings.connectionRelay": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Über Plex-Vermittlung (gedrosselt)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Via Plex relay (throttled)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings.connectionRemote": {
|
"settings.connectionRemote": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -5727,13 +5647,13 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Die Obergrenze, nicht das Ziel. Im eigenen Netz überträgt der Server das Original; unterwegs beginnt Flixy kleiner und tastet sich hinauf, solange die Verbindung es trägt — höchstens bis zu dieser Stufe. Auflösung und Bitrate gehen dabei zusammen."
|
"value": "Die Obergrenze, nicht das Ziel. Im eigenen Netz überträgt der Server das Original; unterwegs beginnt Halo kleiner und tastet sich hinauf, solange die Verbindung es trägt — höchstens bis zu dieser Stufe. Auflösung und Bitrate gehen dabei zusammen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "An upper limit, not a target. On your own network the server sends the original; away from home Flixy starts lower and works its way up as long as the connection holds — never above this step. Resolution and bitrate move together."
|
"value": "An upper limit, not a target. On your own network the server sends the original; away from home Halo starts lower and works its way up as long as the connection holds — never above this step. Resolution and bitrate move together."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5926,23 +5846,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings.serverKind": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Art"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Type"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings.serverName": {
|
"settings.serverName": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -6698,35 +6601,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signIn.manualHint": {
|
|
||||||
"comment": "Hinweis, wenn die Browser-Verknüpfung nicht greift",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Hat das Fenster nicht verknüpft? Öffne plex.tv/link und gib diesen Code ein:"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Window didn't link? Open plex.tv/link and enter this code:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"signIn.privacy": {
|
"signIn.privacy": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Die Anmeldung läuft über deinen Server. Dein Passwort erreicht diese App nie."
|
"value": "Die Anmeldung läuft unmittelbar mit deinem Server. Kein fremder Dienst ist daran beteiligt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Sign-in happens on your server. Your password never reaches this app."
|
"value": "You sign in directly with your own server. No third-party service is involved."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6748,41 +6634,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signIn.timedOut": {
|
|
||||||
"comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen",
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Sign-in wasn't completed. Please try again."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"signIn.windowFailed": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Das Anmeldefenster ließ sich nicht öffnen."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "The sign-in window couldn't be opened."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"signIn.withJellyfin": {
|
"signIn.withJellyfin": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -6799,22 +6650,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signIn.withPlex": {
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Mit Plex anmelden"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Sign in with Plex"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sort.lastPlayed": {
|
"sort.lastPlayed": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -7308,23 +7143,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"technical.directPlayVLC": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Direkt (VLC)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Direct (VLC)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"technical.file": {
|
"technical.file": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
|
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
|
import HaloPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Übersetzt Titel vom Server in Download-Aufträge.
|
/// Übersetzt Titel vom Server in Download-Aufträge.
|
||||||
@@ -81,6 +82,16 @@ enum DownloadQuality: String, CaseIterable, Identifiable {
|
|||||||
struct DownloadService {
|
struct DownloadService {
|
||||||
let backend: (any MediaBackend)?
|
let backend: (any MediaBackend)?
|
||||||
let coordinator: DownloadCoordinator
|
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.
|
/// Baut den Auftrag für einen einzelnen Titel.
|
||||||
///
|
///
|
||||||
@@ -91,7 +102,8 @@ struct DownloadService {
|
|||||||
func plan(
|
func plan(
|
||||||
for item: MediaItem,
|
for item: MediaItem,
|
||||||
mediaIndex: Int = 0,
|
mediaIndex: Int = 0,
|
||||||
quality: DownloadQuality = .original
|
quality: DownloadQuality = .original,
|
||||||
|
audioStream: MediaStream? = nil
|
||||||
) throws(DownloadRefusal) -> DownloadPlan {
|
) throws(DownloadRefusal) -> DownloadPlan {
|
||||||
guard let backend else {
|
guard let backend else {
|
||||||
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
|
||||||
@@ -105,8 +117,32 @@ struct DownloadService {
|
|||||||
guard let part = media.files.first else {
|
guard let part = media.files.first else {
|
||||||
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
|
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?
|
let url: URL?
|
||||||
switch quality {
|
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:
|
case .original:
|
||||||
url = backend.downloadURL(for: part)
|
url = backend.downloadURL(for: part)
|
||||||
case .fullHD, .hd, .low:
|
case .fullHD, .hd, .low:
|
||||||
@@ -143,8 +179,11 @@ struct DownloadService {
|
|||||||
// den Metadaten des Servers. Die umgerechneten Stufen sind aus
|
// den Metadaten des Servers. Die umgerechneten Stufen sind aus
|
||||||
// Bitrate und Dauer hochgerechnet — und was hochgerechnet ist,
|
// Bitrate und Dauer hochgerechnet — und was hochgerechnet ist,
|
||||||
// darf abweichen, ohne dass die Datei kaputt wäre.
|
// darf abweichen, ohne dass die Datei kaputt wäre.
|
||||||
expectedIsExact: quality == .original && part.size != nil,
|
// Umgepackt stimmt die Größe des Servers nicht mehr: dieselben
|
||||||
offlineMedia: offlineMedia(for: item, media: media, part: part)
|
// 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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +192,30 @@ struct DownloadService {
|
|||||||
func download(
|
func download(
|
||||||
_ item: MediaItem,
|
_ item: MediaItem,
|
||||||
mediaIndex: Int = 0,
|
mediaIndex: Int = 0,
|
||||||
quality: DownloadQuality = .original
|
quality: DownloadQuality = .original,
|
||||||
|
audioStream: MediaStream? = nil
|
||||||
) throws(DownloadRefusal) -> DownloadItem {
|
) throws(DownloadRefusal) -> DownloadItem {
|
||||||
try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex, quality: quality))
|
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.
|
/// Was ein Stapel ergeben hat.
|
||||||
@@ -186,7 +246,8 @@ struct DownloadService {
|
|||||||
/// stillschweigend.
|
/// stillschweigend.
|
||||||
func download(
|
func download(
|
||||||
_ episodes: [MediaItem],
|
_ episodes: [MediaItem],
|
||||||
quality: DownloadQuality = .original
|
quality: DownloadQuality = .original,
|
||||||
|
audioStreamIndex: Int? = nil
|
||||||
) async -> BatchResult {
|
) async -> BatchResult {
|
||||||
guard let backend else { return (0, [], 0) }
|
guard let backend else { return (0, [], 0) }
|
||||||
|
|
||||||
@@ -205,7 +266,16 @@ struct DownloadService {
|
|||||||
let detailed = (try? await backend.item(id: episode.id)) ?? episode
|
let detailed = (try? await backend.item(id: episode.id)) ?? episode
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try coordinator.enqueue(try plan(for: detailed, quality: quality))
|
// 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
|
accepted += 1
|
||||||
} catch DownloadRefusal.noDownloadableFile {
|
} catch DownloadRefusal.noDownloadableFile {
|
||||||
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
||||||
@@ -239,15 +309,58 @@ struct DownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Der Abzug der Serverangaben, den der Player offline braucht.
|
/// Der Abzug der Serverangaben, den der Player offline braucht.
|
||||||
private func offlineMedia(for item: MediaItem, media: MediaVersion, part: MediaFile) -> OfflineMedia {
|
/// Welche Tonspur vorgeschlagen wird.
|
||||||
OfflineMedia(
|
///
|
||||||
|
/// **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,
|
container: part.container ?? media.container,
|
||||||
videoCodec: media.videoCodec,
|
videoCodec: media.videoCodec,
|
||||||
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec,
|
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,
|
videoResolution: media.videoResolution,
|
||||||
bitrate: media.bitrate,
|
bitrate: media.bitrate,
|
||||||
audioLanguages: part.audioStreams.compactMap(\.language),
|
// **Beim Umpacken bleibt je eine übrig.** Alle zu notieren wäre
|
||||||
subtitleLanguages: part.subtitleStreams.compactMap(\.language),
|
// 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 {
|
markers: item.markers.map {
|
||||||
OfflineMarker(
|
OfflineMarker(
|
||||||
type: $0.kind.rawValue,
|
type: $0.kind.rawValue,
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import Foundation
|
||||||
|
import JellyfinKit
|
||||||
|
import MediaKit
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Der Zustand der Verbindung zum Server.
|
||||||
|
///
|
||||||
|
/// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß,
|
||||||
|
/// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort,
|
||||||
|
/// an dem sich entscheidet, was auf dem Bildschirm erscheint.
|
||||||
|
///
|
||||||
|
/// Seit es zwei Serverarten gibt, hält diese Klasse **einen** aktiven Server,
|
||||||
|
/// nicht zwei. Zwei gleichzeitig hießen: zwei Startseiten, zwei Mediatheken,
|
||||||
|
/// zwei Suchergebnisse — und bei jedem Titel die Frage, woher er stammt.
|
||||||
|
/// Umschalten ja, nebeneinander nein.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class HaloSession {
|
||||||
|
enum Phase: Equatable {
|
||||||
|
/// Beim Start: liegt ein Token im Schlüsselbund?
|
||||||
|
case launching
|
||||||
|
case signedOut
|
||||||
|
/// Ein Weg zum Server wird gesucht.
|
||||||
|
case connecting(serverName: String)
|
||||||
|
case ready
|
||||||
|
case failed(message: String)
|
||||||
|
// Kein eigener ==-Operator mehr: er war nur nötig, weil `PlexResource`
|
||||||
|
// nicht `Equatable` war und über die Kennung verglichen werden musste.
|
||||||
|
// Alle verbliebenen Nutzlasten sind `String`, also leitet der Compiler
|
||||||
|
// die Gleichheit selbst ab — und ein künftiger Fall wird nicht
|
||||||
|
// stillschweigend als „ungleich" behandelt.
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let jellyfinAddress = "halo.jellyfin.address"
|
||||||
|
static let jellyfinUserID = "halo.jellyfin.userID"
|
||||||
|
static let jellyfinUserName = "halo.jellyfin.userName"
|
||||||
|
static let jellyfinServerName = "halo.jellyfin.serverName"
|
||||||
|
static let jellyfinIsAdministrator = "halo.jellyfin.isAdministrator"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
|
||||||
|
///
|
||||||
|
/// Nicht in den Voreinstellungen: es gibt vollen Zugriff auf die
|
||||||
|
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
|
||||||
|
/// später daneben passt, ohne diesen zu verdrängen.
|
||||||
|
private static let jellyfinTokenAccount = "jellyfin.server"
|
||||||
|
|
||||||
|
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
|
||||||
|
///
|
||||||
|
/// **Diese Namen stehen sonst nirgends mehr.** Mit dem Plex-Code ging auch
|
||||||
|
/// das Einzige, was die beiden Token je wieder entfernt hätte — wer sich
|
||||||
|
/// nie abgemeldet hat, trüge ein gültiges plex.tv-Token bis in alle
|
||||||
|
/// Ewigkeit im Schlüsselbund, ohne dass irgendein Weg es noch löschen
|
||||||
|
/// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
|
||||||
|
/// verschwinden.
|
||||||
|
private enum Altlast {
|
||||||
|
static let merker = "halo.migration.plexPurged"
|
||||||
|
static let tokenKonten = ["plex.account", "plex.server"]
|
||||||
|
static let schlüssel = [
|
||||||
|
"halo.backend.kind",
|
||||||
|
"halo.server.clientIdentifier",
|
||||||
|
"halo.server.connectionURI",
|
||||||
|
"halo.server.name",
|
||||||
|
"halo.server.connectionIsLocal",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob der Server in **demselben** Netz steht wie wir.
|
||||||
|
///
|
||||||
|
/// Entscheidet, ob „Auto" gedeckelt anfängt: zuhause trägt die Leitung
|
||||||
|
/// das Original, unterwegs nicht. Der Server wird danach gefragt, weil
|
||||||
|
/// ein Blick auf die Adresse nicht genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
|
||||||
|
/// auch von der Couch aus unter einem öffentlichen Namen.
|
||||||
|
///
|
||||||
|
/// Im Zweifel `false`: lieber einmal zu vorsichtig gedeckelt als eine
|
||||||
|
/// Wiedergabe, die über eine Fernverbindung gar nicht erst anläuft.
|
||||||
|
private(set) var isLocalConnection = false
|
||||||
|
|
||||||
|
private(set) var phase: Phase = .launching
|
||||||
|
|
||||||
|
/// Der aktive Server hinter dem neutralen Vertrag.
|
||||||
|
///
|
||||||
|
/// Die Ansichten sprechen ausschließlich hierüber und sehen nicht, wer
|
||||||
|
/// dahintersteckt. Dass dieser Vertrag einmal zwei Server gleichzeitig
|
||||||
|
/// getragen hat, ist kein Zufall, sondern der Grund, warum für einen
|
||||||
|
/// weiteren keine einzige Ansicht neu geschrieben werden muss.
|
||||||
|
private(set) var backend: (any MediaBackend)?
|
||||||
|
|
||||||
|
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
|
||||||
|
private(set) var jellyfinAccount: JellyfinAccount?
|
||||||
|
|
||||||
|
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
||||||
|
var accountName: String? { jellyfinAccount?.userName }
|
||||||
|
|
||||||
|
/// Die Mediatheken, die Halo darstellen kann — Musik und Fotos
|
||||||
|
/// erscheinen gar nicht erst.
|
||||||
|
private(set) var libraries: [MediaLibrary] = []
|
||||||
|
|
||||||
|
/// Wie der Server heißt, so wie er sich selbst nennt.
|
||||||
|
var serverName: String { backend?.serverName ?? "" }
|
||||||
|
|
||||||
|
/// Unter welcher Adresse er erreicht wird.
|
||||||
|
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
|
||||||
|
|
||||||
|
/// Wie die Verbindung zum Server zu beschreiben ist.
|
||||||
|
///
|
||||||
|
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
|
||||||
|
/// die Sitzung. Nah oder fern ist dabei die Angabe, die etwas erklärt —
|
||||||
|
/// an ihr hängt, ob „Auto" gedeckelt anfängt, und damit die Hälfte aller
|
||||||
|
/// Fragen nach Rucklern. Die blosse Adresse steht schon eine Zeile höher.
|
||||||
|
var connectionDescription: String? {
|
||||||
|
guard let serverAddress else { return nil }
|
||||||
|
return isLocalConnection
|
||||||
|
? "Lokal · \(serverAddress)"
|
||||||
|
: "Von außen · \(serverAddress)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob der aktive Server Live-TV anbietet.
|
||||||
|
///
|
||||||
|
/// Die Fähigkeit selbst, nicht der Umweg über die Serverart. Einen
|
||||||
|
/// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine
|
||||||
|
/// Zusage, die niemand einlöst — so gibt sie nur, wer sie auch einlöst.
|
||||||
|
var supportsLiveTV: Bool { backend?.capabilities.contains(.liveTV) == true }
|
||||||
|
|
||||||
|
/// Ob er darüber hinaus aufnehmen kann.
|
||||||
|
///
|
||||||
|
/// Getrennt gefragt: ein Server kann empfangen, ohne aufnehmen zu können.
|
||||||
|
var supportsRecordings: Bool { backend?.capabilities.contains(.liveTVRecordings) == true }
|
||||||
|
|
||||||
|
/// Der Server als Live-TV-Server — sofern er einer ist.
|
||||||
|
var liveTV: (any LiveTVBackend)? { backend as? any LiveTVBackend }
|
||||||
|
|
||||||
|
/// Der Server als Aufnahme-Server — sofern er einer ist.
|
||||||
|
var recorder: (any LiveTVRecordingBackend)? { backend as? any LiveTVRecordingBackend }
|
||||||
|
|
||||||
|
/// Warum gerade nicht abgespielt werden kann.
|
||||||
|
///
|
||||||
|
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
|
||||||
|
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
|
||||||
|
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
|
||||||
|
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
|
||||||
|
///
|
||||||
|
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
|
||||||
|
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
|
||||||
|
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
|
||||||
|
var playbackUnavailableMessage: String {
|
||||||
|
backend != nil
|
||||||
|
? String(localized: "player.jellyfinPending")
|
||||||
|
: String(localized: "error.serverUnreachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
private let jellyfinIdentity: JellyfinIdentity
|
||||||
|
private let tokenStore: any TokenStoring
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
init(
|
||||||
|
jellyfinIdentity: JellyfinIdentity,
|
||||||
|
tokenStore: any TokenStoring,
|
||||||
|
defaults: UserDefaults = .standard
|
||||||
|
) {
|
||||||
|
self.jellyfinIdentity = jellyfinIdentity
|
||||||
|
self.tokenStore = tokenStore
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Start
|
||||||
|
|
||||||
|
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
|
||||||
|
func restore() async {
|
||||||
|
purgePlexLeftovers()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
if await adoptStubServerIfRequested() { return }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if await restoreJellyfin() { return }
|
||||||
|
phase = .signedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Räumt einmal weg, was die Plex-Zeit hinterlassen hat.
|
||||||
|
///
|
||||||
|
/// **Der einzige Weg, auf dem das je wieder passiert.** Vorher entfernte
|
||||||
|
/// `signOut()` die Plex-Token über den Authenticator; den gibt es nicht
|
||||||
|
/// mehr, und `plex.account` stünde sonst in keiner Zeile dieses Projekts.
|
||||||
|
/// Wer bis heute angemeldet war und nie auf „Abmelden" gedrückt hat,
|
||||||
|
/// behielte ein gültiges Token am fremden Konto — unsichtbar und
|
||||||
|
/// unlöschbar.
|
||||||
|
///
|
||||||
|
/// Der Merker wird **vor** der Prüfung gesetzt: ein Aufräumen, das
|
||||||
|
/// scheitert, soll nicht bei jedem Start wiederkehren.
|
||||||
|
private func purgePlexLeftovers() {
|
||||||
|
guard !defaults.bool(forKey: Altlast.merker) else { return }
|
||||||
|
defaults.set(true, forKey: Altlast.merker)
|
||||||
|
|
||||||
|
// Nur anfassen, wo auch etwas liegt. Sonst verlöre jeder
|
||||||
|
// Jellyfin-Nutzer beim ersten Start seinen Mediatheken-Speicher für
|
||||||
|
// nichts.
|
||||||
|
let hattePlex = Altlast.tokenKonten.contains { tokenStore.token(for: $0) != nil }
|
||||||
|
|| Altlast.schlüssel.contains { defaults.object(forKey: $0) != nil }
|
||||||
|
guard hattePlex else { return }
|
||||||
|
|
||||||
|
for konto in Altlast.tokenKonten { tokenStore.remove(for: konto) }
|
||||||
|
for schlüssel in Altlast.schlüssel { defaults.removeObject(forKey: schlüssel) }
|
||||||
|
// Titel und Sehfortschritt gehörten zu einem Konto, das es hier nicht
|
||||||
|
// mehr gibt. Gelesen würden sie ohnehin nie wieder.
|
||||||
|
Task { await LibraryCache.shared.clearAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet ab und räumt dabei alles ab, was zur Anmeldung gehörte.
|
||||||
|
///
|
||||||
|
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
|
||||||
|
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
|
||||||
|
func signOut() {
|
||||||
|
forgetJellyfin()
|
||||||
|
clearActiveServer()
|
||||||
|
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
|
||||||
|
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
||||||
|
// verloren.
|
||||||
|
Task { await LibraryCache.shared.clearAll() }
|
||||||
|
phase = .signedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Verbinden mit Jellyfin
|
||||||
|
|
||||||
|
/// Übernimmt eine fertige Jellyfin-Anmeldung.
|
||||||
|
///
|
||||||
|
/// Die Anmeldung selbst führt `JellyfinSignInModel` — sie besteht aus
|
||||||
|
/// mehreren Schritten mit eigener Bedienung (Adresse, Code, Rückfall auf
|
||||||
|
/// Passwort) und gehört deshalb in ein eigenes Modell. Hier kommt nur das
|
||||||
|
/// Ergebnis an: ein Konto und ein Weg dorthin.
|
||||||
|
#if DEBUG
|
||||||
|
/// Meldet sich an der Attrappe aus `Tools/JellyfinStub` an.
|
||||||
|
///
|
||||||
|
/// Nur in der Entwicklung und nur auf ausdrückliches Kommando:
|
||||||
|
/// `-HaloStubServer http://127.0.0.1:8096` als Startargument. Der Weg
|
||||||
|
/// führt bewusst **am Schlüsselbund vorbei** und schreibt nichts fort —
|
||||||
|
/// so lässt sich die Oberfläche ansehen, prüfen und fotografieren, ohne
|
||||||
|
/// dass je ein echter Server, ein echtes Konto oder ein echter Token im
|
||||||
|
/// Spiel wäre. Genau daran hat es bisher gefehlt: Aussagen über das
|
||||||
|
/// Aussehen waren Vermutungen, weil niemand hinsehen konnte.
|
||||||
|
private func adoptStubServerIfRequested() async -> Bool {
|
||||||
|
guard let raw = defaults.string(forKey: "HaloStubServer"),
|
||||||
|
let baseURL = URL(string: raw)
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
let account = JellyfinAccount(
|
||||||
|
userID: "user-1",
|
||||||
|
userName: "Attrappe",
|
||||||
|
token: "stub-token",
|
||||||
|
isAdministrator: true
|
||||||
|
)
|
||||||
|
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: account.token)
|
||||||
|
let backend = JellyfinBackend(client: client, account: account, serverName: "Attrappe")
|
||||||
|
|
||||||
|
jellyfinAccount = account
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
do {
|
||||||
|
libraries = try await loadLibraries(from: backend)
|
||||||
|
phase = .ready
|
||||||
|
} catch {
|
||||||
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func adoptJellyfin(
|
||||||
|
client: JellyfinClient,
|
||||||
|
account: JellyfinAccount,
|
||||||
|
serverName: String
|
||||||
|
) async {
|
||||||
|
phase = .connecting(serverName: serverName)
|
||||||
|
|
||||||
|
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
|
||||||
|
clearActiveServer()
|
||||||
|
jellyfinAccount = account
|
||||||
|
self.backend = backend
|
||||||
|
isLocalConnection = await backend.isInLocalNetwork()
|
||||||
|
|
||||||
|
do {
|
||||||
|
libraries = try await loadLibraries(from: backend)
|
||||||
|
} catch {
|
||||||
|
self.backend = nil
|
||||||
|
jellyfinAccount = nil
|
||||||
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberJellyfin(account: account, serverName: serverName, baseURL: client.baseURL)
|
||||||
|
phase = .ready
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nimmt eine gespeicherte Jellyfin-Anmeldung wieder auf.
|
||||||
|
///
|
||||||
|
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Token nicht
|
||||||
|
/// mehr gilt — dann steht die Anmeldung wieder am Anfang. Ist der Server
|
||||||
|
/// bloß gerade nicht erreichbar, bleibt die Anmeldung dagegen bestehen und
|
||||||
|
/// es gibt einen Fehler mit „Erneut versuchen": auf dem Weg zur Arbeit ist
|
||||||
|
/// jeder Server unerreichbar, und das ist kein Grund, jemanden abzumelden.
|
||||||
|
private func restoreJellyfin() async -> Bool {
|
||||||
|
guard let address = defaults.string(forKey: Key.jellyfinAddress),
|
||||||
|
let baseURL = URL(string: address),
|
||||||
|
let userID = defaults.string(forKey: Key.jellyfinUserID),
|
||||||
|
let token = tokenStore.token(for: Self.jellyfinTokenAccount)
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
let serverName = defaults.string(forKey: Key.jellyfinServerName) ?? ""
|
||||||
|
phase = .connecting(serverName: serverName)
|
||||||
|
|
||||||
|
let account = JellyfinAccount(
|
||||||
|
userID: userID,
|
||||||
|
userName: defaults.string(forKey: Key.jellyfinUserName) ?? "",
|
||||||
|
token: token,
|
||||||
|
isAdministrator: defaults.bool(forKey: Key.jellyfinIsAdministrator)
|
||||||
|
)
|
||||||
|
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: token)
|
||||||
|
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
|
||||||
|
|
||||||
|
clearActiveServer()
|
||||||
|
jellyfinAccount = account
|
||||||
|
self.backend = backend
|
||||||
|
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
|
||||||
|
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
|
||||||
|
isLocalConnection = await backend.isInLocalNetwork()
|
||||||
|
|
||||||
|
do {
|
||||||
|
libraries = try await loadLibraries(from: backend)
|
||||||
|
phase = .ready
|
||||||
|
return true
|
||||||
|
} catch MediaBackendError.unauthorized {
|
||||||
|
// Der Token wurde am Server zurückgezogen. Ihn zu behalten würde
|
||||||
|
// bei jedem Start denselben Fehler erzeugen.
|
||||||
|
forgetJellyfin()
|
||||||
|
clearActiveServer()
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
|
||||||
|
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
|
||||||
|
defaults.set(account.userID, forKey: Key.jellyfinUserID)
|
||||||
|
defaults.set(account.userName, forKey: Key.jellyfinUserName)
|
||||||
|
defaults.set(serverName, forKey: Key.jellyfinServerName)
|
||||||
|
defaults.set(account.isAdministrator, forKey: Key.jellyfinIsAdministrator)
|
||||||
|
try? tokenStore.store(account.token, for: Self.jellyfinTokenAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func forgetJellyfin() {
|
||||||
|
for key in [Key.jellyfinAddress, Key.jellyfinUserID, Key.jellyfinUserName,
|
||||||
|
Key.jellyfinServerName, Key.jellyfinIsAdministrator] {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
tokenStore.remove(for: Self.jellyfinTokenAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Der aktive Server
|
||||||
|
|
||||||
|
/// Holt die Mediathekenliste und merkt sie sich.
|
||||||
|
///
|
||||||
|
/// **Der Speicher ist hier nur ein Rettungsanker**, kein Beschleuniger:
|
||||||
|
/// die Liste ist eine einzige kleine Anfrage, und sie sofort zu zeigen
|
||||||
|
/// hiesse, eine Sitzung für fertig zu erklären, bevor feststeht, dass der
|
||||||
|
/// Token noch gilt. Antwortet der Server dagegen **nicht**, ist eine
|
||||||
|
/// Seitenleiste von gestern besser als eine leere — im Zug ist das der
|
||||||
|
/// Unterschied zwischen „das Heruntergeladene ansehen" und „gar nichts".
|
||||||
|
private func loadLibraries(from backend: any MediaBackend) async throws -> [MediaLibrary] {
|
||||||
|
let kennung = backend.identity
|
||||||
|
do {
|
||||||
|
let geladen = try await backend.libraries().filter(\.isSupported)
|
||||||
|
await LibraryCache.shared.storeLibraries(geladen, server: kennung)
|
||||||
|
return geladen
|
||||||
|
} catch {
|
||||||
|
guard let gemerkt = await LibraryCache.shared.libraries(server: kennung),
|
||||||
|
!gemerkt.isEmpty
|
||||||
|
else { throw error }
|
||||||
|
return gemerkt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearActiveServer() {
|
||||||
|
backend = nil
|
||||||
|
jellyfinAccount = nil
|
||||||
|
libraries = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
|
||||||
|
func retry() async {
|
||||||
|
if await restoreJellyfin() { return }
|
||||||
|
phase = .signedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Kennung für Jellyfin-Anmeldungen.
|
||||||
|
///
|
||||||
|
/// Der Anmeldebildschirm baut sich seinen eigenen Client, bevor es eine
|
||||||
|
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
|
||||||
|
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
|
||||||
|
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Woher der Aufmacher seine Bilder nimmt.
|
/// Woher der Aufmacher seine Bilder nimmt.
|
||||||
@@ -64,8 +64,8 @@ enum HeroArtwork {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
|
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
|
||||||
if isPortrait(placement, compact: compact) { return FlixyAspectRatio.poster }
|
if isPortrait(placement, compact: compact) { return HaloAspectRatio.poster }
|
||||||
return compact ? FlixyAspectRatio.heroCompact : FlixyAspectRatio.hero
|
return compact ? HaloAspectRatio.heroCompact : HaloAspectRatio.hero
|
||||||
}
|
}
|
||||||
|
|
||||||
static func backdropURL(
|
static func backdropURL(
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
|
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
|
||||||
///
|
///
|
||||||
@@ -13,15 +12,15 @@ import PlexKit
|
|||||||
@Observable
|
@Observable
|
||||||
final class HomeLayoutSettings {
|
final class HomeLayoutSettings {
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let hidden = "flixy.home.hiddenHubs"
|
static let hidden = "halo.home.hiddenHubs"
|
||||||
static let order = "flixy.home.hubOrder"
|
static let order = "halo.home.hubOrder"
|
||||||
static let heroCount = "flixy.home.heroCount"
|
static let heroCount = "halo.home.heroCount"
|
||||||
static let heroSource = "flixy.home.heroSource"
|
static let heroSource = "halo.home.heroSource"
|
||||||
static let heroLibraries = "flixy.home.heroLibraries"
|
static let heroLibraries = "halo.home.heroLibraries"
|
||||||
static let combinesContinueRows = "flixy.home.combinesContinueRows"
|
static let combinesContinueRows = "halo.home.combinesContinueRows"
|
||||||
static let hidesWatchedInLatest = "flixy.home.hidesWatchedInLatest"
|
static let hidesWatchedInLatest = "halo.home.hidesWatchedInLatest"
|
||||||
static let cardScale = "flixy.home.cardScale"
|
static let cardScale = "halo.home.cardScale"
|
||||||
static let heroHeight = "flixy.home.heroHeight"
|
static let heroHeight = "halo.home.heroHeight"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Woraus sich der Aufmacher speist.
|
/// Woraus sich der Aufmacher speist.
|
||||||
@@ -5,14 +5,14 @@ import Observation
|
|||||||
|
|
||||||
/// Führt durch die Anmeldung an einem Jellyfin-Server.
|
/// Führt durch die Anmeldung an einem Jellyfin-Server.
|
||||||
///
|
///
|
||||||
/// Eigenes Modell statt eines weiteren Falls in `FlixySession.Phase`: die
|
/// Eigenes Modell statt eines weiteren Falls in `HaloSession.Phase`: die
|
||||||
/// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse
|
/// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse
|
||||||
/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort
|
/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort
|
||||||
/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am
|
/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am
|
||||||
/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin.
|
/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin.
|
||||||
///
|
///
|
||||||
/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits
|
/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits
|
||||||
/// angemeldeten Client bestätigt; das Passwort erreicht Flixy dabei nie —
|
/// angemeldeten Client bestätigt; das Passwort erreicht Halo dabei nie —
|
||||||
/// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt.
|
/// 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
|
/// 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
|
/// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
|
|
||||||
/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand
|
/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand
|
||||||
/// gesagt hat.
|
/// gesagt hat.
|
||||||
@@ -28,13 +28,13 @@ final class LanguagePreferences {
|
|||||||
private enum Key {
|
private enum Key {
|
||||||
/// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der
|
/// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der
|
||||||
/// Besitzer gewechselt, nicht der Wert.
|
/// Besitzer gewechselt, nicht der Wert.
|
||||||
static let audio = "flixy.playback.audioLanguage"
|
static let audio = "halo.playback.audioLanguage"
|
||||||
static let subtitles = "flixy.playback.subtitleLanguage"
|
static let subtitles = "halo.playback.subtitleLanguage"
|
||||||
static let fallbacks = "flixy.languages.fallbacks"
|
static let fallbacks = "halo.languages.fallbacks"
|
||||||
static let avoidsAudioDescription = "flixy.languages.avoidsAudioDescription"
|
static let avoidsAudioDescription = "halo.languages.avoidsAudioDescription"
|
||||||
static let perLibrary = "flixy.languages.library"
|
static let perLibrary = "halo.languages.library"
|
||||||
static let perSeries = "flixy.languages.series"
|
static let perSeries = "halo.languages.series"
|
||||||
static let rememberedTracks = "flixy.languages.seriesTrack"
|
static let rememberedTracks = "halo.languages.seriesTrack"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Sprachen, die zur Auswahl stehen.
|
/// Die Sprachen, die zur Auswahl stehen.
|
||||||
+1
-1
@@ -10,7 +10,7 @@ import MediaKit
|
|||||||
/// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild.
|
/// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild.
|
||||||
///
|
///
|
||||||
/// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den
|
/// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den
|
||||||
/// übrigen Modellen und nicht in FlixyDesign, das `MediaItem` gar nicht kennt.
|
/// übrigen Modellen und nicht in HaloDesign, das `MediaItem` gar nicht kennt.
|
||||||
extension MediaItem {
|
extension MediaItem {
|
||||||
/// Der Titel auf einer Kachel in einer Reihe.
|
/// Der Titel auf einer Kachel in einer Reihe.
|
||||||
///
|
///
|
||||||
+2
-2
@@ -13,8 +13,8 @@ import MediaKit
|
|||||||
@Observable
|
@Observable
|
||||||
final class NavigationLayoutSettings {
|
final class NavigationLayoutSettings {
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let hidden = "flixy.navigation.hidden"
|
static let hidden = "halo.navigation.hidden"
|
||||||
static let order = "flixy.navigation.order"
|
static let order = "halo.navigation.order"
|
||||||
}
|
}
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
@@ -133,7 +133,7 @@ final class NowPlaying {
|
|||||||
///
|
///
|
||||||
/// Die Handlung läuft über einen Sprung auf den Hauptakteur statt über
|
/// Die Handlung läuft über einen Sprung auf den Hauptakteur statt über
|
||||||
/// `assumeIsolated`: dass diese Rückrufe dort ankommen, ist nirgends
|
/// `assumeIsolated`: dass diese Rückrufe dort ankommen, ist nirgends
|
||||||
/// zugesagt, und dieselbe Annahme hat die App im VLC-Engine schon einmal
|
/// zugesagt, und dieselbe Annahme hat die App schon einmal
|
||||||
/// zum Absturz gebracht. Der Umweg kostet einen Durchlauf — unbemerkbar
|
/// zum Absturz gebracht. Der Umweg kostet einen Durchlauf — unbemerkbar
|
||||||
/// neben dem Tastendruck, der ihn ausgelöst hat.
|
/// neben dem Tastendruck, der ihn ausgelöst hat.
|
||||||
private func add(
|
private func add(
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
|
|
||||||
/// Hält fest, was beim letzten Abspielversuch passiert ist.
|
/// Hält fest, was beim letzten Abspielversuch passiert ist.
|
||||||
///
|
///
|
||||||
/// Der Player verschwindet, wenn man ihn schließt — und mit ihm jeder Hinweis
|
/// 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
|
/// darauf, woran es lag. Dieser Mitschnitt überlebt das und steht in den
|
||||||
/// Einstellungen. Aus „es läuft nicht" wird damit „VLC, lokale Verbindung,
|
/// Einstellungen. Aus „es läuft nicht" wird damit „umgepackt, lokale
|
||||||
/// hängt bei Öffnen" — und das ist ein Unterschied, der Stunden spart.
|
/// Verbindung, hängt bei Öffnen" — ein Unterschied, der Stunden spart.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class PlaybackDiagnostics {
|
final class PlaybackDiagnostics {
|
||||||
@@ -65,11 +65,8 @@ final class PlaybackDiagnostics {
|
|||||||
|
|
||||||
private static func describe(_ decision: PlaybackDecision) -> String {
|
private static func describe(_ decision: PlaybackDecision) -> String {
|
||||||
switch decision.method {
|
switch decision.method {
|
||||||
case .directPlay(let engine):
|
case .directPlay:
|
||||||
switch engine {
|
"Direkt"
|
||||||
case .avPlayer: "Direkt · AVPlayer"
|
|
||||||
case .vlc: "Direkt · VLC"
|
|
||||||
}
|
|
||||||
case .remux:
|
case .remux:
|
||||||
decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } }
|
decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } }
|
||||||
? "Umgepackt · Bild unverändert, Ton umgerechnet"
|
? "Umgepackt · Bild unverändert, Ton umgerechnet"
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
// **`!os(tvOS)` und nicht nur `canImport`.** Der Fernseher verlinkt FlixyVLC
|
|
||||||
// nicht, aber der Abhängigkeits-Sucher von Clang prüft den Import trotzdem —
|
|
||||||
// und bricht dann an VLCKit ab, das für dieses Ziel gar nicht gebaut wird.
|
|
||||||
// „Unable to resolve module dependency: VLCKit" nennt dabei weder diese Datei
|
|
||||||
// noch VLC als Grund.
|
|
||||||
#if canImport(FlixyVLC) && !os(tvOS)
|
|
||||||
import FlixyVLC
|
|
||||||
#endif
|
|
||||||
import Observation
|
import Observation
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
|
|
||||||
/// Was der Nutzer über die Wiedergabe eingestellt hat.
|
/// Was der Nutzer über die Wiedergabe eingestellt hat.
|
||||||
///
|
///
|
||||||
@@ -36,19 +28,19 @@ final class PlaybackSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let wifiQuality = "flixy.playback.quality.wifi"
|
static let wifiQuality = "halo.playback.quality.wifi"
|
||||||
static let cellularQuality = "flixy.playback.quality.cellular"
|
static let cellularQuality = "halo.playback.quality.cellular"
|
||||||
static let autoSkipIntro = "flixy.playback.autoSkipIntro"
|
static let autoSkipIntro = "halo.playback.autoSkipIntro"
|
||||||
static let autoSkipCredits = "flixy.playback.autoSkipCredits"
|
static let autoSkipCredits = "halo.playback.autoSkipCredits"
|
||||||
static let autoPlayNext = "flixy.playback.autoPlayNext"
|
static let autoPlayNext = "halo.playback.autoPlayNext"
|
||||||
static let locksLandscape = "flixy.playback.locksLandscape"
|
static let locksLandscape = "halo.playback.locksLandscape"
|
||||||
static let rewindSeconds = "flixy.playback.rewindSeconds"
|
static let rewindSeconds = "halo.playback.rewindSeconds"
|
||||||
static let forwardSeconds = "flixy.playback.forwardSeconds"
|
static let forwardSeconds = "halo.playback.forwardSeconds"
|
||||||
static let defaultRate = "flixy.playback.defaultRate"
|
static let defaultRate = "halo.playback.defaultRate"
|
||||||
static let remembersQuality = "flixy.playback.remembersQuality"
|
static let remembersQuality = "halo.playback.remembersQuality"
|
||||||
static let rememberedQuality = "flixy.playback.rememberedQuality"
|
static let rememberedQuality = "halo.playback.rememberedQuality"
|
||||||
static let showsBitrate = "flixy.playback.showsBitrate"
|
static let showsBitrate = "halo.playback.showsBitrate"
|
||||||
static let lastPlaybackStruggled = "flixy.playback.struggled"
|
static let lastPlaybackStruggled = "halo.playback.struggled"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Sprünge, die es zur Auswahl gibt.
|
/// Die Sprünge, die es zur Auswahl gibt.
|
||||||
@@ -60,25 +52,6 @@ final class PlaybackSettings {
|
|||||||
/// Die Geschwindigkeiten, mit denen eine Wiedergabe beginnen kann.
|
/// Die Geschwindigkeiten, mit denen eine Wiedergabe beginnen kann.
|
||||||
static let rateChoices: [Double] = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
static let rateChoices: [Double] = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||||
|
|
||||||
/// Ob ein zweiter Spieler zur Verfügung steht.
|
|
||||||
///
|
|
||||||
/// VLC steckt in einem eigenen Paket, das nur für iOS gebaut wird — sein
|
|
||||||
/// Binärpaket hat keinen Teil für Mac Catalyst. Wo es fehlt, übernimmt der
|
|
||||||
/// Server: MKV und DTS werden dann umgerechnet statt unverändert
|
|
||||||
/// übertragen. Das kostet ihn Rechenzeit und sieht man nicht, aber es
|
|
||||||
/// spielt.
|
|
||||||
///
|
|
||||||
/// Auf dem Fernseher ebenfalls nicht: 920 MB Binärpaket für einen
|
|
||||||
/// Rückfallweg, der auf einem Apple TV selten greift, sind die Bauzeit
|
|
||||||
/// nicht wert.
|
|
||||||
static var hasFallbackEngine: Bool {
|
|
||||||
#if canImport(FlixyVLC) && !os(tvOS)
|
|
||||||
true
|
|
||||||
#else
|
|
||||||
false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
|
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
|
||||||
@@ -165,10 +138,6 @@ final class PlaybackSettings {
|
|||||||
|
|
||||||
return PlaybackPolicy(
|
return PlaybackPolicy(
|
||||||
allowsTranscoding: true,
|
allowsTranscoding: true,
|
||||||
// Nur, wenn es ihn auf dieser Plattform gibt. Ohne VLC wählte die
|
|
||||||
// Entscheidung sonst einen Spieler, den es nicht gibt — und die
|
|
||||||
// Wiedergabe bliebe schwarz, statt dass der Server umrechnet.
|
|
||||||
allowsFallbackEngine: Self.hasFallbackEngine,
|
|
||||||
maximumBitrate: quality.bitrateLimit
|
maximumBitrate: quality.bitrateLimit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ import Observation
|
|||||||
final class SearchFeed {
|
final class SearchFeed {
|
||||||
/// Wie viele zuletzt gesuchte Begriffe gemerkt werden.
|
/// Wie viele zuletzt gesuchte Begriffe gemerkt werden.
|
||||||
private static let recentLimit = 8
|
private static let recentLimit = 8
|
||||||
private static let recentKey = "flixy.search.recent"
|
private static let recentKey = "halo.search.recent"
|
||||||
|
|
||||||
private(set) var hubs: [MediaRowGroup] = []
|
private(set) var hubs: [MediaRowGroup] = []
|
||||||
private(set) var isSearching = false
|
private(set) var isSearching = false
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
|
|
||||||
/// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg.
|
/// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg.
|
||||||
///
|
///
|
||||||
@@ -14,8 +14,8 @@ import FlixyPlayback
|
|||||||
@Observable
|
@Observable
|
||||||
final class SubtitleAppearance {
|
final class SubtitleAppearance {
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let size = "flixy.subtitles.size"
|
static let size = "halo.subtitles.size"
|
||||||
static let palette = "flixy.subtitles.palette"
|
static let palette = "halo.subtitles.palette"
|
||||||
}
|
}
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe
|
/// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
// **Die ganze Datei nur für den Fernseher.**
|
// **Die ganze Datei nur für den Fernseher.**
|
||||||
//
|
//
|
||||||
// Sie liegt im geteilten Ordner, weil `Flixy/Session/` von beiden Zielen
|
// Sie liegt im geteilten Ordner, weil `Halo/Session/` von beiden Zielen
|
||||||
// übersetzt wird — und `TVServices` gibt es auf iOS nicht. Ein `#if` um den
|
// ü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
|
// einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft
|
||||||
// ihn trotzdem und bricht mit „Unable to resolve module dependency:
|
// ihn trotzdem und bricht mit „Unable to resolve module dependency:
|
||||||
// TVServices" ab, ohne den Fernseher zu erwähnen. Dieselbe Falle wie bei
|
// TVServices" ab, ohne den Fernseher zu erwähnen. Dieselbe Falle wie bei
|
||||||
// VLCKit, siehe `PlaybackSettings`.
|
// siehe `PlaybackSettings`.
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
import Foundation
|
import Foundation
|
||||||
import MediaKit
|
import MediaKit
|
||||||
@@ -21,7 +21,7 @@ import TVServices
|
|||||||
@MainActor
|
@MainActor
|
||||||
enum TopShelfWriter {
|
enum TopShelfWriter {
|
||||||
/// Die App-Gruppe, die App und Erweiterung teilen.
|
/// Die App-Gruppe, die App und Erweiterung teilen.
|
||||||
static let group = "group.com.scarriffleservices.flixy"
|
static let group = "group.com.scarriffleservices.halo"
|
||||||
|
|
||||||
/// Die Kante, in der das Regal Plakate zeigt.
|
/// Die Kante, in der das Regal Plakate zeigt.
|
||||||
///
|
///
|
||||||
+1
-1
@@ -99,5 +99,5 @@ extension EnvironmentValues {
|
|||||||
///
|
///
|
||||||
/// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es
|
/// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es
|
||||||
/// auch nichts neu zu laden.
|
/// auch nichts neu zu laden.
|
||||||
@Entry var flixyItemChanged: () async -> Void = {}
|
@Entry var haloItemChanged: () async -> Void = {}
|
||||||
}
|
}
|
||||||
+115
-8
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
import HaloDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
|
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
|
||||||
@@ -10,7 +11,7 @@ import MediaKit
|
|||||||
/// Zwei Schritte für eine Absicht.
|
/// Zwei Schritte für eine Absicht.
|
||||||
///
|
///
|
||||||
/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt
|
/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt
|
||||||
/// in `FlixyDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es
|
/// in `HaloDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es
|
||||||
/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der
|
/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der
|
||||||
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
|
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
|
||||||
/// und wäre für VoiceOver gar nicht mehr da.
|
/// und wäre für VoiceOver gar nicht mehr da.
|
||||||
@@ -26,21 +27,32 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
var opensDetails: Bool = false
|
var opensDetails: Bool = false
|
||||||
@ViewBuilder let card: () -> Card
|
@ViewBuilder let card: () -> Card
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(QuickPlay.self) private var quickPlay
|
@Environment(QuickPlay.self) private var quickPlay
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
@State private var isPointerInside = false
|
@State private var isPointerInside = false
|
||||||
@State private var errorMessage: String?
|
@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
|
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
|
||||||
/// hat. Siehe `ItemActionEntries`.
|
/// hat. Siehe `ItemActionEntries`.
|
||||||
@Environment(\.flixyItemChanged) private var itemChanged
|
@Environment(\.haloItemChanged) private var itemChanged
|
||||||
/// Der Weg auf die Detailseite — siehe `MediaStack`.
|
/// Der Weg auf die Detailseite — siehe `MediaStack`.
|
||||||
@Environment(\.flixyPush) private var push
|
@Environment(\.haloPush) private var push
|
||||||
|
|
||||||
private var isMac: Bool { FlixyPlatform.isMac }
|
private var isMac: Bool { HaloPlatform.isMac }
|
||||||
|
|
||||||
/// Ob es überhaupt etwas zu starten gibt.
|
/// Ob es überhaupt etwas zu starten gibt.
|
||||||
///
|
///
|
||||||
@@ -93,6 +105,15 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
Button("tile.play", systemImage: "play.fill") { trigger() }
|
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()
|
Divider()
|
||||||
}
|
}
|
||||||
ItemActionEntries(
|
ItemActionEntries(
|
||||||
@@ -117,6 +138,51 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
// Aktion, die Millisekunden dauert, zu spät und ginge zu früh. Geht
|
// Aktion, die Millisekunden dauert, zu spät und ginge zu früh. Geht
|
||||||
// sie dagegen schief, muss man es erfahren — sonst bliebe der Haken
|
// sie dagegen schief, muss man es erfahren — sonst bliebe der Haken
|
||||||
// einfach aus, und das sieht aus wie ein Fehler der App.
|
// 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(
|
.alert(
|
||||||
Text("error.title"),
|
Text("error.title"),
|
||||||
isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
|
isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
|
||||||
@@ -170,13 +236,54 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
.shadow(color: .black.opacity(0.35), radius: 3, y: 1)
|
.shadow(color: .black.opacity(0.35), radius: 3, y: 1)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
.opacity(isPointerInside ? 1 : 0)
|
.opacity(isPointerInside ? 1 : 0)
|
||||||
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
|
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
|
||||||
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
|
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
|
||||||
.accessibilityHidden(true)
|
.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() {
|
private func trigger() {
|
||||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Das Aktionsmenü auf einer Detailseite.
|
/// Das Aktionsmenü auf einer Detailseite.
|
||||||
@@ -17,7 +17,7 @@ struct ItemActionsMenu: View {
|
|||||||
/// Detailseite neu geladen werden sollte.
|
/// Detailseite neu geladen werden sollte.
|
||||||
let onChange: () async -> Void
|
let onChange: () async -> Void
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@State private var isWorking = false
|
@State private var isWorking = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Detailseite eines Titels.
|
/// Die Detailseite eines Titels.
|
||||||
@@ -10,7 +10,7 @@ import MediaKit
|
|||||||
struct MediaDetailView: View {
|
struct MediaDetailView: View {
|
||||||
let route: MediaRoute
|
let route: MediaRoute
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@State private var state: LoadState<MediaItem> = .loading
|
@State private var state: LoadState<MediaItem> = .loading
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -37,12 +37,12 @@ struct MediaDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(state.value?.title ?? route.title)
|
.navigationTitle(state.value?.title ?? route.title)
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if let item = state.value {
|
if let item = state.value {
|
||||||
ToolbarItem(placement: .flixyTrailing) {
|
ToolbarItem(placement: .haloTrailing) {
|
||||||
ItemActionsMenu(item: item) { await load() }
|
ItemActionsMenu(item: item) { await load() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
|
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
|
||||||
@@ -15,7 +15,7 @@ struct ParentLinks: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
if !destinations.isEmpty {
|
if !destinations.isEmpty {
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
ForEach(destinations, id: \.route.ratingKey) { destination in
|
ForEach(destinations, id: \.route.ratingKey) { destination in
|
||||||
NavigationLink(value: destination.route) {
|
NavigationLink(value: destination.route) {
|
||||||
Label {
|
Label {
|
||||||
@@ -24,16 +24,16 @@ struct ParentLinks: View {
|
|||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: destination.symbol)
|
Image(systemName: destination.symbol)
|
||||||
}
|
}
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.background(Color.flixyElevated, in: .capsule)
|
.background(Color.haloElevated, in: .capsule)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
}
|
}
|
||||||
+40
-40
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
||||||
@@ -7,9 +7,9 @@ import MediaKit
|
|||||||
struct PlayableDetailView: View {
|
struct PlayableDetailView: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
@State private var selectedVersion = 0
|
@State private var selectedVersion = 0
|
||||||
@@ -17,7 +17,7 @@ struct PlayableDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
VStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||||
header
|
header
|
||||||
|
|
||||||
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
|
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
|
||||||
@@ -29,14 +29,14 @@ struct PlayableDetailView: View {
|
|||||||
|
|
||||||
if item.hasMultipleVersions {
|
if item.hasMultipleVersions {
|
||||||
VersionPicker(media: item.versions, selection: $selectedVersion)
|
VersionPicker(media: item.versions, selection: $selectedVersion)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let summary = item.summary, !summary.isEmpty {
|
if let summary = item.summary, !summary.isEmpty {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !item.genres.isEmpty {
|
if !item.genres.isEmpty {
|
||||||
@@ -53,13 +53,13 @@ struct PlayableDetailView: View {
|
|||||||
part: part,
|
part: part,
|
||||||
isExpanded: $showsTechnicalDetails
|
isExpanded: $showsTechnicalDetails
|
||||||
)
|
)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, FlixySpacing.section)
|
.padding(.bottom, HaloSpacing.section)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentMedia: MediaVersion? {
|
private var currentMedia: MediaVersion? {
|
||||||
@@ -104,9 +104,9 @@ struct PlayableDetailView: View {
|
|||||||
item.watchProgress == nil ? "hero.play" : "hero.resume",
|
item.watchProgress == nil ? "hero.play" : "hero.resume",
|
||||||
systemImage: "play.fill"
|
systemImage: "play.fill"
|
||||||
)
|
)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.padding(.horizontal, FlixySpacing.loose)
|
.padding(.horizontal, HaloSpacing.loose)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
@@ -118,11 +118,11 @@ struct PlayableDetailView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("detail.playFromStart", systemImage: "gobackward")
|
Label("detail.playFromStart", systemImage: "gobackward")
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(Color.flixyTextSecondary)
|
.tint(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||||
@@ -210,10 +210,10 @@ struct VersionPicker: View {
|
|||||||
@Binding var selection: Int
|
@Binding var selection: Int
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||||
Text("detail.versions")
|
Text("detail.versions")
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
Picker("detail.versions", selection: $selection) {
|
Picker("detail.versions", selection: $selection) {
|
||||||
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
|
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
|
||||||
@@ -232,17 +232,17 @@ struct TagStrip: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
ForEach(tags, id: \.self) { tag in
|
ForEach(tags, id: \.self) { tag in
|
||||||
Text(tag)
|
Text(tag)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.vertical, FlixySpacing.tight)
|
.padding(.vertical, HaloSpacing.tight)
|
||||||
.background(Color.flixyElevated, in: .capsule)
|
.background(Color.haloElevated, in: .capsule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
}
|
}
|
||||||
@@ -251,20 +251,20 @@ struct TagStrip: View {
|
|||||||
/// Die Besetzung mit Porträts.
|
/// Die Besetzung mit Porträts.
|
||||||
struct CastRow: View {
|
struct CastRow: View {
|
||||||
let cast: [MediaPerson]
|
let cast: [MediaPerson]
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
Text("detail.cast")
|
Text("detail.cast")
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
|
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
HStack(alignment: .top, spacing: HaloSpacing.regular) {
|
||||||
ForEach(cast.prefix(20)) { role in
|
ForEach(cast.prefix(20)) { role in
|
||||||
VStack(spacing: FlixySpacing.snug) {
|
VStack(spacing: HaloSpacing.snug) {
|
||||||
FlixyArtwork(
|
HaloArtwork(
|
||||||
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
|
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
fallbackSymbol: "person"
|
fallbackSymbol: "person"
|
||||||
@@ -273,15 +273,15 @@ struct CastRow: View {
|
|||||||
.clipShape(.circle)
|
.clipShape(.circle)
|
||||||
|
|
||||||
Text(role.name)
|
Text(role.name)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
if let character = role.role {
|
if let character = role.role {
|
||||||
Text(character)
|
Text(character)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,7 +289,7 @@ struct CastRow: View {
|
|||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
}
|
}
|
||||||
+92
-47
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Detailseite einer Serie oder Staffel.
|
/// Die Detailseite einer Serie oder Staffel.
|
||||||
@@ -10,9 +10,10 @@ import MediaKit
|
|||||||
struct SeriesDetailView: View {
|
struct SeriesDetailView: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
|
@Environment(\.haloAccent) private var accent
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
@State private var children: LoadState<[MediaItem]> = .loading
|
@State private var children: LoadState<[MediaItem]> = .loading
|
||||||
|
|
||||||
@@ -27,21 +28,23 @@ struct SeriesDetailView: View {
|
|||||||
@State private var selection: Set<String> = []
|
@State private var selection: Set<String> = []
|
||||||
@State private var sample: MediaItem?
|
@State private var sample: MediaItem?
|
||||||
@State private var showsQualityChoice = false
|
@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 isWorking = false
|
||||||
@State private var summary: String?
|
@State private var summary: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
VStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||||
header
|
header
|
||||||
|
|
||||||
ParentLinks(item: item)
|
ParentLinks(item: item)
|
||||||
|
|
||||||
if let summary = item.summary, !summary.isEmpty {
|
if let summary = item.summary, !summary.isEmpty {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !item.genres.isEmpty { TagStrip(tags: item.genres) }
|
if !item.genres.isEmpty { TagStrip(tags: item.genres) }
|
||||||
@@ -55,17 +58,17 @@ struct SeriesDetailView: View {
|
|||||||
scope: .series(item.id),
|
scope: .series(item.id),
|
||||||
title: "languages.forThisSeries"
|
title: "languages.forThisSeries"
|
||||||
)
|
)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
|
|
||||||
childList
|
childList
|
||||||
|
|
||||||
if !item.cast.isEmpty { CastRow(cast: item.cast) }
|
if !item.cast.isEmpty { CastRow(cast: item.cast) }
|
||||||
}
|
}
|
||||||
.padding(.bottom, FlixySpacing.section)
|
.padding(.bottom, HaloSpacing.section)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) { selectionBar }
|
.safeAreaInset(edge: .bottom, spacing: 0) { selectionBar }
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
Text("downloads.quality.title"),
|
Text("downloads.quality.title"),
|
||||||
@@ -77,14 +80,41 @@ struct SeriesDetailView: View {
|
|||||||
backend: session.backend
|
backend: session.backend
|
||||||
)
|
)
|
||||||
ForEach(choice.available) { quality in
|
ForEach(choice.available) { quality in
|
||||||
Button(choice.label(for: quality)) {
|
Button(choice.label(for: quality)) { chose(quality) }
|
||||||
Task { await loadSelected(quality: quality) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Button("action.cancel", role: .cancel) {}
|
Button("action.cancel", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text("downloads.quality.hint")
|
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(
|
.alert(
|
||||||
Text("downloads.batch.title"),
|
Text("downloads.batch.title"),
|
||||||
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
|
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
|
||||||
@@ -133,34 +163,34 @@ struct SeriesDetailView: View {
|
|||||||
private var childList: some View {
|
private var childList: some View {
|
||||||
switch children {
|
switch children {
|
||||||
case .loading:
|
case .loading:
|
||||||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, FlixySpacing.section)
|
ProgressView().frame(maxWidth: .infinity).padding(.vertical, HaloSpacing.section)
|
||||||
|
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
|
|
||||||
case .loaded(let entries):
|
case .loaded(let entries):
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
||||||
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
||||||
// als von allem anderen auf dieser Seite.
|
// als von allem anderen auf dieser Seite.
|
||||||
HStack {
|
HStack {
|
||||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
Spacer()
|
Spacer()
|
||||||
if entries.contains(where: { $0.kind == .episode }) {
|
if entries.contains(where: { $0.kind == .episode }) {
|
||||||
selectionControls(for: entries)
|
selectionControls(for: entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
|
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(entries) { entry in
|
ForEach(entries) { entry in
|
||||||
row(for: entry)
|
row(for: entry)
|
||||||
Divider().overlay(Color.flixySeparator)
|
Divider().overlay(Color.haloSeparator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,16 +207,16 @@ struct SeriesDetailView: View {
|
|||||||
Button {
|
Button {
|
||||||
if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) }
|
if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) }
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
ChildRow(item: entry)
|
ChildRow(item: entry)
|
||||||
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
|
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(gewaehlt ? accent : Color.flixyTextMuted)
|
.foregroundStyle(gewaehlt ? accent : Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
|
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
|
||||||
} else {
|
} else {
|
||||||
// **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im
|
// **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im
|
||||||
@@ -198,7 +228,7 @@ struct SeriesDetailView: View {
|
|||||||
// Als Geschwister in einer Reihe statt als Überzug: dann teilt das
|
// Als Geschwister in einer Reihe statt als Überzug: dann teilt das
|
||||||
// Layout den Platz auf, und der Titel kann gar nicht erst unter
|
// Layout den Platz auf, und der Titel kann gar nicht erst unter
|
||||||
// dem Knopf verschwinden.
|
// dem Knopf verschwinden.
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
|
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
|
||||||
ChildRow(item: entry)
|
ChildRow(item: entry)
|
||||||
}
|
}
|
||||||
@@ -211,7 +241,7 @@ struct SeriesDetailView: View {
|
|||||||
DownloadButton(item: entry, loadsDetails: true)
|
DownloadButton(item: entry, loadsDetails: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +249,7 @@ struct SeriesDetailView: View {
|
|||||||
private func selectionControls(for entries: [MediaItem]) -> some View {
|
private func selectionControls(for entries: [MediaItem]) -> some View {
|
||||||
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
|
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
|
||||||
|
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
if isSelecting {
|
if isSelecting {
|
||||||
Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") {
|
Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") {
|
||||||
selection = selection.count == folgen.count ? [] : Set(folgen)
|
selection = selection.count == folgen.count ? [] : Set(folgen)
|
||||||
@@ -228,12 +258,12 @@ struct SeriesDetailView: View {
|
|||||||
isSelecting = false
|
isSelecting = false
|
||||||
selection = []
|
selection = []
|
||||||
}
|
}
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
} else {
|
} else {
|
||||||
Button("detail.select") { isSelecting = true }
|
Button("detail.select") { isSelecting = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
}
|
}
|
||||||
@@ -258,14 +288,14 @@ struct SeriesDetailView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.padding(.horizontal, FlixySpacing.loose)
|
.padding(.horizontal, HaloSpacing.loose)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.disabled(isWorking)
|
.disabled(isWorking)
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
.padding(.vertical, HaloSpacing.regular)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(.bar)
|
.background(.bar)
|
||||||
}
|
}
|
||||||
@@ -291,12 +321,27 @@ struct SeriesDetailView: View {
|
|||||||
showsQualityChoice = true
|
showsQualityChoice = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSelected(quality: DownloadQuality) async {
|
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
|
isWorking = true
|
||||||
defer { isWorking = false }
|
defer { isWorking = false }
|
||||||
|
|
||||||
let service = DownloadService(backend: session.backend, coordinator: downloads)
|
let result = await downloadService.download(
|
||||||
let result = await service.download(selectedEpisodes, quality: quality)
|
selectedEpisodes, quality: quality, audioStreamIndex: audioStreamIndex
|
||||||
|
)
|
||||||
|
|
||||||
var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
||||||
if let erster = result.refused.first {
|
if let erster = result.refused.first {
|
||||||
@@ -342,11 +387,11 @@ struct SeriesDetailView: View {
|
|||||||
/// Eine Staffel- oder Folgenzeile.
|
/// Eine Staffel- oder Folgenzeile.
|
||||||
private struct ChildRow: View {
|
private struct ChildRow: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
FlixyArtwork(
|
HaloArtwork(
|
||||||
url: session.backend?.artworkURL(
|
url: session.backend?.artworkURL(
|
||||||
path: item.thumb,
|
path: item.thumb,
|
||||||
width: item.kind == .season ? 120 : 240,
|
width: item.kind == .season ? 120 : 240,
|
||||||
@@ -366,14 +411,14 @@ private struct ChildRow: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(rowTitle)
|
Text(rowTitle)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
|
|
||||||
if let subtitle {
|
if let subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,14 +427,14 @@ private struct ChildRow: View {
|
|||||||
|
|
||||||
if item.isWatched {
|
if item.isWatched {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.accessibilityLabel(Text("item.watched"))
|
.accessibilityLabel(Text("item.watched"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Das waagerechte Polster liegt jetzt eine Ebene höher, bei der
|
// Das waagerechte Polster liegt jetzt eine Ebene höher, bei der
|
||||||
// Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am
|
// Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am
|
||||||
// Bildschirmrand.
|
// Bildschirmrand.
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
+35
-24
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die aufklappbaren technischen Angaben zu einer Datei.
|
/// Die aufklappbaren technischen Angaben zu einer Datei.
|
||||||
///
|
///
|
||||||
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Flixy sie
|
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Halo sie
|
||||||
/// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die
|
/// 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
|
/// 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.
|
/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird.
|
||||||
@@ -14,19 +14,33 @@ struct TechnicalDetails: View {
|
|||||||
let part: MediaFile
|
let part: MediaFile
|
||||||
@Binding var isExpanded: Bool
|
@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 {
|
private var decision: PlaybackDecision {
|
||||||
PlaybackDecider().decide(profile: media.profile(file: part), policy: .directPlayOnly)
|
PlaybackDecider().decide(
|
||||||
|
profile: media.profile(file: part),
|
||||||
|
policy: PlaybackPolicy(allowsTranscoding: true)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DisclosureGroup(isExpanded: $isExpanded) {
|
DisclosureGroup(isExpanded: $isExpanded) {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||||
row("technical.playbackMethod", playbackMethodLabel)
|
row("technical.playbackMethod", playbackMethodLabel)
|
||||||
if let reason = rationaleLabel {
|
if let reason = rationaleLabel {
|
||||||
row("technical.reason", reason)
|
row("technical.reason", reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight)
|
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
|
||||||
|
|
||||||
if let container = part.container { row("technical.container", container.uppercased()) }
|
if let container = part.container { row("technical.container", container.uppercased()) }
|
||||||
if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
|
if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
|
||||||
@@ -41,7 +55,7 @@ struct TechnicalDetails: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !part.audioStreams.isEmpty {
|
if !part.audioStreams.isEmpty {
|
||||||
Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight)
|
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
|
||||||
ForEach(part.audioStreams) { stream in
|
ForEach(part.audioStreams) { stream in
|
||||||
row("technical.audio", trackLabel(stream))
|
row("technical.audio", trackLabel(stream))
|
||||||
}
|
}
|
||||||
@@ -54,36 +68,36 @@ struct TechnicalDetails: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let file = part.file {
|
if let file = part.file {
|
||||||
Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight)
|
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("technical.file")
|
Text("technical.file")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
Text(file)
|
Text(file)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, FlixySpacing.snug)
|
.padding(.top, HaloSpacing.snug)
|
||||||
} label: {
|
} label: {
|
||||||
Text("technical.title")
|
Text("technical.title")
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
}
|
}
|
||||||
.tint(Color.flixyTextSecondary)
|
.tint(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
|
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,11 +110,8 @@ struct TechnicalDetails: View {
|
|||||||
|
|
||||||
private var playbackMethodLabel: String {
|
private var playbackMethodLabel: String {
|
||||||
switch decision.method {
|
switch decision.method {
|
||||||
case .directPlay(let engine):
|
case .directPlay:
|
||||||
switch engine {
|
String(localized: "technical.directPlayNative")
|
||||||
case .avPlayer: String(localized: "technical.directPlayNative")
|
|
||||||
case .vlc: String(localized: "technical.directPlayVLC")
|
|
||||||
}
|
|
||||||
// **Zwei Fälle unter einem Namen wären eine Lüge.** Umgepackt bleibt
|
// **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.
|
// das Bild unberührt — der Ton nur dann, wenn AVPlayer ihn kennt.
|
||||||
case .remux:
|
case .remux:
|
||||||
+79
-16
@@ -1,8 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Der Download-Knopf auf einer Detailseite.
|
/// Der Download-Knopf auf einer Detailseite.
|
||||||
///
|
///
|
||||||
@@ -26,15 +25,24 @@ struct DownloadButton: View {
|
|||||||
/// nicht drückt.
|
/// nicht drückt.
|
||||||
var loadsDetails: Bool = false
|
var loadsDetails: Bool = false
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
@State private var refusal: DownloadRefusal?
|
@State private var refusal: DownloadRefusal?
|
||||||
@State private var showsQualityChoice = false
|
@State private var showsQualityChoice = false
|
||||||
/// Der nachgeholte Titel — gilt ab dann statt `item`.
|
/// Der nachgeholte Titel — gilt ab dann statt `item`.
|
||||||
@State private var detailed: MediaItem?
|
@State private var detailed: MediaItem?
|
||||||
@State private var isLoadingDetails = false
|
@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? {
|
private var existing: DownloadItem? {
|
||||||
downloads.items.first { $0.ratingKey == item.id }
|
downloads.items.first { $0.ratingKey == item.id }
|
||||||
@@ -56,18 +64,18 @@ struct DownloadButton: View {
|
|||||||
} else {
|
} else {
|
||||||
Label(label, systemImage: symbol)
|
Label(label, systemImage: symbol)
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
|
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
|
||||||
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
|
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
|
||||||
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
|
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.disabled(isLoadingDetails)
|
.disabled(isLoadingDetails)
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
|
.tint(existing?.state == .completed ? accent : Color.haloTextSecondary)
|
||||||
.accessibilityLabel(Text(label))
|
.accessibilityLabel(Text(label))
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
Text("downloads.quality.title"),
|
Text("downloads.quality.title"),
|
||||||
@@ -75,12 +83,29 @@ struct DownloadButton: View {
|
|||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
ForEach(choice.available) { quality in
|
ForEach(choice.available) { quality in
|
||||||
Button(choice.label(for: quality)) { enqueue(quality: quality) }
|
Button(choice.label(for: quality)) { chose(quality) }
|
||||||
}
|
}
|
||||||
Button("action.cancel", role: .cancel) {}
|
Button("action.cancel", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text("downloads.quality.hint")
|
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(
|
.alert(
|
||||||
Text("downloads.refused.title"),
|
Text("downloads.refused.title"),
|
||||||
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
|
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
|
||||||
@@ -157,10 +182,45 @@ struct DownloadButton: View {
|
|||||||
showsQualityChoice = true
|
showsQualityChoice = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enqueue(quality: DownloadQuality) {
|
private var service: DownloadService {
|
||||||
let service = DownloadService(backend: session.backend, coordinator: downloads)
|
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 {
|
do {
|
||||||
_ = try service.download(source, mediaIndex: mediaIndex, quality: quality)
|
_ = try service.download(
|
||||||
|
source, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
refusal = error
|
refusal = error
|
||||||
}
|
}
|
||||||
@@ -187,9 +247,10 @@ extension DownloadQuality {
|
|||||||
struct DownloadAllButton: View {
|
struct DownloadAllButton: View {
|
||||||
let container: MediaItem
|
let container: MediaItem
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
@State private var isWorking = false
|
@State private var isWorking = false
|
||||||
@State private var summary: String?
|
@State private var summary: String?
|
||||||
@@ -208,7 +269,7 @@ struct DownloadAllButton: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(Color.flixyTextSecondary)
|
.tint(Color.haloTextSecondary)
|
||||||
.disabled(isWorking)
|
.disabled(isWorking)
|
||||||
.alert(
|
.alert(
|
||||||
Text("downloads.batch.title"),
|
Text("downloads.batch.title"),
|
||||||
@@ -224,7 +285,9 @@ struct DownloadAllButton: View {
|
|||||||
isWorking = true
|
isWorking = true
|
||||||
defer { isWorking = false }
|
defer { isWorking = false }
|
||||||
|
|
||||||
let service = DownloadService(backend: session.backend, coordinator: downloads)
|
let service = DownloadService(
|
||||||
|
backend: session.backend, coordinator: downloads, languages: languages
|
||||||
|
)
|
||||||
let result = await service.downloadAll(in: container)
|
let result = await service.downloadAll(in: container)
|
||||||
|
|
||||||
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen.
|
/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen.
|
||||||
+40
-40
@@ -1,14 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
|
|
||||||
/// Was offline ist und was gerade geladen wird.
|
/// Was offline ist und was gerade geladen wird.
|
||||||
struct DownloadsView: View {
|
struct DownloadsView: View {
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
|
|
||||||
@@ -24,16 +24,16 @@ struct DownloadsView: View {
|
|||||||
list
|
list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("sidebar.downloads"))
|
.navigationTitle(Text("sidebar.downloads"))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
|
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
|
||||||
// mehr, und dies ist der Weg dorthin.
|
// mehr, und dies ist der Weg dorthin.
|
||||||
if sizeClass == .compact {
|
if sizeClass == .compact {
|
||||||
ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() }
|
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
// Keine Rückfrage beim Entfernen.
|
// Keine Rückfrage beim Entfernen.
|
||||||
//
|
//
|
||||||
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
|
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
|
||||||
@@ -88,8 +88,8 @@ struct DownloadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var active: [DownloadItem] {
|
private var active: [DownloadItem] {
|
||||||
@@ -125,7 +125,7 @@ struct DownloadsView: View {
|
|||||||
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
|
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
|
||||||
private struct StorageSection: View {
|
private struct StorageSection: View {
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var downloads = downloads
|
@Bindable var downloads = downloads
|
||||||
@@ -133,10 +133,10 @@ private struct StorageSection: View {
|
|||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
Text("downloads.used")
|
Text("downloads.used")
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
|
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let limit = downloads.storageLimitBytes, limit > 0 {
|
if let limit = downloads.storageLimitBytes, limit > 0 {
|
||||||
@@ -157,20 +157,20 @@ private struct DownloadRow: View {
|
|||||||
let onRemove: () -> Void
|
let onRemove: () -> Void
|
||||||
|
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let subtitle = item.subtitle {
|
if let subtitle = item.subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,19 +190,19 @@ private struct DownloadRow: View {
|
|||||||
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
|
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
|
||||||
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
|
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
|
||||||
)
|
)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let message = item.failureMessage {
|
if let message = item.failureMessage {
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyDestructive)
|
.foregroundStyle(Color.haloDestructive)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, FlixySpacing.tight)
|
.padding(.vertical, HaloSpacing.tight)
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
.swipeActions(edge: .trailing) {
|
.swipeActions(edge: .trailing) {
|
||||||
Button("action.delete", role: .destructive, action: onRemove)
|
Button("action.delete", role: .destructive, action: onRemove)
|
||||||
}
|
}
|
||||||
@@ -240,18 +240,18 @@ private struct DownloadRow: View {
|
|||||||
|
|
||||||
case .completed:
|
case .completed:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.accessibilityLabel(Text("downloads.available"))
|
.accessibilityLabel(Text("downloads.available"))
|
||||||
|
|
||||||
case .queued, .waiting:
|
case .queued, .waiting:
|
||||||
Image(systemName: "clock")
|
Image(systemName: "clock")
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.accessibilityLabel(Text("downloads.queued"))
|
.accessibilityLabel(Text("downloads.queued"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var progress: some View {
|
private var progress: some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.tight) {
|
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
|
||||||
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
|
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
|
||||||
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
|
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
|
||||||
// Download.
|
// Download.
|
||||||
@@ -273,17 +273,17 @@ private struct DownloadRow: View {
|
|||||||
// gemeinsames Mass für beide Zahlen (siehe `unit`), und die
|
// gemeinsames Mass für beide Zahlen (siehe `unit`), und die
|
||||||
// Prozente am rechten Rand — die wachsen nach innen und schieben
|
// Prozente am rechten Rand — die wachsen nach innen und schieben
|
||||||
// nichts.
|
// nichts.
|
||||||
HStack(alignment: .firstTextBaseline, spacing: FlixySpacing.snug) {
|
HStack(alignment: .firstTextBaseline, spacing: HaloSpacing.snug) {
|
||||||
Text(statusLine)
|
Text(statusLine)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: FlixySpacing.snug)
|
Spacer(minLength: HaloSpacing.snug)
|
||||||
if let percentage {
|
if let percentage {
|
||||||
Text(percentage)
|
Text(percentage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,28 +387,28 @@ private struct DownloadRow: View {
|
|||||||
private struct HoldNotice: View {
|
private struct HoldNotice: View {
|
||||||
let hold: TransferHold
|
let hold: TransferHold
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
HStack(alignment: .top, spacing: HaloSpacing.regular) {
|
||||||
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
|
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
|
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
Text(hold == .offline
|
Text(hold == .offline
|
||||||
? "downloads.hold.offline.description"
|
? "downloads.hold.offline.description"
|
||||||
: "downloads.hold.wifi.description")
|
: "downloads.hold.wifi.description")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, FlixySpacing.tight)
|
.padding(.vertical, HaloSpacing.tight)
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
|
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
|
||||||
@@ -65,8 +65,8 @@ struct HeroCarousel: View {
|
|||||||
@State private var isHoveringDots = false
|
@State private var isHoveringDots = false
|
||||||
|
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
@Environment(TopChrome.self) private var chrome
|
@Environment(TopChrome.self) private var chrome
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
@@ -118,7 +118,7 @@ struct HeroCarousel: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: FlixySpacing.regular) {
|
VStack(spacing: HaloSpacing.regular) {
|
||||||
heroSection
|
heroSection
|
||||||
// Die Höhe: fast das ganze Fenster.
|
// Die Höhe: fast das ganze Fenster.
|
||||||
//
|
//
|
||||||
@@ -189,7 +189,7 @@ struct HeroCarousel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isMac: Bool { FlixyPlatform.isMac }
|
private var isMac: Bool { HaloPlatform.isMac }
|
||||||
|
|
||||||
/// Die beiden Pfeile links und rechts.
|
/// Die beiden Pfeile links und rechts.
|
||||||
///
|
///
|
||||||
@@ -203,7 +203,7 @@ struct HeroCarousel: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
arrow(symbol: "chevron.right", direction: 1)
|
arrow(symbol: "chevron.right", direction: 1)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.opacity(isPointerInside ? 1 : 0)
|
.opacity(isPointerInside ? 1 : 0)
|
||||||
.animation(.easeOut(duration: 0.2), value: isPointerInside)
|
.animation(.easeOut(duration: 0.2), value: isPointerInside)
|
||||||
.onContinuousHover { phase in
|
.onContinuousHover { phase in
|
||||||
@@ -219,7 +219,7 @@ struct HeroCarousel: View {
|
|||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 18, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(FlixySpacing.regular)
|
.padding(HaloSpacing.regular)
|
||||||
.background(.black.opacity(0.45), in: .circle)
|
.background(.black.opacity(0.45), in: .circle)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -237,10 +237,10 @@ struct HeroCarousel: View {
|
|||||||
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
|
/// 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.
|
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
|
||||||
private var indicator: some View {
|
private var indicator: some View {
|
||||||
HStack(spacing: isEnlarged ? FlixySpacing.regular : FlixySpacing.snug) {
|
HStack(spacing: isEnlarged ? HaloSpacing.regular : HaloSpacing.snug) {
|
||||||
ForEach(items.indices, id: \.self) { position in
|
ForEach(items.indices, id: \.self) { position in
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4))
|
.fill(position == currentIndex ? accent : Color.haloTextMuted.opacity(0.4))
|
||||||
.frame(
|
.frame(
|
||||||
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
|
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
|
||||||
height: isEnlarged ? 12 : 6
|
height: isEnlarged ? 12 : 6
|
||||||
@@ -255,7 +255,7 @@ struct HeroCarousel: View {
|
|||||||
}
|
}
|
||||||
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
|
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
|
||||||
// beim Annähern etwas bewirken, nicht erst beim Treffen.
|
// beim Annähern etwas bewirken, nicht erst beim Treffen.
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.onContinuousHover { phase in
|
.onContinuousHover { phase in
|
||||||
guard isMac else { return }
|
guard isMac else { return }
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Der Aufmacher oben auf der Startseite.
|
/// Der Aufmacher oben auf der Startseite.
|
||||||
@@ -12,10 +12,10 @@ struct HeroSection: View {
|
|||||||
/// Wie weit es dabei hereinrutscht.
|
/// Wie weit es dabei hereinrutscht.
|
||||||
var incomingOffset: CGFloat = 0
|
var incomingOffset: CGFloat = 0
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(QuickPlay.self) private var quickPlay
|
@Environment(QuickPlay.self) private var quickPlay
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -56,21 +56,21 @@ struct HeroSection: View {
|
|||||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||||
} label: {
|
} label: {
|
||||||
Label(playLabel, systemImage: "play.fill")
|
Label(playLabel, systemImage: "play.fill")
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.padding(.horizontal, FlixySpacing.loose)
|
.padding(.horizontal, HaloSpacing.loose)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
|
|
||||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||||
Label("hero.details", systemImage: "info.circle")
|
Label("hero.details", systemImage: "info.circle")
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.padding(.horizontal, FlixySpacing.loose)
|
.padding(.horizontal, HaloSpacing.loose)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(Color.flixyTextSecondary)
|
.tint(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||||
@@ -9,7 +9,7 @@ import MediaKit
|
|||||||
/// Reihen zeigt und die Logik sonst zweimal dastünde. Kopiert wäre dabei
|
/// Reihen zeigt und die Logik sonst zweimal dastünde. Kopiert wäre dabei
|
||||||
/// ausgerechnet der Teil gewesen, an dem schon mehrere Fehler steckten.
|
/// ausgerechnet der Teil gewesen, an dem schon mehrere Fehler steckten.
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
@State private var feed = HomeFeed()
|
@State private var feed = HomeFeed()
|
||||||
|
|
||||||
@@ -41,17 +41,17 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
|
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
|
||||||
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
|
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
|
||||||
// ohnehin mehr.
|
// ohnehin mehr.
|
||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
|
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
|
||||||
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
|
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
|
||||||
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
|
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
|
||||||
// Aufmacher.
|
// Aufmacher.
|
||||||
.flixyClearToolbarBackground()
|
.haloClearToolbarBackground()
|
||||||
// Gar keine Navigationsleiste auf der Startseite.
|
// Gar keine Navigationsleiste auf der Startseite.
|
||||||
//
|
//
|
||||||
// Sie war leer — kein Titel, keine Knöpfe — und belegte trotzdem ihre
|
// Sie war leer — kein Titel, keine Knöpfe — und belegte trotzdem ihre
|
||||||
@@ -63,12 +63,12 @@ struct HomeView: View {
|
|||||||
.refreshable { await feed.load(from: session.backend) }
|
.refreshable { await feed.load(from: session.backend) }
|
||||||
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
||||||
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
||||||
.environment(\.flixyItemChanged, { await feed.load(from: session.backend) })
|
.environment(\.haloItemChanged, { await feed.load(from: session.backend) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||||
let featured = feed.heroItems(for: layout)
|
let featured = feed.heroItems(for: layout)
|
||||||
if !featured.isEmpty {
|
if !featured.isEmpty {
|
||||||
HeroCarousel(items: featured)
|
HeroCarousel(items: featured)
|
||||||
@@ -78,9 +78,9 @@ struct HomeView: View {
|
|||||||
MediaRow(row: hub)
|
MediaRow(row: hub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, FlixySpacing.section)
|
.padding(.bottom, HaloSpacing.section)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine waagerechte Reihe von Titeln.
|
/// Eine waagerechte Reihe von Titeln.
|
||||||
@@ -8,7 +8,7 @@ struct MediaRow: View {
|
|||||||
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
||||||
var opensDetails: Bool = false
|
var opensDetails: Bool = false
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
|
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
|
|
||||||
@@ -23,19 +23,19 @@ struct MediaRow: View {
|
|||||||
private var posterWidth: CGFloat { 116 * layout.cardScale }
|
private var posterWidth: CGFloat { 116 * layout.cardScale }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
Text(row.title)
|
Text(row.title)
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
|
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
LazyHStack(spacing: FlixySpacing.regular) {
|
LazyHStack(spacing: HaloSpacing.regular) {
|
||||||
ForEach(row.items) { item in
|
ForEach(row.items) { item in
|
||||||
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
|
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
|
||||||
// bleibt an beliebiger Stelle stehen.
|
// bleibt an beliebiger Stelle stehen.
|
||||||
.scrollTargetLayout()
|
.scrollTargetLayout()
|
||||||
+13
-13
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln.
|
/// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln.
|
||||||
@@ -20,7 +20,7 @@ import MediaKit
|
|||||||
/// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst
|
/// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst
|
||||||
/// gezeichnete Leiste kennt diese Grenze nicht.
|
/// gezeichnete Leiste kennt diese Grenze nicht.
|
||||||
struct LibraryBrowser: View {
|
struct LibraryBrowser: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
|
|
||||||
/// Was gerade gezeigt wird.
|
/// Was gerade gezeigt wird.
|
||||||
///
|
///
|
||||||
@@ -36,7 +36,7 @@ struct LibraryBrowser: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
inhalt
|
inhalt
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
// **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste
|
// **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste
|
||||||
// über der Leiste, statt dahinter zu verschwinden — und das gilt
|
// über der Leiste, statt dahinter zu verschwinden — und das gilt
|
||||||
// auch für die Ladeanzeige und den Fuss „alles geladen".
|
// auch für die Ladeanzeige und den Fuss „alles geladen".
|
||||||
@@ -86,11 +86,11 @@ struct LibraryBrowser: View {
|
|||||||
}
|
}
|
||||||
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
|
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
|
||||||
// kein nachgebautes Aussehen.
|
// kein nachgebautes Aussehen.
|
||||||
.background { FlixySidebar.surface(in: .capsule) }
|
.background { HaloSidebar.surface(in: .capsule) }
|
||||||
.clipShape(.capsule)
|
.clipShape(.capsule)
|
||||||
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.bottom, FlixySpacing.snug)
|
.padding(.bottom, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var reihe: some View {
|
private var reihe: some View {
|
||||||
@@ -99,8 +99,8 @@ struct LibraryBrowser: View {
|
|||||||
pille(eintrag)
|
pille(eintrag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, HaloSpacing.snug)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pille(_ eintrag: Eintrag) -> some View {
|
private func pille(_ eintrag: Eintrag) -> some View {
|
||||||
@@ -116,12 +116,12 @@ struct LibraryBrowser: View {
|
|||||||
}
|
}
|
||||||
// Kleiner als in der Leiste auf dem iPad: dort stehen drei
|
// Kleiner als in der Leiste auf dem iPad: dort stehen drei
|
||||||
// Einträge, hier bis zu fünf — und auf einem Telefon.
|
// Einträge, hier bis zu fünf — und auf einem Telefon.
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(gewaehlt ? Color.flixyTextPrimary : Color.flixyTextSecondary)
|
.foregroundStyle(gewaehlt ? Color.haloTextPrimary : Color.haloTextSecondary)
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, HaloSpacing.snug)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.background {
|
.background {
|
||||||
if gewaehlt { Capsule().fill(Color.flixyElevated) }
|
if gewaehlt { Capsule().fill(Color.haloElevated) }
|
||||||
}
|
}
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
+7
-7
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Filter und Sortierung einer Mediathek.
|
/// Filter und Sortierung einer Mediathek.
|
||||||
@@ -14,8 +14,8 @@ struct LibraryFilterSheet: View {
|
|||||||
let library: MediaLibrary
|
let library: MediaLibrary
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@State private var genres: [MediaFilterOption] = []
|
@State private var genres: [MediaFilterOption] = []
|
||||||
|
|
||||||
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
|
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
|
||||||
@@ -91,11 +91,11 @@ struct LibraryFilterSheet: View {
|
|||||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.flixyForm()
|
.haloForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("filter.title"))
|
.navigationTitle(Text("filter.title"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("action.cancel") { dismiss() }
|
Button("action.cancel") { dismiss() }
|
||||||
@@ -109,7 +109,7 @@ struct LibraryFilterSheet: View {
|
|||||||
}
|
}
|
||||||
.task { await loadGenres() }
|
.task { await loadGenres() }
|
||||||
}
|
}
|
||||||
.flixyResizableSheet()
|
.haloResizableSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadGenres() async {
|
private func loadGenres() async {
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine Mediathek zum Durchblättern.
|
/// Eine Mediathek zum Durchblättern.
|
||||||
struct LibraryView: View {
|
struct LibraryView: View {
|
||||||
let library: MediaLibrary
|
let library: MediaLibrary
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
||||||
/// zwischen Raster und Liste.
|
/// zwischen Raster und Liste.
|
||||||
@Environment(HomeLayoutSettings.self) private var sizing
|
@Environment(HomeLayoutSettings.self) private var sizing
|
||||||
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
|
@AppStorage("halo.library.layout") private var layout: LibraryLayout = .grid
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
@State private var items: [MediaItem] = []
|
@State private var items: [MediaItem] = []
|
||||||
@@ -59,9 +59,9 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(library.title)
|
.navigationTitle(library.title)
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.sheet(isPresented: $showsFilters) {
|
.sheet(isPresented: $showsFilters) {
|
||||||
LibraryFilterSheet(query: $query, library: library)
|
LibraryFilterSheet(query: $query, library: library)
|
||||||
@@ -80,7 +80,7 @@ struct LibraryView: View {
|
|||||||
.onChange(of: query) { _, _ in Task { await reload() } }
|
.onChange(of: query) { _, _ in Task { await reload() } }
|
||||||
.refreshable { await reload() }
|
.refreshable { await reload() }
|
||||||
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
||||||
.environment(\.flixyItemChanged, { await reload() })
|
.environment(\.haloItemChanged, { await reload() })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -97,8 +97,8 @@ struct LibraryView: View {
|
|||||||
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
|
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
|
||||||
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
|
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
|
||||||
// ohne dass die Größenklasse abgefragt werden muss.
|
// ohne dass die Größenklasse abgefragt werden muss.
|
||||||
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: FlixySpacing.regular)],
|
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: HaloSpacing.regular)],
|
||||||
spacing: FlixySpacing.loose
|
spacing: HaloSpacing.loose
|
||||||
) {
|
) {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
QuickPlayTile(item: item) {
|
QuickPlayTile(item: item) {
|
||||||
@@ -119,12 +119,12 @@ struct LibraryView: View {
|
|||||||
PlaceholderCard(index: position)
|
PlaceholderCard(index: position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.screenMargin)
|
.padding(HaloSpacing.screenMargin)
|
||||||
|
|
||||||
moreButton.padding(.bottom, FlixySpacing.section)
|
moreButton.padding(.bottom, HaloSpacing.section)
|
||||||
footerCount.padding(.bottom, FlixySpacing.section)
|
footerCount.padding(.bottom, HaloSpacing.section)
|
||||||
}
|
}
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var list: some View {
|
private var list: some View {
|
||||||
@@ -137,19 +137,19 @@ struct LibraryView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.task { await loadMoreIfNeeded(reaching: item) }
|
.task { await loadMoreIfNeeded(reaching: item) }
|
||||||
|
|
||||||
Divider().overlay(Color.flixySeparator)
|
Divider().overlay(Color.haloSeparator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pendingCount > 0 {
|
if pendingCount > 0 {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.padding(.vertical, FlixySpacing.section)
|
.padding(.vertical, HaloSpacing.section)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
moreButton.padding(.vertical, FlixySpacing.section)
|
moreButton.padding(.vertical, HaloSpacing.section)
|
||||||
footerCount.padding(.bottom, FlixySpacing.section)
|
footerCount.padding(.bottom, HaloSpacing.section)
|
||||||
}
|
}
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
|
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
|
||||||
@@ -173,8 +173,8 @@ struct LibraryView: View {
|
|||||||
private var footerCount: some View {
|
private var footerCount: some View {
|
||||||
if let page, !page.hasMore, !items.isEmpty {
|
if let page, !page.hasMore, !items.isEmpty {
|
||||||
Text("library.allLoaded \(items.count)")
|
Text("library.allLoaded \(items.count)")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,10 +183,10 @@ struct LibraryView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var moreButton: some View {
|
private var moreButton: some View {
|
||||||
if loadMoreFailed, let page, page.hasMore {
|
if loadMoreFailed, let page, page.hasMore {
|
||||||
VStack(spacing: FlixySpacing.snug) {
|
VStack(spacing: HaloSpacing.snug) {
|
||||||
Text("library.loadMoreFailed")
|
Text("library.loadMoreFailed")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
Button("library.loadMore") {
|
Button("library.loadMore") {
|
||||||
Task { await loadMore() }
|
Task { await loadMore() }
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ struct LibraryView: View {
|
|||||||
// steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste,
|
// steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste,
|
||||||
// und ein zweiter Weg dorthin wäre nur Verdopplung.
|
// und ein zweiter Weg dorthin wäre nur Verdopplung.
|
||||||
if sizeClass == .compact {
|
if sizeClass == .compact {
|
||||||
ToolbarItem(placement: .flixyTrailing) {
|
ToolbarItem(placement: .haloTrailing) {
|
||||||
NavigationLink(value: SearchRoute()) {
|
NavigationLink(value: SearchRoute()) {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ struct LibraryView: View {
|
|||||||
// oben, und selbst dort wo es das gibt, ist es in einem Raster kaum
|
// 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
|
// zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen
|
||||||
// Knopf und keine Geste.
|
// Knopf und keine Geste.
|
||||||
ToolbarItem(placement: .flixyTrailing) {
|
ToolbarItem(placement: .haloTrailing) {
|
||||||
Button {
|
Button {
|
||||||
Task { await reload() }
|
Task { await reload() }
|
||||||
} label: {
|
} label: {
|
||||||
@@ -228,7 +228,7 @@ struct LibraryView: View {
|
|||||||
.disabled(isLoadingMore)
|
.disabled(isLoadingMore)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .flixyTrailing) {
|
ToolbarItem(placement: .haloTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showsFilters = true
|
showsFilters = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -239,7 +239,7 @@ struct LibraryView: View {
|
|||||||
.accessibilityLabel(Text("library.filters"))
|
.accessibilityLabel(Text("library.filters"))
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .flixyTrailing) {
|
ToolbarItem(placement: .haloTrailing) {
|
||||||
Button {
|
Button {
|
||||||
layout = layout == .grid ? .list : .grid
|
layout = layout == .grid ? .list : .grid
|
||||||
} label: {
|
} label: {
|
||||||
@@ -255,7 +255,7 @@ struct LibraryView: View {
|
|||||||
// Reihe etwas steht, kann nur der entscheiden, der die Reihe
|
// Reihe etwas steht, kann nur der entscheiden, der die Reihe
|
||||||
// schreibt.
|
// schreibt.
|
||||||
if sizeClass == .compact {
|
if sizeClass == .compact {
|
||||||
ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() }
|
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,11 +380,11 @@ enum LibraryLayout: String, CaseIterable {
|
|||||||
/// Eine Zeile in der Listenansicht.
|
/// Eine Zeile in der Listenansicht.
|
||||||
private struct LibraryListRow: View {
|
private struct LibraryListRow: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
FlixyArtwork(
|
HaloArtwork(
|
||||||
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
||||||
aspectRatio: PosterCard.aspectRatio
|
aspectRatio: PosterCard.aspectRatio
|
||||||
)
|
)
|
||||||
@@ -393,13 +393,13 @@ private struct LibraryListRow: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let subtitle = item.cardSubtitle {
|
if let subtitle = item.cardSubtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,12 +407,12 @@ private struct LibraryListRow: View {
|
|||||||
|
|
||||||
if item.isWatched {
|
if item.isWatched {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.accessibilityLabel(Text("item.watched"))
|
.accessibilityLabel(Text("item.watched"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
+12
-12
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Welche Sender überhaupt angezeigt werden.
|
/// Welche Sender überhaupt angezeigt werden.
|
||||||
@@ -12,8 +12,8 @@ struct ChannelSelectionSheet: View {
|
|||||||
let channels: [LiveChannel]
|
let channels: [LiveChannel]
|
||||||
@Binding var selection: Set<String>
|
@Binding var selection: Set<String>
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -28,9 +28,9 @@ struct ChannelSelectionSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(Text("liveTV.selection"))
|
.navigationTitle(Text("liveTV.selection"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .flixyLeading) {
|
ToolbarItem(placement: .haloLeading) {
|
||||||
// „Alle" statt „keine": eine leere Auswahl bedeutet
|
// „Alle" statt „keine": eine leere Auswahl bedeutet
|
||||||
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
|
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
|
||||||
// wäre verwirrend.
|
// wäre verwirrend.
|
||||||
@@ -57,10 +57,10 @@ struct ChannelSelectionSheet: View {
|
|||||||
selection.insert(channel.id)
|
selection.insert(channel.id)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
FlixyArtwork(
|
HaloArtwork(
|
||||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||||
aspectRatio: FlixyAspectRatio.channelLogo,
|
aspectRatio: HaloAspectRatio.channelLogo,
|
||||||
fallbackSymbol: "tv",
|
fallbackSymbol: "tv",
|
||||||
contentMode: .fit
|
contentMode: .fit
|
||||||
)
|
)
|
||||||
@@ -69,17 +69,17 @@ struct ChannelSelectionSheet: View {
|
|||||||
if let number = channel.number {
|
if let number = channel.number {
|
||||||
Text(number)
|
Text(number)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(channel.title)
|
Text(channel.title)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
|
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
|
||||||
.foregroundStyle(isShown(channel) ? accent : Color.flixyTextMuted)
|
.foregroundStyle(isShown(channel) ? accent : Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Live-TV mit Programmführer.
|
/// Live-TV mit Programmführer.
|
||||||
struct LiveTVView: View {
|
struct LiveTVView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@@ -74,7 +74,7 @@ struct LiveTVView: View {
|
|||||||
} description: {
|
} description: {
|
||||||
Text("liveTV.noChannels.description")
|
Text("liveTV.noChannels.description")
|
||||||
} actions: {
|
} actions: {
|
||||||
VStack(spacing: FlixySpacing.regular) {
|
VStack(spacing: HaloSpacing.regular) {
|
||||||
Button("action.retry") { Task { await load() } }
|
Button("action.retry") { Task { await load() } }
|
||||||
searchTrace
|
searchTrace
|
||||||
}
|
}
|
||||||
@@ -107,12 +107,12 @@ struct LiveTVView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("sidebar.liveTV"))
|
.navigationTitle(Text("sidebar.liveTV"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if case .available = availability, !channels.isEmpty {
|
if case .available = availability, !channels.isEmpty {
|
||||||
ToolbarItem(placement: .flixyTrailing) {
|
ToolbarItem(placement: .haloTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showsSelection = true
|
showsSelection = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -120,7 +120,7 @@ struct LiveTVView: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(Text("liveTV.selection"))
|
.accessibilityLabel(Text("liveTV.selection"))
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .flixyTrailing) {
|
ToolbarItem(placement: .haloTrailing) {
|
||||||
Picker("liveTV.mode", selection: $mode) {
|
Picker("liveTV.mode", selection: $mode) {
|
||||||
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
|
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
|
||||||
Image(systemName: "list.bullet").tag(LiveTVMode.channels)
|
Image(systemName: "list.bullet").tag(LiveTVMode.channels)
|
||||||
@@ -169,11 +169,11 @@ struct LiveTVView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var searchTrace: some View {
|
private var searchTrace: some View {
|
||||||
if !trace.isEmpty {
|
if !trace.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||||
if let channelSource {
|
if let channelSource {
|
||||||
Text(channelSource)
|
Text(channelSource)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -181,7 +181,7 @@ struct LiveTVView: View {
|
|||||||
ForEach(trace) { probe in
|
ForEach(trace) { probe in
|
||||||
Text("\(probe.path) → \(probe.result)")
|
Text("\(probe.path) → \(probe.result)")
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.system(size: 10, design: .monospaced))
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
@@ -191,18 +191,18 @@ struct LiveTVView: View {
|
|||||||
.frame(maxHeight: 200)
|
.frame(maxHeight: 200)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
FlixyClipboard.copy(
|
HaloClipboard.copy(
|
||||||
trace
|
trace
|
||||||
.map { "\($0.path) → \($0.result)" }
|
.map { "\($0.path) → \($0.result)" }
|
||||||
.joined(separator: "\n")
|
.joined(separator: "\n")
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,11 +216,11 @@ struct LiveTVView: View {
|
|||||||
isTuning: isTuning == channel.id,
|
isTuning: isTuning == channel.id,
|
||||||
onTune: { Task { await tune(channel) } }
|
onTune: { Task { await tune(channel) } }
|
||||||
)
|
)
|
||||||
Divider().overlay(Color.flixySeparator)
|
Divider().overlay(Color.haloSeparator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
@@ -329,43 +329,43 @@ private struct ChannelRow: View {
|
|||||||
let isTuning: Bool
|
let isTuning: Bool
|
||||||
let onTune: () -> Void
|
let onTune: () -> Void
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
private var current: LiveProgram? { programs.first { $0.isRunning() } }
|
private var current: LiveProgram? { programs.first { $0.isRunning() } }
|
||||||
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
|
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTune) {
|
Button(action: onTune) {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
|
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
|
||||||
// fast nie quadratisch, und formatfüllend fehlten links und
|
// fast nie quadratisch, und formatfüllend fehlten links und
|
||||||
// rechts genau die Teile, an denen man den Sender erkennt.
|
// rechts genau die Teile, an denen man den Sender erkennt.
|
||||||
FlixyArtwork(
|
HaloArtwork(
|
||||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||||
aspectRatio: FlixyAspectRatio.channelLogo,
|
aspectRatio: HaloAspectRatio.channelLogo,
|
||||||
fallbackSymbol: "tv",
|
fallbackSymbol: "tv",
|
||||||
contentMode: .fit
|
contentMode: .fit
|
||||||
)
|
)
|
||||||
.frame(width: 56, height: 34)
|
.frame(width: 56, height: 34)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
if let number = channel.number {
|
if let number = channel.number {
|
||||||
Text(number)
|
Text(number)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
Text(channel.title)
|
Text(channel.title)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let current {
|
if let current {
|
||||||
Text(current.title)
|
Text(current.title)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
// Der Balken zeigt, wie weit die Sendung ist — beim
|
// Der Balken zeigt, wie weit die Sendung ist — beim
|
||||||
// Umschalten die nützlichste Angabe überhaupt.
|
// Umschalten die nützlichste Angabe überhaupt.
|
||||||
@@ -373,14 +373,14 @@ private struct ChannelRow: View {
|
|||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
} else {
|
} else {
|
||||||
Text("liveTV.noProgram")
|
Text("liveTV.noProgram")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let next {
|
if let next {
|
||||||
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
|
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,8 +395,8 @@ private struct ChannelRow: View {
|
|||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
.padding(.vertical, HaloSpacing.regular)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -427,43 +427,43 @@ private struct TuneFailureSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
Text(failure.channel)
|
Text(failure.channel)
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
Text(failure.reason)
|
Text(failure.reason)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
|
|
||||||
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
|
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
|
||||||
Text(line)
|
Text(line)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
FlixyClipboard.copy(failure.trace.joined(separator: "\n"))
|
HaloClipboard.copy(failure.trace.joined(separator: "\n"))
|
||||||
} label: {
|
} label: {
|
||||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.screenMargin)
|
.padding(HaloSpacing.screenMargin)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("liveTV.tuneFailed"))
|
.navigationTitle(Text("liveTV.tuneFailed"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.close") { dismiss() }
|
Button("action.close") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flixyResizableSheet()
|
.haloResizableSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
|
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
|
||||||
@@ -34,7 +34,7 @@ struct ProgramGuide: View {
|
|||||||
var onRecord: ((LiveProgram) -> Void)?
|
var onRecord: ((LiveProgram) -> Void)?
|
||||||
var onRecordSeries: ((LiveProgram) -> Void)?
|
var onRecordSeries: ((LiveProgram) -> Void)?
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
@State private var scrollX: CGFloat = 0
|
@State private var scrollX: CGFloat = 0
|
||||||
@State private var selection: GuideSelection?
|
@State private var selection: GuideSelection?
|
||||||
@@ -50,7 +50,7 @@ struct ProgramGuide: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
axis
|
axis
|
||||||
Divider().overlay(Color.flixySeparator)
|
Divider().overlay(Color.haloSeparator)
|
||||||
|
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
@@ -65,7 +65,7 @@ struct ProgramGuide: View {
|
|||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.sheet(item: $selection) { selection in
|
.sheet(item: $selection) { selection in
|
||||||
ProgramDetailSheet(
|
ProgramDetailSheet(
|
||||||
channel: selection.channel,
|
channel: selection.channel,
|
||||||
@@ -104,12 +104,12 @@ struct ProgramGuide: View {
|
|||||||
private var axis: some View {
|
private var axis: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("liveTV.channelCount \(channels.count)")
|
Text("liveTV.channelCount \(channels.count)")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
// Der Abstand gehört in die Spaltenbreite hinein, nicht
|
// Der Abstand gehört in die Spaltenbreite hinein, nicht
|
||||||
// dazu: außen addiert er sich, und die ganze Zeitachse steht
|
// dazu: außen addiert er sich, und die ganze Zeitachse steht
|
||||||
// acht Punkte neben dem Programm darunter.
|
// acht Punkte neben dem Programm darunter.
|
||||||
.padding(.leading, FlixySpacing.snug)
|
.padding(.leading, HaloSpacing.snug)
|
||||||
.frame(width: channelWidth, alignment: .leading)
|
.frame(width: channelWidth, alignment: .leading)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ struct ProgramGuide: View {
|
|||||||
ForEach(layout.ticks, id: \.self) { tick in
|
ForEach(layout.ticks, id: \.self) { tick in
|
||||||
Text(tick.formatted(date: .omitted, time: .shortened))
|
Text(tick.formatted(date: .omitted, time: .shortened))
|
||||||
.font(.system(.caption2, design: .rounded))
|
.font(.system(.caption2, design: .rounded))
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.frame(width: layout.tickWidth, alignment: .leading)
|
.frame(width: layout.tickWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,7 @@ struct ProgramGuide: View {
|
|||||||
.clipped()
|
.clipped()
|
||||||
}
|
}
|
||||||
.frame(height: axisHeight)
|
.frame(height: axisHeight)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Was auf diesem Sender gerade läuft.
|
/// Was auf diesem Sender gerade läuft.
|
||||||
@@ -147,20 +147,20 @@ struct ProgramGuide: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(channels) { channel in
|
ForEach(channels) { channel in
|
||||||
Button { onTune(channel, running(on: channel)) } label: {
|
Button { onTune(channel, running(on: channel)) } label: {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
FlixyArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
|
HaloArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
.clipShape(.rect(cornerRadius: 4))
|
.clipShape(.rect(cornerRadius: 4))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(channel.title)
|
Text(channel.title)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
if let number = channel.number {
|
if let number = channel.number {
|
||||||
Text(number)
|
Text(number)
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ struct ProgramGuide: View {
|
|||||||
ProgressView().controlSize(.mini)
|
ProgressView().controlSize(.mini)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, HaloSpacing.snug)
|
||||||
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
|
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
|
||||||
.overlay(alignment: .bottom) { rowSeparator }
|
.overlay(alignment: .bottom) { rowSeparator }
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
@@ -179,7 +179,7 @@ struct ProgramGuide: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: channelWidth)
|
.frame(width: channelWidth)
|
||||||
.background(Color.flixyElevated.opacity(0.4))
|
.background(Color.haloElevated.opacity(0.4))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var lanes: some View {
|
private var lanes: some View {
|
||||||
@@ -217,9 +217,9 @@ struct ProgramGuide: View {
|
|||||||
return ZStack(alignment: .topLeading) {
|
return ZStack(alignment: .topLeading) {
|
||||||
if entries.isEmpty {
|
if entries.isEmpty {
|
||||||
Text("liveTV.noProgram")
|
Text("liveTV.noProgram")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.frame(height: rowHeight, alignment: .leading)
|
.frame(height: rowHeight, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ struct ProgramGuide: View {
|
|||||||
/// Zeile, die man antippt, gehört dann zum Nachbarsender.
|
/// Zeile, die man antippt, gehört dann zum Nachbarsender.
|
||||||
private var rowSeparator: some View {
|
private var rowSeparator: some View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.flixySeparator)
|
.fill(Color.haloSeparator)
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,21 +250,21 @@ struct ProgramGuide: View {
|
|||||||
return Button { selection = GuideSelection(channel: channel, program: program) } label: {
|
return Button { selection = GuideSelection(channel: channel, program: program) } label: {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(program.title)
|
Text(program.title)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
|
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
|
||||||
.font(.system(.caption2, design: .rounded))
|
.font(.system(.caption2, design: .rounded))
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, HaloSpacing.snug)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
|
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
|
||||||
.background(
|
.background(
|
||||||
running ? accent.opacity(0.28) : Color.flixyElevated,
|
running ? accent.opacity(0.28) : Color.haloElevated,
|
||||||
in: .rect(cornerRadius: FlixyRadius.control)
|
in: .rect(cornerRadius: HaloRadius.control)
|
||||||
)
|
)
|
||||||
.overlay(alignment: .bottomLeading) {
|
.overlay(alignment: .bottomLeading) {
|
||||||
// Der Balken zeigt, wie weit die laufende Sendung ist — beim
|
// Der Balken zeigt, wie weit die laufende Sendung ist — beim
|
||||||
@@ -299,45 +299,45 @@ private struct ProgramDetailSheet: View {
|
|||||||
let onRecord: (() -> Void)?
|
let onRecord: (() -> Void)?
|
||||||
let onRecordSeries: (() -> Void)?
|
let onRecordSeries: (() -> Void)?
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
FlixyArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
|
HaloArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
.clipShape(.rect(cornerRadius: 6))
|
.clipShape(.rect(cornerRadius: 6))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(channel.title)
|
Text(channel.title)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
|
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(program.title)
|
Text(program.title)
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
if let subtitle = program.subtitle {
|
if let subtitle = program.subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isRunning {
|
if isRunning {
|
||||||
Button { onTune(); dismiss() } label: {
|
Button { onTune(); dismiss() } label: {
|
||||||
Label("liveTV.tune", systemImage: "play.fill")
|
Label("liveTV.tune", systemImage: "play.fill")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
@@ -348,15 +348,15 @@ private struct ProgramDetailSheet: View {
|
|||||||
// ohne Aufnahmen fehlt hier schlicht alles.
|
// ohne Aufnahmen fehlt hier schlicht alles.
|
||||||
if program.seriesTimerID != nil {
|
if program.seriesTimerID != nil {
|
||||||
Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle")
|
Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
} else if onRecord != nil || onRecordSeries != nil {
|
} else if onRecord != nil || onRecordSeries != nil {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
if let onRecord {
|
if let onRecord {
|
||||||
Button { onRecord(); dismiss() } label: {
|
Button { onRecord(); dismiss() } label: {
|
||||||
Label("liveTV.record", systemImage: "record.circle")
|
Label("liveTV.record", systemImage: "record.circle")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@ private struct ProgramDetailSheet: View {
|
|||||||
Button { onRecordSeries(); dismiss() } label: {
|
Button { onRecordSeries(); dismiss() } label: {
|
||||||
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
|
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
@@ -373,20 +373,20 @@ private struct ProgramDetailSheet: View {
|
|||||||
|
|
||||||
if let summary = program.summary, !summary.isEmpty {
|
if let summary = program.summary, !summary.isEmpty {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.screenMargin)
|
.padding(HaloSpacing.screenMargin)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.close") { dismiss() }
|
Button("action.close") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flixyResizableSheet()
|
.haloResizableSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Aufnahmen und was noch vorgemerkt ist.
|
/// Aufnahmen und was noch vorgemerkt ist.
|
||||||
@@ -12,7 +12,7 @@ import MediaKit
|
|||||||
/// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine
|
/// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine
|
||||||
/// stellt, stellt meist gleich die andere mit.
|
/// stellt, stellt meist gleich die andere mit.
|
||||||
struct RecordingsView: View {
|
struct RecordingsView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
|
|
||||||
@State private var recordings: [LiveRecording] = []
|
@State private var recordings: [LiveRecording] = []
|
||||||
@State private var timers: [LiveTimer] = []
|
@State private var timers: [LiveTimer] = []
|
||||||
@@ -47,7 +47,7 @@ struct RecordingsView: View {
|
|||||||
list
|
list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
.refreshable { await load() }
|
.refreshable { await load() }
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
@@ -90,7 +90,7 @@ struct RecordingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
} header: {
|
} header: {
|
||||||
Text("liveTV.recordings")
|
Text("liveTV.recordings")
|
||||||
}
|
}
|
||||||
@@ -118,23 +118,23 @@ struct RecordingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
} header: {
|
} header: {
|
||||||
Text("liveTV.scheduled")
|
Text("liveTV.scheduled")
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Zeilen
|
// MARK: - Zeilen
|
||||||
|
|
||||||
private func row(_ recording: LiveRecording) -> some View {
|
private func row(_ recording: LiveRecording) -> some View {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
FlixyArtwork(
|
HaloArtwork(
|
||||||
url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120),
|
url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120),
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
fallbackSymbol: "recordingtape",
|
fallbackSymbol: "recordingtape",
|
||||||
@@ -145,23 +145,23 @@ struct RecordingsView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(recording.title)
|
Text(recording.title)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
HStack(spacing: FlixySpacing.tight) {
|
HStack(spacing: HaloSpacing.tight) {
|
||||||
if recording.isInProgress {
|
if recording.isInProgress {
|
||||||
// Eine laufende Aufnahme ist keine fertige: sie wächst
|
// Eine laufende Aufnahme ist keine fertige: sie wächst
|
||||||
// noch, und wer sie jetzt öffnet, sieht ein Ende, das
|
// noch, und wer sie jetzt öffnet, sieht ein Ende, das
|
||||||
// nicht das Ende ist.
|
// nicht das Ende ist.
|
||||||
Label("liveTV.recording.inProgress", systemImage: "record.circle")
|
Label("liveTV.recording.inProgress", systemImage: "record.circle")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyDestructive)
|
.foregroundStyle(Color.haloDestructive)
|
||||||
}
|
}
|
||||||
if let subtitle = subtitle(for: recording) {
|
if let subtitle = subtitle(for: recording) {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,29 +175,29 @@ struct RecordingsView: View {
|
|||||||
private func timerRow(_ timer: LiveTimer) -> some View {
|
private func timerRow(_ timer: LiveTimer) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(timer.title)
|
Text(timer.title)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
|
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
|
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
Image(systemName: "rectangle.stack.badge.record")
|
Image(systemName: "rectangle.stack.badge.record")
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(timer.name)
|
Text(timer.name)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
|
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Der Ladezustand eines Bildschirms.
|
/// Der Ladezustand eines Bildschirms.
|
||||||
///
|
///
|
||||||
@@ -53,20 +52,17 @@ extension LoadState {
|
|||||||
|
|
||||||
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
|
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
|
||||||
///
|
///
|
||||||
/// **Eine** Stelle. Es gab davon zwei — hier und in `FlixySession` —, und sie
|
/// **Eine** Stelle. Es gab davon zwei — hier und in `HaloSession` —, und sie
|
||||||
/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
|
/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
|
||||||
/// Bildschirm eine andere Erklärung.
|
/// Bildschirm eine andere Erklärung.
|
||||||
enum ErrorText {
|
enum ErrorText {
|
||||||
static func describe(_ error: any Error) -> String {
|
static func describe(_ error: any Error) -> String {
|
||||||
// Die neutrale Form zuerst, weil die Ansichten nur noch sie sehen.
|
// Die neutrale Form zuerst: die Ansichten sehen nur noch sie, und
|
||||||
// Plex' eigener Fehler kommt nur noch dort an, wo unmittelbar mit ihm
|
// jeder Server übersetzt seine eigenen Fehler schon an der Grenze
|
||||||
// gesprochen wird — Player, Downloads, Live-TV.
|
// seines Moduls dorthin.
|
||||||
if let backendError = error as? MediaBackendError {
|
if let backendError = error as? MediaBackendError {
|
||||||
return describe(backendError)
|
return describe(backendError)
|
||||||
}
|
}
|
||||||
if let plexError = error as? PlexError {
|
|
||||||
return describe(plexError.asMediaBackendError)
|
|
||||||
}
|
|
||||||
return error.localizedDescription
|
return error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
+6
-6
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// Der erste Buchstabe auf einer Scheibe.
|
/// Der erste Buchstabe auf einer Scheibe.
|
||||||
///
|
///
|
||||||
@@ -16,7 +16,7 @@ struct AccountAvatar: View {
|
|||||||
let name: String
|
let name: String
|
||||||
var diameter: CGFloat = 26
|
var diameter: CGFloat = 26
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(name.prefix(1).uppercased())
|
Text(name.prefix(1).uppercased())
|
||||||
@@ -25,7 +25,7 @@ struct AccountAvatar: View {
|
|||||||
// Kleinschrift auf einer grossen Scheibe sieht aus wie ein
|
// Kleinschrift auf einer grossen Scheibe sieht aus wie ein
|
||||||
// Fehler.
|
// Fehler.
|
||||||
.font(.system(size: diameter * 0.46, weight: .semibold))
|
.font(.system(size: diameter * 0.46, weight: .semibold))
|
||||||
.foregroundStyle(Color.flixyCanvas)
|
.foregroundStyle(Color.haloCanvas)
|
||||||
.frame(width: diameter, height: diameter)
|
.frame(width: diameter, height: diameter)
|
||||||
.background(accent, in: .circle)
|
.background(accent, in: .circle)
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ extension EnvironmentValues {
|
|||||||
/// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs.
|
/// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs.
|
||||||
/// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst
|
/// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst
|
||||||
/// als Argument durchgereicht bekommen.
|
/// als Argument durchgereicht bekommen.
|
||||||
@Entry var flixyOpenSettings: () -> Void = {}
|
@Entry var haloOpenSettings: () -> Void = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Das Monogramm als Knopf für eine Werkzeugleiste.
|
/// Das Monogramm als Knopf für eine Werkzeugleiste.
|
||||||
@@ -48,8 +48,8 @@ extension EnvironmentValues {
|
|||||||
/// damit **vor** Suche, Filter und Ansicht statt dahinter. Wo in der Reihe es
|
/// damit **vor** Suche, Filter und Ansicht statt dahinter. Wo in der Reihe es
|
||||||
/// steht, kann nur der entscheiden, der die Reihe schreibt.
|
/// steht, kann nur der entscheiden, der die Reihe schreibt.
|
||||||
struct AccountToolbarButton: View {
|
struct AccountToolbarButton: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.flixyOpenSettings) private var openSettings
|
@Environment(\.haloOpenSettings) private var openSettings
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: openSettings) {
|
Button(action: openSettings) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// Wer angemeldet ist — unten in der Seitenleiste.
|
/// Wer angemeldet ist — unten in der Seitenleiste.
|
||||||
///
|
///
|
||||||
@@ -22,7 +22,7 @@ import FlixyDesign
|
|||||||
struct AccountBar: View {
|
struct AccountBar: View {
|
||||||
@Binding var selection: SidebarDestination
|
@Binding var selection: SidebarDestination
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@State private var showsSignOutConfirmation = false
|
@State private var showsSignOutConfirmation = false
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ struct AccountBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 240)
|
.frame(minWidth: 240)
|
||||||
.background(Color.flixySurface)
|
.background(Color.haloSurface)
|
||||||
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
|
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
|
||||||
// steht trotzdem da, damit aus dem Popover dort kein
|
// steht trotzdem da, damit aus dem Popover dort kein
|
||||||
// bildschirmfüllendes Sheet wird.
|
// bildschirmfüllendes Sheet wird.
|
||||||
@@ -77,19 +77,19 @@ struct AccountBar: View {
|
|||||||
Button {
|
Button {
|
||||||
isExpanded.toggle()
|
isExpanded.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
AccountAvatar(name: title)
|
AccountAvatar(name: title)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
if let subtitle {
|
if let subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,10 +102,10 @@ struct AccountBar: View {
|
|||||||
// Zeigt nach oben: dorthin geht die Karte auf.
|
// Zeigt nach oben: dorthin geht die Karte auf.
|
||||||
Image(systemName: "chevron.up")
|
Image(systemName: "chevron.up")
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -121,18 +121,18 @@ struct AccountBar: View {
|
|||||||
) -> some View {
|
) -> some View {
|
||||||
Button(action: perform) {
|
Button(action: perform) {
|
||||||
Label(title, systemImage: symbol)
|
Label(title, systemImage: symbol)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(isDestructive ? Color.flixyDestructive : Color.flixyTextPrimary)
|
.foregroundStyle(isDestructive ? Color.haloDestructive : Color.haloTextPrimary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
.padding(.vertical, HaloSpacing.regular)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var separator: some View {
|
private var separator: some View {
|
||||||
Divider().overlay(Color.flixySeparator)
|
Divider().overlay(Color.haloSeparator)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+69
-69
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Wo die Navigation sitzt.
|
/// Wo die Navigation sitzt.
|
||||||
@@ -49,7 +49,7 @@ struct NavigationGroup: Identifiable {
|
|||||||
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
|
/// 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
|
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
|
||||||
/// „umrandet" und „aus Glas".
|
/// „umrandet" und „aus Glas".
|
||||||
struct FlixySidebar: View {
|
struct HaloSidebar: View {
|
||||||
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
|
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
|
||||||
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
|
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
|
||||||
let groups: [NavigationGroup]
|
let groups: [NavigationGroup]
|
||||||
@@ -57,7 +57,7 @@ struct FlixySidebar: View {
|
|||||||
@Binding var placement: NavigationPlacement
|
@Binding var placement: NavigationPlacement
|
||||||
let layout: NavigationLayoutSettings
|
let layout: NavigationLayoutSettings
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
|
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
|
||||||
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
|
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
|
||||||
@State private var collapsed: Set<String> = []
|
@State private var collapsed: Set<String> = []
|
||||||
@@ -80,7 +80,7 @@ struct FlixySidebar: View {
|
|||||||
static func surface(in shape: some InsettableShape) -> some View {
|
static func surface(in shape: some InsettableShape) -> some View {
|
||||||
shape
|
shape
|
||||||
.fill(.ultraThinMaterial)
|
.fill(.ultraThinMaterial)
|
||||||
.overlay { shape.fill(Color.flixyCanvas.opacity(0.55)) }
|
.overlay { shape.fill(Color.haloCanvas.opacity(0.55)) }
|
||||||
.overlay {
|
.overlay {
|
||||||
shape.strokeBorder(
|
shape.strokeBorder(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
@@ -134,10 +134,10 @@ struct FlixySidebar: View {
|
|||||||
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
|
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
|
||||||
if isEditing {
|
if isEditing {
|
||||||
Text("sidebar.editHint")
|
Text("sidebar.editHint")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.bottom, FlixySpacing.snug)
|
.padding(.bottom, HaloSpacing.snug)
|
||||||
|
|
||||||
List {
|
List {
|
||||||
ForEach(groups) { group in
|
ForEach(groups) { group in
|
||||||
@@ -149,8 +149,8 @@ struct FlixySidebar: View {
|
|||||||
} header: {
|
} header: {
|
||||||
if let title = group.title {
|
if let title = group.title {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -162,13 +162,13 @@ struct FlixySidebar: View {
|
|||||||
.environment(\.editMode, .constant(.active))
|
.environment(\.editMode, .constant(.active))
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
ForEach(groups) { group in
|
ForEach(groups) { group in
|
||||||
section(visible(group))
|
section(visible(group))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, HaloSpacing.snug)
|
||||||
.padding(.bottom, FlixySpacing.regular)
|
.padding(.bottom, HaloSpacing.regular)
|
||||||
}
|
}
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
@@ -176,14 +176,14 @@ struct FlixySidebar: View {
|
|||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
Divider().overlay(Color.flixySeparator)
|
Divider().overlay(Color.haloSeparator)
|
||||||
AccountBar(selection: $selection)
|
AccountBar(selection: $selection)
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, HaloSpacing.snug)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.frame(width: Self.width)
|
.frame(width: Self.width)
|
||||||
.background { Self.surface(in: .rect(cornerRadius: FlixyRadius.panel, style: .continuous)) }
|
.background { Self.surface(in: .rect(cornerRadius: HaloRadius.panel, style: .continuous)) }
|
||||||
.clipShape(.rect(cornerRadius: FlixyRadius.panel, style: .continuous))
|
.clipShape(.rect(cornerRadius: HaloRadius.panel, style: .continuous))
|
||||||
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
|
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
|
||||||
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
|
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
|
||||||
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
|
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
|
||||||
@@ -194,18 +194,18 @@ struct FlixySidebar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
|
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
|
||||||
} label: {
|
} label: {
|
||||||
Text(isEditing ? "action.done" : "sidebar.edit")
|
Text(isEditing ? "action.done" : "sidebar.edit")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, HaloSpacing.snug)
|
||||||
.padding(.vertical, FlixySpacing.tight)
|
.padding(.vertical, HaloSpacing.tight)
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(isEditing ? accent : Color.flixyTextSecondary)
|
.foregroundStyle(isEditing ? accent : Color.haloTextSecondary)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
@@ -214,21 +214,21 @@ struct FlixySidebar: View {
|
|||||||
placement = .top
|
placement = .top
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "rectangle.topthird.inset.filled")
|
Image(systemName: "rectangle.topthird.inset.filled")
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.padding(FlixySpacing.tight)
|
.padding(HaloSpacing.tight)
|
||||||
// Ohne eigene Klickfläche reicht das Ziel nur so weit
|
// Ohne eigene Klickfläche reicht das Ziel nur so weit
|
||||||
// wie das Zeichen selbst — auf dem Mac trifft man das
|
// wie das Zeichen selbst — auf dem Mac trifft man das
|
||||||
// mit dem Zeiger kaum.
|
// mit dem Zeiger kaum.
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.accessibilityLabel(Text("sidebar.moveToTop"))
|
.accessibilityLabel(Text("sidebar.moveToTop"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.top, FlixySpacing.regular)
|
.padding(.top, HaloSpacing.regular)
|
||||||
.padding(.bottom, FlixySpacing.snug)
|
.padding(.bottom, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
|
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
|
||||||
@@ -271,16 +271,16 @@ struct FlixySidebar: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
|
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.top, FlixySpacing.snug)
|
.padding(.top, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -301,12 +301,12 @@ struct FlixySidebar: View {
|
|||||||
private func editRow(_ entry: NavigationEntry) -> some View {
|
private func editRow(_ entry: NavigationEntry) -> some View {
|
||||||
let isHidden = layout.isHidden(entry.arrangementID)
|
let isHidden = layout.isHidden(entry.arrangementID)
|
||||||
|
|
||||||
return HStack(spacing: FlixySpacing.snug) {
|
return HStack(spacing: HaloSpacing.snug) {
|
||||||
Button {
|
Button {
|
||||||
layout.setHidden(!isHidden, for: entry.arrangementID)
|
layout.setHidden(!isHidden, for: entry.arrangementID)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
|
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
|
||||||
.foregroundStyle(isHidden ? Color.flixyTextMuted : accent)
|
.foregroundStyle(isHidden ? Color.haloTextMuted : accent)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -318,8 +318,8 @@ struct FlixySidebar: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(isHidden ? Color.flixyTextMuted : Color.flixyTextPrimary)
|
.foregroundStyle(isHidden ? Color.haloTextMuted : Color.haloTextPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ entry: NavigationEntry) -> some View {
|
private func row(_ entry: NavigationEntry) -> some View {
|
||||||
@@ -328,23 +328,23 @@ struct FlixySidebar: View {
|
|||||||
return Button {
|
return Button {
|
||||||
selection = entry.destination
|
selection = entry.destination
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
Image(systemName: entry.symbol)
|
Image(systemName: entry.symbol)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.frame(width: 22, alignment: .center)
|
.frame(width: 22, alignment: .center)
|
||||||
entry.title
|
entry.title
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
// Ein langer Mediathekenname darf die Leiste nicht
|
// Ein langer Mediathekenname darf die Leiste nicht
|
||||||
// breiter machen; sie hat eine feste Breite.
|
// breiter machen; sie hat eine feste Breite.
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.foregroundStyle(isSelected ? accent : Color.flixyTextPrimary)
|
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.background {
|
.background {
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Capsule().fill(Color.flixyElevated)
|
Capsule().fill(Color.haloElevated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
@@ -360,29 +360,29 @@ struct FlixySidebar: View {
|
|||||||
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
|
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
|
||||||
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
|
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
|
||||||
/// hinauswächst, wäre schlechter als ein Menü.
|
/// hinauswächst, wäre schlechter als ein Menü.
|
||||||
struct FlixyTopBar: View {
|
struct HaloTopBar: View {
|
||||||
let groups: [NavigationGroup]
|
let groups: [NavigationGroup]
|
||||||
@Binding var selection: SidebarDestination
|
@Binding var selection: SidebarDestination
|
||||||
@Binding var placement: NavigationPlacement
|
@Binding var placement: NavigationPlacement
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: FlixySpacing.tight) {
|
HStack(spacing: HaloSpacing.tight) {
|
||||||
Button {
|
Button {
|
||||||
placement = .sidebar
|
placement = .sidebar
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "sidebar.leading")
|
Image(systemName: "sidebar.leading")
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.padding(.horizontal, FlixySpacing.regular)
|
.padding(.horizontal, HaloSpacing.regular)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
|
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
|
||||||
// das Zeichen; auf dem Mac war der Umschalter damit
|
// das Zeichen; auf dem Mac war der Umschalter damit
|
||||||
// praktisch nicht zu treffen.
|
// praktisch nicht zu treffen.
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.accessibilityLabel(Text("sidebar.moveToSide"))
|
.accessibilityLabel(Text("sidebar.moveToSide"))
|
||||||
|
|
||||||
ForEach(groups) { group in
|
ForEach(groups) { group in
|
||||||
@@ -395,12 +395,12 @@ struct FlixyTopBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, HaloSpacing.snug)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.background { FlixySidebar.surface(in: .capsule) }
|
.background { HaloSidebar.surface(in: .capsule) }
|
||||||
.clipShape(.capsule)
|
.clipShape(.capsule)
|
||||||
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||||
.padding(.top, FlixySpacing.snug)
|
.padding(.top, HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pill(_ entry: NavigationEntry) -> some View {
|
private func pill(_ entry: NavigationEntry) -> some View {
|
||||||
@@ -410,13 +410,13 @@ struct FlixyTopBar: View {
|
|||||||
selection = entry.destination
|
selection = entry.destination
|
||||||
} label: {
|
} label: {
|
||||||
entry.title
|
entry.title
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundStyle(isSelected ? accent : Color.flixyTextPrimary)
|
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
||||||
.padding(.horizontal, FlixySpacing.loose)
|
.padding(.horizontal, HaloSpacing.loose)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.background {
|
.background {
|
||||||
if isSelected { Capsule().fill(Color.flixyElevated) }
|
if isSelected { Capsule().fill(Color.haloElevated) }
|
||||||
}
|
}
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
@@ -433,21 +433,21 @@ struct FlixyTopBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlixySpacing.tight) {
|
HStack(spacing: HaloSpacing.tight) {
|
||||||
if let title = group.title {
|
if let title = group.title {
|
||||||
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
|
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
|
||||||
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
|
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
|
||||||
// Grund, an einem Wort zu sparen.
|
// Grund, an einem Wort zu sparen.
|
||||||
Text(title).fixedSize()
|
Text(title).fixedSize()
|
||||||
}
|
}
|
||||||
Image(systemName: "chevron.down").font(.flixyCaption)
|
Image(systemName: "chevron.down").font(.haloCaption)
|
||||||
}
|
}
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(containsSelection ? accent : Color.flixyTextPrimary)
|
.foregroundStyle(containsSelection ? accent : Color.haloTextPrimary)
|
||||||
.padding(.horizontal, FlixySpacing.loose)
|
.padding(.horizontal, HaloSpacing.loose)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.background {
|
.background {
|
||||||
if containsSelection { Capsule().fill(Color.flixyElevated) }
|
if containsSelection { Capsule().fill(Color.haloElevated) }
|
||||||
}
|
}
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ import MediaKit
|
|||||||
struct MediaStack<Content: View>: View {
|
struct MediaStack<Content: View>: View {
|
||||||
@ViewBuilder let content: () -> Content
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@State private var path = NavigationPath()
|
@State private var path = NavigationPath()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -31,7 +31,7 @@ struct MediaStack<Content: View>: View {
|
|||||||
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
|
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
|
||||||
.navigationDestination(for: SearchRoute.self) { _ in SearchView() }
|
.navigationDestination(for: SearchRoute.self) { _ in SearchView() }
|
||||||
}
|
}
|
||||||
.environment(\.flixyPush) { ziel in path.append(ziel) }
|
.environment(\.haloPush) { ziel in path.append(ziel) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,5 +45,5 @@ extension EnvironmentValues {
|
|||||||
///
|
///
|
||||||
/// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts
|
/// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts
|
||||||
/// zu schieben, und ein Absturz wäre die schlechtere Antwort.
|
/// zu schieben, und ein Absturz wäre die schlechtere Antwort.
|
||||||
@Entry var flixyPush: (any Hashable) -> Void = { _ in }
|
@Entry var haloPush: (any Hashable) -> Void = { _ in }
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Die Wurzelansicht.
|
/// Die Wurzelansicht.
|
||||||
///
|
///
|
||||||
@@ -11,8 +10,8 @@ import PlexKit
|
|||||||
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
|
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
|
||||||
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
|
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(FlixyTheme.self) private var theme
|
@Environment(HaloTheme.self) private var theme
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@@ -36,13 +35,13 @@ struct RootView: View {
|
|||||||
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
|
/// 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
|
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
|
||||||
/// zum Aufbewahren.
|
/// zum Aufbewahren.
|
||||||
@AppStorage("flixy.navigation.customization")
|
@AppStorage("halo.navigation.customization")
|
||||||
private var customization = TabViewCustomization()
|
private var customization = TabViewCustomization()
|
||||||
|
|
||||||
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
|
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
|
||||||
///
|
///
|
||||||
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
|
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
|
||||||
@AppStorage("flixy.navigation.placement")
|
@AppStorage("halo.navigation.placement")
|
||||||
private var storedPlacement: NavigationPlacement = .sidebar
|
private var storedPlacement: NavigationPlacement = .sidebar
|
||||||
|
|
||||||
/// Dieselbe Wahl, aber als Zustand dieser Ansicht.
|
/// Dieselbe Wahl, aber als Zustand dieser Ansicht.
|
||||||
@@ -86,22 +85,10 @@ struct RootView: View {
|
|||||||
case .launching:
|
case .launching:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
|
|
||||||
case .signedOut, .authenticating:
|
case .signedOut:
|
||||||
SignInView(
|
SignInView()
|
||||||
phase: session.phase,
|
|
||||||
manualCode: session.showsManualCode ? session.pendingCode : nil,
|
|
||||||
onSignIn: { Task { await session.beginSignIn() } },
|
|
||||||
onCancel: { session.cancelSignIn() }
|
|
||||||
)
|
|
||||||
|
|
||||||
case .choosingServer(let servers):
|
|
||||||
NavigationStack {
|
|
||||||
ServerPickerView(servers: servers) { resource in
|
|
||||||
Task { await session.connect(to: resource) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .connecting(let name):
|
case .connecting(let name):
|
||||||
ConnectingView(serverName: name)
|
ConnectingView(serverName: name)
|
||||||
@@ -117,7 +104,7 @@ struct RootView: View {
|
|||||||
library
|
library
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.environment(topChrome)
|
.environment(topChrome)
|
||||||
// **Aus dem Fenster, nicht aus dem Layout.**
|
// **Aus dem Fenster, nicht aus dem Layout.**
|
||||||
//
|
//
|
||||||
@@ -134,7 +121,7 @@ struct RootView: View {
|
|||||||
// Durchgang.
|
// Durchgang.
|
||||||
//
|
//
|
||||||
// **Nicht** die Ursache des Players, der oben und unten hinausging:
|
// **Nicht** die Ursache des Players, der oben und unten hinausging:
|
||||||
// das war die Seitenleiste selbst, siehe `FlixySidebar`. Diese Zeile
|
// das war die Seitenleiste selbst, siehe `HaloSidebar`. Diese Zeile
|
||||||
// spart eine Layoutrunde, mehr nicht.
|
// spart eine Layoutrunde, mehr nicht.
|
||||||
.onGeometryChange(for: CGSize.self) { proxy in
|
.onGeometryChange(for: CGSize.self) { proxy in
|
||||||
proxy.size
|
proxy.size
|
||||||
@@ -142,7 +129,7 @@ struct RootView: View {
|
|||||||
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
|
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
|
||||||
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
||||||
// zuverlässig neu aus, ein Scene-Body nicht.
|
// zuverlässig neu aus, ein Scene-Body nicht.
|
||||||
.flixyAccent(theme)
|
.haloAccent(theme)
|
||||||
.tint(theme.accentColor)
|
.tint(theme.accentColor)
|
||||||
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
|
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
|
||||||
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
|
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
|
||||||
@@ -187,7 +174,7 @@ struct RootView: View {
|
|||||||
// ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer
|
// ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer
|
||||||
// Stunde wieder stand.
|
// Stunde wieder stand.
|
||||||
//
|
//
|
||||||
// Hier und nicht im Koordinator: `FlixyDownloads` hängt bewusst nicht
|
// Hier und nicht im Koordinator: `HaloDownloads` hängt bewusst nicht
|
||||||
// an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht
|
// an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht
|
||||||
// (siehe `TransferHold`). Diese Ansicht kennt beide.
|
// (siehe `TransferHold`). Diese Ansicht kennt beide.
|
||||||
.onChange(of: network.isReachable) { _, erreichbar in
|
.onChange(of: network.isReachable) { _, erreichbar in
|
||||||
@@ -247,10 +234,11 @@ struct RootView: View {
|
|||||||
settings: settings,
|
settings: settings,
|
||||||
languages: languages,
|
languages: languages,
|
||||||
subtitles: subtitleAppearance,
|
subtitles: subtitleAppearance,
|
||||||
// **Nicht mehr über `server`.** Der ist ein `PlexServer` und bei
|
// **Vom Server erfragt, nicht aus der Adresse geschlossen.** Wer
|
||||||
// Jellyfin immer `nil` — damit galt dort jede Wiedergabe als
|
// über einen Reverse Proxy geht, erreicht seinen Server auch von
|
||||||
// entfernt, auch zuhause am Kabel, und „Auto" hätte ein
|
// der Couch aus unter einem öffentlichen Namen — ihn deshalb als
|
||||||
// 67-Mbit-Remux ohne Not heruntergerechnet.
|
// entfernt zu führen hiesse, „Auto" würde ein 67-Mbit-Remux
|
||||||
|
// zuhause am Kabel ohne Not herunterrechnen.
|
||||||
isLocalConnection: session.isLocalConnection,
|
isLocalConnection: session.isLocalConnection,
|
||||||
connectionDescription: session.connectionDescription,
|
connectionDescription: session.connectionDescription,
|
||||||
unavailableMessage: session.playbackUnavailableMessage
|
unavailableMessage: session.playbackUnavailableMessage
|
||||||
@@ -301,7 +289,7 @@ struct RootView: View {
|
|||||||
.overlay(alignment: .topTrailing) { profileButton }
|
.overlay(alignment: .topTrailing) { profileButton }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.customizationID("flixy.tab.home")
|
.customizationID("halo.tab.home")
|
||||||
|
|
||||||
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
|
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
|
||||||
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
|
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
|
||||||
@@ -319,12 +307,12 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
sidebarLabel(Text(library.title), symbol: library.kind.symbol)
|
sidebarLabel(Text(library.title), symbol: library.kind.symbol)
|
||||||
}
|
}
|
||||||
.customizationID("flixy.tab.library.\(library.id)")
|
.customizationID("halo.tab.library.\(library.id)")
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("sidebar.libraries")
|
Text("sidebar.libraries")
|
||||||
}
|
}
|
||||||
.customizationID("flixy.tab.libraries")
|
.customizationID("halo.tab.libraries")
|
||||||
|
|
||||||
if session.supportsLiveTV {
|
if session.supportsLiveTV {
|
||||||
Tab(value: SidebarDestination.liveTV) {
|
Tab(value: SidebarDestination.liveTV) {
|
||||||
@@ -332,7 +320,7 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
|
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
|
||||||
}
|
}
|
||||||
.customizationID("flixy.tab.liveTV")
|
.customizationID("halo.tab.liveTV")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Tab(value: SidebarDestination.libraryHub) {
|
Tab(value: SidebarDestination.libraryHub) {
|
||||||
@@ -340,7 +328,7 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
tabLabel("sidebar.library", symbol: "books.vertical")
|
tabLabel("sidebar.library", symbol: "books.vertical")
|
||||||
}
|
}
|
||||||
.customizationID("flixy.tab.libraryHub")
|
.customizationID("halo.tab.libraryHub")
|
||||||
}
|
}
|
||||||
|
|
||||||
Tab(value: SidebarDestination.downloads) {
|
Tab(value: SidebarDestination.downloads) {
|
||||||
@@ -348,7 +336,7 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
|
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
|
||||||
}
|
}
|
||||||
.customizationID("flixy.tab.downloads")
|
.customizationID("halo.tab.downloads")
|
||||||
|
|
||||||
// **Nicht auf dem iPhone.** Dort führt der Weg über das
|
// **Nicht auf dem iPhone.** Dort führt der Weg über das
|
||||||
// Monogramm oben rechts — siehe `profileButton`. Einstellungen
|
// Monogramm oben rechts — siehe `profileButton`. Einstellungen
|
||||||
@@ -361,7 +349,7 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
tabLabel("sidebar.settings", symbol: "gearshape")
|
tabLabel("sidebar.settings", symbol: "gearshape")
|
||||||
}
|
}
|
||||||
.customizationID("flixy.tab.settings")
|
.customizationID("halo.tab.settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Suche nur dort, wo Platz für sie ist.
|
// Die Suche nur dort, wo Platz für sie ist.
|
||||||
@@ -374,7 +362,7 @@ struct RootView: View {
|
|||||||
Tab(value: SidebarDestination.search, role: .search) {
|
Tab(value: SidebarDestination.search, role: .search) {
|
||||||
mediaStack { SearchView() }
|
mediaStack { SearchView() }
|
||||||
}
|
}
|
||||||
.customizationID("flixy.tab.search")
|
.customizationID("halo.tab.search")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.sidebarAdaptable)
|
.tabViewStyle(.sidebarAdaptable)
|
||||||
@@ -401,7 +389,7 @@ struct RootView: View {
|
|||||||
if sizeClass == .regular {
|
if sizeClass == .regular {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
if placement == .sidebar {
|
if placement == .sidebar {
|
||||||
FlixySidebar(
|
HaloSidebar(
|
||||||
groups: navigationGroups,
|
groups: navigationGroups,
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
placement: $placement.animation(Self.placementAnimation),
|
placement: $placement.animation(Self.placementAnimation),
|
||||||
@@ -445,7 +433,7 @@ struct RootView: View {
|
|||||||
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
|
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if placement == .top {
|
if placement == .top {
|
||||||
FlixyTopBar(
|
HaloTopBar(
|
||||||
groups: navigationGroups.map(visible),
|
groups: navigationGroups.map(visible),
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
placement: $placement.animation(Self.placementAnimation)
|
placement: $placement.animation(Self.placementAnimation)
|
||||||
@@ -485,7 +473,7 @@ struct RootView: View {
|
|||||||
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
|
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
|
||||||
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
|
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
|
||||||
// statt in einem Bereich, den man erst wieder verlassen muss.
|
// statt in einem Bereich, den man erst wieder verlassen muss.
|
||||||
.environment(\.flixyOpenSettings, { showsSettings = true })
|
.environment(\.haloOpenSettings, { showsSettings = true })
|
||||||
.sheet(isPresented: $showsSettings) {
|
.sheet(isPresented: $showsSettings) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
@@ -493,7 +481,7 @@ struct RootView: View {
|
|||||||
// jeder weiss das, und wer in einem Unterbildschirm steht,
|
// jeder weiss das, und wer in einem Unterbildschirm steht,
|
||||||
// zieht damit gleich alles weg. Ein Knopf sagt es.
|
// zieht damit gleich alles weg. Ein Knopf sagt es.
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .flixyTrailing) {
|
ToolbarItem(placement: .haloTrailing) {
|
||||||
Button("action.close") { showsSettings = false }
|
Button("action.close") { showsSettings = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,16 +515,16 @@ struct RootView: View {
|
|||||||
showsSettings = true
|
showsSettings = true
|
||||||
} label: {
|
} label: {
|
||||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
// Damit er auch über einem hellen Aufmacher steht.
|
// Damit er auch über einem hellen Aufmacher steht.
|
||||||
.background(.black.opacity(0.28), in: .circle)
|
.background(.black.opacity(0.28), in: .circle)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.trailing, FlixySpacing.regular)
|
.padding(.trailing, HaloSpacing.regular)
|
||||||
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
|
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
|
||||||
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
|
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
|
||||||
// Knopf um ihre Höhe nach unten, mitten ins Bild.
|
// Knopf um ihre Höhe nach unten, mitten ins Bild.
|
||||||
.padding(.top, FlixySpacing.snug)
|
.padding(.top, HaloSpacing.snug)
|
||||||
.opacity(topChrome.isRevealed ? 1 : 0)
|
.opacity(topChrome.isRevealed ? 1 : 0)
|
||||||
.allowsHitTesting(topChrome.isRevealed)
|
.allowsHitTesting(topChrome.isRevealed)
|
||||||
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
|
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
|
||||||
@@ -555,7 +543,7 @@ struct RootView: View {
|
|||||||
/// schwebt nichts, worunter man greifen müsste.
|
/// schwebt nichts, worunter man greifen müsste.
|
||||||
private var topInset: CGFloat {
|
private var topInset: CGFloat {
|
||||||
guard sizeClass == .regular else { return 0 }
|
guard sizeClass == .regular else { return 0 }
|
||||||
return statusBarHeight + (placement == .sidebar ? FlixySidebar.margin : Self.topBarHeight)
|
return statusBarHeight + (placement == .sidebar ? HaloSidebar.margin : Self.topBarHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var navigationGroups: [NavigationGroup] {
|
private var navigationGroups: [NavigationGroup] {
|
||||||
@@ -40,7 +40,7 @@ extension View {
|
|||||||
/// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht
|
/// 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
|
/// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite
|
||||||
/// weit unten steht.
|
/// weit unten steht.
|
||||||
func flixyRevealsTopChrome() -> some View {
|
func haloRevealsTopChrome() -> some View {
|
||||||
modifier(RevealsTopChrome())
|
modifier(RevealsTopChrome())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// Ein noch nicht gebauter Bereich.
|
/// Ein noch nicht gebauter Bereich.
|
||||||
///
|
///
|
||||||
@@ -15,9 +15,9 @@ struct PlaceholderScreen: View {
|
|||||||
} description: {
|
} description: {
|
||||||
Text("placeholder.description")
|
Text("placeholder.description")
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(destination.title)
|
.navigationTitle(destination.title)
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// Die schwebende Videokachel.
|
/// Die schwebende Videokachel.
|
||||||
///
|
///
|
||||||
@@ -51,7 +51,7 @@ struct MiniPlayer: View {
|
|||||||
// ist als das Fenster, ließe sich nicht mehr wegziehen.
|
// ist als das Fenster, ließe sich nicht mehr wegziehen.
|
||||||
let upper = max(160, min(bounds.width * 0.75, 560))
|
let upper = max(160, min(bounds.width * 0.75, 560))
|
||||||
let width = min(max(chosen * zoom, 140), upper)
|
let width = min(max(chosen * zoom, 140), upper)
|
||||||
return CGSize(width: width, height: (width / FlixyAspectRatio.landscape).rounded())
|
return CGSize(width: width, height: (width / HaloAspectRatio.landscape).rounded())
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -105,10 +105,10 @@ struct MiniPlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.clipShape(.rect(cornerRadius: FlixyRadius.card))
|
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: FlixyRadius.card)
|
RoundedRectangle(cornerRadius: HaloRadius.card)
|
||||||
.strokeBorder(Color.flixySeparator, lineWidth: 0.5)
|
.strokeBorder(Color.haloSeparator, lineWidth: 0.5)
|
||||||
}
|
}
|
||||||
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
|
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
@@ -158,9 +158,9 @@ struct MiniPlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.tight)
|
.padding(HaloSpacing.tight)
|
||||||
// Über dem Fortschrittsbalken, nicht darauf.
|
// Über dem Fortschrittsbalken, nicht darauf.
|
||||||
.padding(.bottom, FlixySpacing.tight)
|
.padding(.bottom, HaloSpacing.tight)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
|
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
||||||
///
|
///
|
||||||
@@ -34,7 +34,7 @@ struct PlaybackScrubber: View {
|
|||||||
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
||||||
let onActivity: () -> Void
|
let onActivity: () -> Void
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
private static let trackHeight: CGFloat = 5
|
private static let trackHeight: CGFloat = 5
|
||||||
private static let thumbSize: CGFloat = 13
|
private static let thumbSize: CGFloat = 13
|
||||||
@@ -2,14 +2,8 @@ import AVKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import Observation
|
import Observation
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
// Siehe `PlaybackSettings`: `!os(tvOS)` muss dazu, sonst bricht der
|
|
||||||
// Abhängigkeits-Sucher an VLCKit ab, das für dieses Ziel gar nicht gebaut wird.
|
|
||||||
#if canImport(FlixyVLC) && !os(tvOS)
|
|
||||||
import FlixyVLC
|
|
||||||
#endif
|
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Führt einen Abspielwunsch aus.
|
/// Führt einen Abspielwunsch aus.
|
||||||
///
|
///
|
||||||
@@ -162,9 +156,6 @@ final class PlayerModel {
|
|||||||
/// das Bild zeichnet: auf dem Telefon hängt die gehaltene `AVPlayerLayer`
|
/// das Bild zeichnet: auf dem Telefon hängt die gehaltene `AVPlayerLayer`
|
||||||
/// daran, auf dem Fernseher ein `AVPlayerViewController`. Beide holen sich
|
/// daran, auf dem Fernseher ein `AVPlayerViewController`. Beide holen sich
|
||||||
/// hier dasselbe ab.
|
/// hier dasselbe ab.
|
||||||
///
|
|
||||||
/// `nil` bei VLC — kein Sonderfall, sondern dieselbe Auskunft, die
|
|
||||||
/// `VideoSurface` heute schon auswertet.
|
|
||||||
var systemPlayer: AVPlayer? { (engine as? AVPlayerEngine)?.player }
|
var systemPlayer: AVPlayer? { (engine as? AVPlayerEngine)?.player }
|
||||||
|
|
||||||
/// Wie das Bild in die Fläche gelegt wird.
|
/// Wie das Bild in die Fläche gelegt wird.
|
||||||
@@ -174,26 +165,14 @@ final class PlayerModel {
|
|||||||
/// Neuaufbau der Engine gehen an der Ansicht vorbei. Läge die Wahl dort,
|
/// Neuaufbau der Engine gehen an der Ansicht vorbei. Läge die Wahl dort,
|
||||||
/// spränge das Bild bei jedem Qualitätswechsel zurück auf „eingepasst".
|
/// spränge das Bild bei jedem Qualitätswechsel zurück auf „eingepasst".
|
||||||
///
|
///
|
||||||
/// **Warum der Knopf lange nichts tat, und warum der Grund nicht der
|
|
||||||
/// `didSet` war, der hier stand.** Ein `didSet` auf einer verfolgten
|
|
||||||
/// Eigenschaft einer `@Observable`-Klasse wird durchaus gemeldet —
|
|
||||||
/// `HomeLayoutSettings` lebt davon. Der Knopf schrieb `videoGravity` auf
|
|
||||||
/// `surface.playerLayer`, und diese Ebene hängt bei einer Datei, die über
|
|
||||||
/// VLC läuft, überhaupt nicht im Baum: `VideoSurface` nimmt dort
|
|
||||||
/// `vlcEngine.videoView`, und `attachSurface` steigt für alles ausser
|
|
||||||
/// AVPlayer gleich wieder aus. Geschrieben wurde also auf eine Ebene, die
|
|
||||||
/// niemand sieht.
|
|
||||||
///
|
|
||||||
/// Deshalb geht die Einstellung jetzt durch die Engine — siehe
|
|
||||||
/// `applyVideoFit()`.
|
|
||||||
var videoFit: VideoFit = .fit
|
var videoFit: VideoFit = .fit
|
||||||
|
|
||||||
/// Wo das Bild aufhört.
|
/// Wo das Bild aufhört.
|
||||||
///
|
///
|
||||||
/// Liegt bei `FlixyPlayback`, weil beide Engines es brauchen. Der Name
|
/// Liegt bei `HaloPlayback`, weil beide Engines es brauchen. Der Name
|
||||||
/// hier bleibt, damit die Ansicht weiter `PlayerModel.VideoFit` schreiben
|
/// hier bleibt, damit die Ansicht weiter `PlayerModel.VideoFit` schreiben
|
||||||
/// kann.
|
/// kann.
|
||||||
typealias VideoFit = FlixyPlayback.VideoFit
|
typealias VideoFit = HaloPlayback.VideoFit
|
||||||
|
|
||||||
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
|
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
|
||||||
///
|
///
|
||||||
@@ -201,6 +180,15 @@ final class PlayerModel {
|
|||||||
/// `@Observable` einen `lazy`-Delegaten nicht überlebt.
|
/// `@Observable` einen `lazy`-Delegaten nicht überlebt.
|
||||||
@ObservationIgnored private var pictureInPicture: AVPictureInPictureController?
|
@ObservationIgnored private var pictureInPicture: AVPictureInPictureController?
|
||||||
@ObservationIgnored private var pictureInPictureDelegate: PictureInPictureBridge?
|
@ObservationIgnored private var pictureInPictureDelegate: PictureInPictureBridge?
|
||||||
|
@ObservationIgnored private var pictureInPictureObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
/// Ob Bild-in-Bild **jetzt** ginge.
|
||||||
|
///
|
||||||
|
/// **Beobachtet, nicht abgefragt.** `isPictureInPicturePossible` steht erst
|
||||||
|
/// fest, wenn der Player ein Bild hat — wer beim Zeichnen fragt, bekommt
|
||||||
|
/// `false` und zeichnet den Knopf nie wieder. Also meldet es sich selbst.
|
||||||
|
private(set) var canUsePictureInPicture = false
|
||||||
|
private(set) var isPictureInPictureActive = false
|
||||||
/// Was beim Beenden von Bild-in-Bild wieder ins Vollbild führen soll.
|
/// Was beim Beenden von Bild-in-Bild wieder ins Vollbild führen soll.
|
||||||
@ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)?
|
@ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)?
|
||||||
|
|
||||||
@@ -257,7 +245,7 @@ final class PlayerModel {
|
|||||||
/// Ohne das gewönne die letzte Meldung von der alten Stelle gegen den
|
/// Ohne das gewönne die letzte Meldung von der alten Stelle gegen den
|
||||||
/// gerade ausgelösten Sprung.
|
/// gerade ausgelösten Sprung.
|
||||||
private var seekTarget: Duration?
|
private var seekTarget: Duration?
|
||||||
/// Ob schon einmal auf VLC ausgewichen wurde.
|
/// Ob schon einmal auf den Transcoder ausgewichen wurde.
|
||||||
private var hasFallenBack = false
|
private var hasFallenBack = false
|
||||||
/// Ob der Vorratsspeicher für diese Wiedergabe aufgegeben wurde.
|
/// Ob der Vorratsspeicher für diese Wiedergabe aufgegeben wurde.
|
||||||
///
|
///
|
||||||
@@ -428,9 +416,6 @@ final class PlayerModel {
|
|||||||
guard let ceiling = automaticCeiling ?? startingCeiling else { return base }
|
guard let ceiling = automaticCeiling ?? startingCeiling else { return base }
|
||||||
return PlaybackPolicy(
|
return PlaybackPolicy(
|
||||||
allowsTranscoding: true,
|
allowsTranscoding: true,
|
||||||
// Ohne Ersatz-Engine: sonst spielte VLC die Datei unverändert ab,
|
|
||||||
// und die abgesenkte Grenze bliebe wirkungslos.
|
|
||||||
allowsFallbackEngine: false,
|
|
||||||
maximumBitrate: min(ceiling, base.maximumBitrate ?? ceiling),
|
maximumBitrate: min(ceiling, base.maximumBitrate ?? ceiling),
|
||||||
forcesTranscoding: true
|
forcesTranscoding: true
|
||||||
)
|
)
|
||||||
@@ -747,8 +732,8 @@ final class PlayerModel {
|
|||||||
videoCodec: offline?.videoCodec,
|
videoCodec: offline?.videoCodec,
|
||||||
audioCodec: offline?.audioCodec,
|
audioCodec: offline?.audioCodec,
|
||||||
bitrate: offline?.bitrate,
|
bitrate: offline?.bitrate,
|
||||||
// Was heruntergeladen wurde, ist untersucht. Ohne diese Zusage
|
// Was heruntergeladen wurde, ist untersucht — und seit das
|
||||||
// ginge jede Offline-Datei vorsichtshalber an VLC.
|
// Herunterladen bei Bedarf umpackt, auch zu öffnen.
|
||||||
hasKnownStreams: offline?.videoCodec != nil
|
hasKnownStreams: offline?.videoCodec != nil
|
||||||
),
|
),
|
||||||
// Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze.
|
// Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze.
|
||||||
@@ -769,10 +754,6 @@ final class PlayerModel {
|
|||||||
let engine = makeEngine(for: decision)
|
let engine = makeEngine(for: decision)
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
attachSurface(to: engine)
|
attachSurface(to: engine)
|
||||||
// **Auch für VLC**, und deshalb hier und nicht in `attachSurface`:
|
|
||||||
// das steigt für alles ausser AVPlayer sofort aus, weil es die
|
|
||||||
// Bildebene anhängt. Die Skalierung geht aber beide an.
|
|
||||||
engine.setVideoFit(videoFit)
|
|
||||||
observe(engine)
|
observe(engine)
|
||||||
// **Vor dem Öffnen.** Ein frisch angelegter Gegenstand ist
|
// **Vor dem Öffnen.** Ein frisch angelegter Gegenstand ist
|
||||||
// ungestaltet, und der erste Untertitel käme sonst in der Vorgabe.
|
// ungestaltet, und der erste Untertitel käme sonst in der Vorgabe.
|
||||||
@@ -1196,18 +1177,24 @@ final class PlayerModel {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Rückzug auf VLC, wenn das Umpacken nicht getragen hat.
|
/// Der Rückzug auf den Transcoder, wenn das Umpacken nicht getragen hat.
|
||||||
///
|
///
|
||||||
/// Genau **einmal** je Wiedergabe: scheitert auch VLC, ist es ein Fehler
|
/// **Hier stand VLC.** Scheiterte das Umpacken, spielte die zweite Engine
|
||||||
|
/// die Datei unverändert — das gibt es nicht mehr. Statt dessen rechnet der
|
||||||
|
/// Server das Bild neu: teurer für ihn, aber es kommt etwas an, und der
|
||||||
|
/// Nutzer steht nicht vor einer schwarzen Wand mit einer Fehlernummer.
|
||||||
|
///
|
||||||
|
/// Genau **einmal** je Wiedergabe: scheitert auch das, ist es ein Fehler
|
||||||
/// des Materials, und ein zweiter Anlauf schickte die App im Kreis.
|
/// des Materials, und ein zweiter Anlauf schickte die App im Kreis.
|
||||||
private func fallBackToVLC() async -> Bool {
|
private func fallBackToTranscode() async -> Bool {
|
||||||
guard !hasFallenBack, policy.allowsFallbackEngine else { return false }
|
let geltend = effectivePolicy
|
||||||
|
guard !hasFallenBack, geltend.allowsTranscoding else { return false }
|
||||||
hasFallenBack = true
|
hasFallenBack = true
|
||||||
diagnostics.note("Umpacken abgelehnt — noch einmal mit VLC")
|
diagnostics.note("Umpacken abgelehnt — noch einmal, diesmal umgerechnet")
|
||||||
|
|
||||||
await restart(forcing: PlaybackDecision(
|
await restart(forcing: PlaybackDecision(
|
||||||
method: .directPlay(engine: .vlc),
|
method: .transcode(maximumBitrate: geltend.maximumBitrate),
|
||||||
rationale: (decision?.rationale ?? []) + [.transcodingUnavailable]
|
rationale: decision?.rationale ?? []
|
||||||
))
|
))
|
||||||
if case .failed = phase { return false }
|
if case .failed = phase { return false }
|
||||||
return true
|
return true
|
||||||
@@ -1541,6 +1528,26 @@ final class PlayerModel {
|
|||||||
seek(to: .milliseconds(marker.endMilliseconds))
|
seek(to: .milliseconds(marker.endMilliseconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
|
||||||
|
///
|
||||||
|
/// **Eine Spur ist keine Wahl.** Der Knopf stand auch dort, wo die Liste
|
||||||
|
/// genau einen Eintrag hatte — ein Angebot, das nach dem Antippen nichts
|
||||||
|
/// anbietet.
|
||||||
|
///
|
||||||
|
/// Woher gezählt wird, hängt daran, wo die Datei liegt: online stehen die
|
||||||
|
/// Spuren des Servers zur Wahl (umstellen heisst dort: neu bestellen),
|
||||||
|
/// offline die der Datei. Siehe `TrackPickerSheet`.
|
||||||
|
var hasAudioChoice: Bool {
|
||||||
|
(isOffline ? audioTracks.count : audioStreams.count) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob es Untertitel gibt.
|
||||||
|
///
|
||||||
|
/// Hier genügt **einer**: „an oder aus" ist auch eine Wahl.
|
||||||
|
var hasSubtitleChoice: Bool {
|
||||||
|
isOffline ? !subtitleTracks.isEmpty : !subtitleStreams.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
func selectAudioTrack(_ track: MediaTrack) {
|
func selectAudioTrack(_ track: MediaTrack) {
|
||||||
engine?.selectAudioTrack(track)
|
engine?.selectAudioTrack(track)
|
||||||
selectedAudioTrack = track
|
selectedAudioTrack = track
|
||||||
@@ -1578,8 +1585,11 @@ final class PlayerModel {
|
|||||||
streamServer = nil
|
streamServer = nil
|
||||||
streamCache = nil
|
streamCache = nil
|
||||||
prefetchStatus = nil
|
prefetchStatus = nil
|
||||||
|
pictureInPictureObservation = nil
|
||||||
pictureInPicture = nil
|
pictureInPicture = nil
|
||||||
pictureInPictureDelegate = nil
|
pictureInPictureDelegate = nil
|
||||||
|
canUsePictureInPicture = false
|
||||||
|
isPictureInPictureActive = false
|
||||||
surface.playerLayer.player = nil
|
surface.playerLayer.player = nil
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
isBuffering = false
|
isBuffering = false
|
||||||
@@ -1726,14 +1736,10 @@ final class PlayerModel {
|
|||||||
/// Eine eigene Funktion, weil es zwei Anlässe gibt: die Wahl des Nutzers
|
/// Eine eigene Funktion, weil es zwei Anlässe gibt: die Wahl des Nutzers
|
||||||
/// und jeder Engine-Wechsel. Beide müssen dasselbe tun.
|
/// und jeder Engine-Wechsel. Beide müssen dasselbe tun.
|
||||||
///
|
///
|
||||||
/// **Zwei Zeilen, weil es zwei Engines gibt.** Die Bildebene gehört der
|
/// Die Bildebene gehört der App und nicht dem Systemplayer — deshalb
|
||||||
/// App und nicht dem Systemplayer — deshalb steht `videoGravity` hier und
|
/// steht `videoGravity` hier und nicht in `AVPlayerEngine`.
|
||||||
/// nicht in `AVPlayerEngine`. VLC zeichnet dagegen selbst und passt sein
|
|
||||||
/// Bild von sich aus ein; ihm muss man es sagen. Solange nur die erste
|
|
||||||
/// Zeile hier stand, war der Knopf bei jeder Datei über VLC ohne Wirkung.
|
|
||||||
func applyVideoFit() {
|
func applyVideoFit() {
|
||||||
surface.playerLayer.videoGravity = videoFit == .fill ? .resizeAspectFill : .resizeAspect
|
surface.playerLayer.videoGravity = videoFit == .fill ? .resizeAspectFill : .resizeAspect
|
||||||
engine?.setVideoFit(videoFit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
|
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
|
||||||
@@ -1743,7 +1749,6 @@ final class PlayerModel {
|
|||||||
/// weiter auf einen abgeräumten Player und bleibt schwarz.
|
/// weiter auf einen abgeräumten Player und bleibt schwarz.
|
||||||
private func attachSurface(to engine: any VideoPlayerEngine) {
|
private func attachSurface(to engine: any VideoPlayerEngine) {
|
||||||
guard let avEngine = engine as? AVPlayerEngine else { return }
|
guard let avEngine = engine as? AVPlayerEngine else { return }
|
||||||
|
|
||||||
surface.playerLayer.player = avEngine.player
|
surface.playerLayer.player = avEngine.player
|
||||||
// **Den Zustand lesen, keine Konstante.** Hier stand fest
|
// **Den Zustand lesen, keine Konstante.** Hier stand fest
|
||||||
// `.resizeAspect` — und weil diese Zeile nach jedem Neuaufbau erneut
|
// `.resizeAspect` — und weil diese Zeile nach jedem Neuaufbau erneut
|
||||||
@@ -1766,6 +1771,32 @@ final class PlayerModel {
|
|||||||
controller?.delegate = bridge
|
controller?.delegate = bridge
|
||||||
pictureInPictureDelegate = bridge
|
pictureInPictureDelegate = bridge
|
||||||
pictureInPicture = controller
|
pictureInPicture = controller
|
||||||
|
// KVO meldet auf einem beliebigen Thread — deshalb der Sprung,
|
||||||
|
// wie bei den Beobachtungen in `AVPlayerEngine`.
|
||||||
|
pictureInPictureObservation = controller?.observe(
|
||||||
|
\.isPictureInPicturePossible,
|
||||||
|
options: [.initial, .new]
|
||||||
|
) { [weak self] controller, _ in
|
||||||
|
let moeglich = controller.isPictureInPicturePossible
|
||||||
|
Task { @MainActor [weak self] in self?.canUsePictureInPicture = moeglich }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schaltet Bild-in-Bild ein oder wieder aus.
|
||||||
|
///
|
||||||
|
/// **Von Hand, nicht nur beim Verlassen der App.** Automatisch wechselt der
|
||||||
|
/// Player schon, wenn man zum Homebildschirm geht — aber nur dann, und auf
|
||||||
|
/// dem Mac gar nicht. Wer das Bild klein neben etwas anderes legen will,
|
||||||
|
/// ohne die App zu verlassen, hatte dafür keinen Weg.
|
||||||
|
func togglePictureInPicture() {
|
||||||
|
guard let pictureInPicture else { return }
|
||||||
|
if pictureInPicture.isPictureInPictureActive {
|
||||||
|
pictureInPicture.stopPictureInPicture()
|
||||||
|
isPictureInPictureActive = false
|
||||||
|
} else {
|
||||||
|
pictureInPicture.startPictureInPicture()
|
||||||
|
isPictureInPictureActive = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1777,20 +1808,14 @@ final class PlayerModel {
|
|||||||
restoreFromPictureInPicture = action
|
restoreFromPictureInPicture = action
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Es gibt nur noch einen.**
|
||||||
|
///
|
||||||
|
/// Die Entscheidung sagt nicht mehr, *wer* spielt, sondern *was ankommt*:
|
||||||
|
/// direkt, umgepackt oder umgerechnet. Am Ende steht immer der
|
||||||
|
/// Systemplayer — und damit überall Bild-in-Bild, AirPlay und die
|
||||||
|
/// Bedienung im Sperrbildschirm, auch auf dem Apple TV.
|
||||||
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
|
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
|
||||||
#if canImport(FlixyVLC) && !os(tvOS)
|
|
||||||
switch decision.engine {
|
|
||||||
case .vlc: VLCPlayerEngine()
|
|
||||||
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
|
|
||||||
// genau darin wechselt AVPlayer die Bitrate von selbst.
|
|
||||||
case .avPlayer, .none: AVPlayerEngine()
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
// Auf dem Mac gibt es VLC (noch) nicht: das Binärpaket ist auf iOS
|
|
||||||
// festgelegt. Was der Systemplayer nicht öffnen kann, scheitert dort
|
|
||||||
// mit einer Meldung, statt still schwarz zu bleiben.
|
|
||||||
AVPlayerEngine()
|
AVPlayerEngine()
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func observe(_ engine: any VideoPlayerEngine) {
|
private func observe(_ engine: any VideoPlayerEngine) {
|
||||||
@@ -1812,19 +1837,17 @@ final class PlayerModel {
|
|||||||
if case .failed(let failure) = state {
|
if case .failed(let failure) = state {
|
||||||
diagnostics.note("Wiedergabe gescheitert: \(failure)")
|
diagnostics.note("Wiedergabe gescheitert: \(failure)")
|
||||||
|
|
||||||
// **Scheitert das Umpacken, bleibt VLC.**
|
// **Scheitert das Umpacken, rechnet der Server um.**
|
||||||
//
|
//
|
||||||
// Der Server entscheidet erst beim Ausliefern, ob er
|
// Er entscheidet erst beim Ausliefern, ob er die
|
||||||
// die Ströme wirklich kopieren kann — und manche
|
// Ströme wirklich kopieren kann — und manche Antworten
|
||||||
// Antworten kann AVPlayer nicht annehmen, auch wenn
|
// kann AVPlayer nicht annehmen, auch wenn sie formal
|
||||||
// sie formal richtig sind. Das hier vorher zu wissen
|
// richtig sind. Das vorher zu wissen ist nicht
|
||||||
// ist nicht möglich; es hinterher aufzufangen schon.
|
// möglich; es hinterher aufzufangen schon.
|
||||||
//
|
//
|
||||||
// Ohne diesen Weg stünde der Nutzer vor einer
|
// Ohne diesen Weg stünde der Nutzer vor einer
|
||||||
// schwarzen Wand mit einer Fehlernummer, obwohl ein
|
// schwarzen Wand mit einer Fehlernummer.
|
||||||
// Player danebensteht, der die Datei anstandslos
|
if case .remux = decision?.method, await fallBackToTranscode() { return }
|
||||||
// spielt.
|
|
||||||
if case .remux = decision?.method, await fallBackToVLC() { return }
|
|
||||||
|
|
||||||
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
|
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
|
||||||
// Der Grund gehört auf den Bildschirm, nicht nur ins
|
// Der Grund gehört auf den Bildschirm, nicht nur ins
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Der Vollbildplayer.
|
/// Der Vollbildplayer.
|
||||||
struct PlayerView: View {
|
struct PlayerView: View {
|
||||||
@@ -14,14 +13,14 @@ struct PlayerView: View {
|
|||||||
/// Nutzers statt eine Nebenwirkung der Navigation.
|
/// Nutzers statt eine Nebenwirkung der Navigation.
|
||||||
let model: PlayerModel
|
let model: PlayerModel
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
/// Woran sich Quer- und Hochformat unterscheiden lassen.
|
/// Woran sich Quer- und Hochformat unterscheiden lassen.
|
||||||
///
|
///
|
||||||
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
|
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
|
||||||
@@ -73,7 +72,7 @@ struct PlayerView: View {
|
|||||||
/// Knopf.
|
/// Knopf.
|
||||||
@State private var isFullScreen = false
|
@State private var isFullScreen = false
|
||||||
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
|
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
|
||||||
private var isMac: Bool { FlixyPlatform.isMac }
|
private var isMac: Bool { HaloPlatform.isMac }
|
||||||
/// Ob gedreht und mit zwei Fingern gezoomt werden kann.
|
/// Ob gedreht und mit zwei Fingern gezoomt werden kann.
|
||||||
///
|
///
|
||||||
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
|
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
|
||||||
@@ -201,7 +200,7 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
.opacity(zeigt ? 1 : 0)
|
.opacity(zeigt ? 1 : 0)
|
||||||
.allowsHitTesting(zeigt)
|
.allowsHitTesting(zeigt)
|
||||||
.padding(FlixySpacing.loose)
|
.padding(HaloSpacing.loose)
|
||||||
// Siehe `controls(for:)`: die Kopfzeile lag sonst hinter
|
// Siehe `controls(for:)`: die Kopfzeile lag sonst hinter
|
||||||
// der Dynamic Island, und der Zurück-Pfeil war tot.
|
// der Dynamic Island, und der Zurück-Pfeil war tot.
|
||||||
.padding(.top, safeArea.top)
|
.padding(.top, safeArea.top)
|
||||||
@@ -218,7 +217,7 @@ struct PlayerView: View {
|
|||||||
// ein zweiter Klick auf dieselbe Stelle legte das Fenster
|
// ein zweiter Klick auf dieselbe Stelle legte das Fenster
|
||||||
// ins Dock, weil das der Doppelklick auf eine
|
// ins Dock, weil das der Doppelklick auf eine
|
||||||
// Fensterleiste tut.
|
// Fensterleiste tut.
|
||||||
.padding(.top, FlixyPlatform.isMac ? Self.titleBarHeight : 0)
|
.padding(.top, HaloPlatform.isMac ? Self.titleBarHeight : 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,12 +334,12 @@ struct PlayerView: View {
|
|||||||
// kein Absturz, aber die falsche Farbe.
|
// kein Absturz, aber die falsche Farbe.
|
||||||
.sheet(item: $trackPicker) { kind in
|
.sheet(item: $trackPicker) { kind in
|
||||||
TrackPickerSheet(model: model, kind: kind)
|
TrackPickerSheet(model: model, kind: kind)
|
||||||
.environment(\.flixyAccent, accent)
|
.environment(\.haloAccent, accent)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showsQualityChoice) {
|
.sheet(isPresented: $showsQualityChoice) {
|
||||||
QualitySheet(model: model)
|
QualitySheet(model: model)
|
||||||
.environment(settings)
|
.environment(settings)
|
||||||
.environment(\.flixyAccent, accent)
|
.environment(\.haloAccent, accent)
|
||||||
}
|
}
|
||||||
.onChange(of: showsQualityChoice) { _, isShown in
|
.onChange(of: showsQualityChoice) { _, isShown in
|
||||||
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
||||||
@@ -372,7 +371,7 @@ struct PlayerView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
scrubber(for: model)
|
scrubber(for: model)
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.loose)
|
.padding(HaloSpacing.loose)
|
||||||
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
|
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
|
||||||
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
|
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
|
||||||
// soll. Ersetzte eines das andere, klebte der Schieberegler im
|
// soll. Ersetzte eines das andere, klebte der Schieberegler im
|
||||||
@@ -396,7 +395,7 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func topBar(for model: PlayerModel) -> some View {
|
private func topBar(for model: PlayerModel) -> some View {
|
||||||
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
HStack(alignment: .top, spacing: HaloSpacing.regular) {
|
||||||
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
|
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
|
||||||
// Zweck der Übung: weiterschauen, während man stöbert.
|
// Zweck der Übung: weiterschauen, während man stöbert.
|
||||||
Button {
|
Button {
|
||||||
@@ -404,7 +403,7 @@ struct PlayerView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.minimize"))
|
.accessibilityLabel(Text("player.minimize"))
|
||||||
|
|
||||||
@@ -415,17 +414,17 @@ struct PlayerView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.stop"))
|
.accessibilityLabel(Text("player.stop"))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(model.title)
|
Text(model.title)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let subtitle = model.subtitle {
|
if let subtitle = model.subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
@@ -448,7 +447,7 @@ struct PlayerView: View {
|
|||||||
? "arrow.down.right.and.arrow.up.left"
|
? "arrow.down.right.and.arrow.up.left"
|
||||||
: "arrow.up.left.and.arrow.down.right")
|
: "arrow.up.left.and.arrow.down.right")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text(isFullScreen ? "player.exitFullScreen" : "player.fullScreen"))
|
.accessibilityLabel(Text(isFullScreen ? "player.exitFullScreen" : "player.fullScreen"))
|
||||||
.keyboardShortcut("f", modifiers: [.command, .control])
|
.keyboardShortcut("f", modifiers: [.command, .control])
|
||||||
@@ -466,7 +465,7 @@ struct PlayerView: View {
|
|||||||
? "arrow.down.right.and.arrow.up.left"
|
? "arrow.down.right.and.arrow.up.left"
|
||||||
: "arrow.up.left.and.arrow.down.right")
|
: "arrow.up.left.and.arrow.down.right")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo"))
|
.accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo"))
|
||||||
}
|
}
|
||||||
@@ -489,7 +488,7 @@ struct PlayerView: View {
|
|||||||
// Zwei Pfeile im Kreis sagen, was passiert: es dreht.
|
// Zwei Pfeile im Kreis sagen, was passiert: es dreht.
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.rotate"))
|
.accessibilityLabel(Text("player.rotate"))
|
||||||
}
|
}
|
||||||
@@ -498,7 +497,7 @@ struct PlayerView: View {
|
|||||||
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
|
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
|
||||||
// Film. Ein gemeinsames Sheet bedeutete jedes Mal Scrollen an der
|
// Film. Ein gemeinsames Sheet bedeutete jedes Mal Scrollen an der
|
||||||
// anderen Liste vorbei.
|
// anderen Liste vorbei.
|
||||||
if !model.audioTracks.isEmpty {
|
if model.hasAudioChoice {
|
||||||
trackButton(
|
trackButton(
|
||||||
symbol: "waveform",
|
symbol: "waveform",
|
||||||
label: "player.audioTracks",
|
label: "player.audioTracks",
|
||||||
@@ -506,7 +505,7 @@ struct PlayerView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !model.subtitleTracks.isEmpty {
|
if model.hasSubtitleChoice {
|
||||||
trackButton(
|
trackButton(
|
||||||
symbol: model.selectedSubtitleTrack == nil
|
symbol: model.selectedSubtitleTrack == nil
|
||||||
? "captions.bubble"
|
? "captions.bubble"
|
||||||
@@ -516,10 +515,24 @@ struct PlayerView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Bild-in-Bild von Hand.** Automatisch geht es nur beim
|
||||||
|
// Verlassen der App — und auf dem Mac gar nicht. Der Knopf
|
||||||
|
// erscheint nur, wenn es wirklich ginge.
|
||||||
|
if model.canUsePictureInPicture {
|
||||||
|
Button {
|
||||||
|
model.togglePictureInPicture()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: model.isPictureInPictureActive ? "pip.exit" : "pip.enter")
|
||||||
|
.font(.title3)
|
||||||
|
.padding(HaloSpacing.snug)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(Text("player.pictureInPicture"))
|
||||||
|
}
|
||||||
|
|
||||||
if model.engine?.supportsAirPlay == true {
|
if model.engine?.supportsAirPlay == true {
|
||||||
AirPlayButton(tint: accent)
|
AirPlayButton(tint: accent)
|
||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
.accessibilityLabel(Text("player.airPlay"))
|
.accessibilityLabel(Text("player.airPlay"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +554,7 @@ struct PlayerView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text(label))
|
.accessibilityLabel(Text(label))
|
||||||
}
|
}
|
||||||
@@ -565,15 +578,15 @@ struct PlayerView: View {
|
|||||||
// sagen nicht, was eingestellt ist; „1080p" schon — und genau
|
// sagen nicht, was eingestellt ist; „1080p" schon — und genau
|
||||||
// das ist die Frage, wegen der man hier hintippt.
|
// das ist die Frage, wegen der man hier hintippt.
|
||||||
Text(model.qualityLabel)
|
Text(model.qualityLabel)
|
||||||
.font(.flixyCaption.weight(.semibold))
|
.font(.haloCaption.weight(.semibold))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, HaloSpacing.snug)
|
||||||
.padding(.vertical, FlixySpacing.tight)
|
.padding(.vertical, HaloSpacing.tight)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: FlixyRadius.card)
|
RoundedRectangle(cornerRadius: HaloRadius.card)
|
||||||
.strokeBorder(.white.opacity(0.5), lineWidth: 1)
|
.strokeBorder(.white.opacity(0.5), lineWidth: 1)
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.snug)
|
.padding(HaloSpacing.snug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(model.isChangingQuality)
|
.disabled(model.isChangingQuality)
|
||||||
@@ -581,7 +594,7 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func transport(for model: PlayerModel) -> some View {
|
private func transport(for model: PlayerModel) -> some View {
|
||||||
HStack(spacing: FlixySpacing.section * 2) {
|
HStack(spacing: HaloSpacing.section * 2) {
|
||||||
Button {
|
Button {
|
||||||
model.skip(by: .seconds(-settings.rewindSeconds))
|
model.skip(by: .seconds(-settings.rewindSeconds))
|
||||||
scheduleControlsHide()
|
scheduleControlsHide()
|
||||||
@@ -639,7 +652,7 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scrubber(for model: PlayerModel) -> some View {
|
private func scrubber(for model: PlayerModel) -> some View {
|
||||||
VStack(spacing: FlixySpacing.tight) {
|
VStack(spacing: HaloSpacing.tight) {
|
||||||
PlaybackScrubber(
|
PlaybackScrubber(
|
||||||
position: model.currentTime,
|
position: model.currentTime,
|
||||||
duration: model.duration ?? .seconds(1),
|
duration: model.duration ?? .seconds(1),
|
||||||
@@ -682,9 +695,9 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("player.nextEpisode", systemImage: "forward.end.fill")
|
Label("player.nextEpisode", systemImage: "forward.end.fill")
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.padding(.horizontal, FlixySpacing.loose)
|
.padding(.horizontal, HaloSpacing.loose)
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
.padding(.vertical, HaloSpacing.regular)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
@@ -693,21 +706,21 @@ struct PlayerView: View {
|
|||||||
model.skipActiveMarker()
|
model.skipActiveMarker()
|
||||||
} label: {
|
} label: {
|
||||||
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
|
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.padding(.horizontal, FlixySpacing.loose)
|
.padding(.horizontal, HaloSpacing.loose)
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
.padding(.vertical, HaloSpacing.regular)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.section)
|
.padding(.horizontal, HaloSpacing.section)
|
||||||
.padding(.leading, safeArea.leading)
|
.padding(.leading, safeArea.leading)
|
||||||
.padding(.trailing, safeArea.trailing)
|
.padding(.trailing, safeArea.trailing)
|
||||||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||||||
// die Bedienelemente eingeblendet sind.
|
// die Bedienelemente eingeblendet sind.
|
||||||
.padding(.bottom, (showsControls ? 110 : FlixySpacing.section) + safeArea.bottom)
|
.padding(.bottom, (showsControls ? 110 : HaloSpacing.section) + safeArea.bottom)
|
||||||
}
|
}
|
||||||
.animation(.snappy, value: model.activeMarker?.id)
|
.animation(.snappy, value: model.activeMarker?.id)
|
||||||
.animation(.snappy, value: model.hasReachedEnd)
|
.animation(.snappy, value: model.hasReachedEnd)
|
||||||
@@ -718,10 +731,10 @@ struct PlayerView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: FlixySpacing.regular) {
|
VStack(spacing: HaloSpacing.regular) {
|
||||||
ProgressView().tint(.white)
|
ProgressView().tint(.white)
|
||||||
Text("player.restoring")
|
Text("player.restoring")
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
// Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass
|
// Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass
|
||||||
// niemand denkt, er müsse sie wiederfinden.
|
// niemand denkt, er müsse sie wiederfinden.
|
||||||
@@ -734,45 +747,33 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func preparing(for model: PlayerModel) -> some View {
|
private func preparing(for model: PlayerModel) -> some View {
|
||||||
VStack(spacing: FlixySpacing.loose) {
|
VStack(spacing: HaloSpacing.loose) {
|
||||||
ProgressView().tint(.white)
|
ProgressView().tint(.white)
|
||||||
Text(model.title)
|
Text(model.title)
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Text("player.preparing")
|
Text("player.preparing")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
|
||||||
// Über die Vermittlung von Plex ist die Leitung auf wenige Mbit
|
|
||||||
// gedrosselt. Ein großer Film läuft darüber nicht an, und ohne
|
|
||||||
// diesen Hinweis sucht man den Fehler in der App statt in der
|
|
||||||
// Verbindung.
|
|
||||||
if session.server?.connection.relay == true {
|
|
||||||
Label("player.relayWarning", systemImage: "exclamationmark.triangle")
|
|
||||||
.font(.flixyCaption)
|
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.top, FlixySpacing.snug)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.section)
|
.padding(HaloSpacing.section)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func failure(message: String) -> some View {
|
private func failure(message: String) -> some View {
|
||||||
VStack(spacing: FlixySpacing.loose) {
|
VStack(spacing: HaloSpacing.loose) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Button("action.close") { playback.close() }
|
Button("action.close") { playback.close() }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.section)
|
.padding(HaloSpacing.section)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die beiden Flächen zum Springen.
|
/// Die beiden Flächen zum Springen.
|
||||||
@@ -820,7 +821,7 @@ struct PlayerView: View {
|
|||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
.font(.system(size: 44))
|
.font(.system(size: 44))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(FlixySpacing.section)
|
.padding(HaloSpacing.section)
|
||||||
.background(.black.opacity(0.35), in: .circle)
|
.background(.black.opacity(0.35), in: .circle)
|
||||||
.transition(.opacity.combined(with: .scale))
|
.transition(.opacity.combined(with: .scale))
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
|
|
||||||
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
|
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
|
||||||
///
|
///
|
||||||
@@ -11,7 +11,7 @@ import FlixyPlayback
|
|||||||
struct QualitySheet: View {
|
struct QualitySheet: View {
|
||||||
let model: PlayerModel
|
let model: PlayerModel
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
/// **Als Fragezeichen**, nicht als Behauptung.
|
/// **Als Fragezeichen**, nicht als Behauptung.
|
||||||
///
|
///
|
||||||
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
|
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
|
||||||
@@ -62,14 +62,14 @@ struct QualitySheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(Text("player.quality"))
|
.navigationTitle(Text("player.quality"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.done") { dismiss() }
|
Button("action.done") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flixyResizableSheet()
|
.haloResizableSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Was gerade läuft — gemessen, nicht gewünscht.
|
/// Was gerade läuft — gemessen, nicht gewünscht.
|
||||||
@@ -158,11 +158,11 @@ struct QualitySheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(label(for: option))
|
Text(label(for: option))
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
if let detail {
|
if let detail {
|
||||||
Text(detail)
|
Text(detail)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if option == model.quality {
|
if option == model.quality {
|
||||||
+38
-9
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
|
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
|
||||||
@@ -27,12 +27,25 @@ struct TrackPickerSheet: View {
|
|||||||
let kind: Kind
|
let kind: Kind
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
switch kind {
|
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:
|
case .audio:
|
||||||
// **Die Spuren der Datei, nicht die des Players.** Rechnet
|
// **Die Spuren der Datei, nicht die des Players.** Rechnet
|
||||||
// der Server um, kennt der Player genau eine — die, für
|
// der Server um, kennt der Player genau eine — die, für
|
||||||
@@ -55,7 +68,7 @@ struct TrackPickerSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward")
|
Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward")
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
} header: {
|
} header: {
|
||||||
Text("player.rememberedTrack")
|
Text("player.rememberedTrack")
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -66,6 +79,22 @@ struct TrackPickerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:
|
case .subtitles:
|
||||||
// „Aus" gehört in dieselbe Liste wie die Spuren, nicht als
|
// „Aus" gehört in dieselbe Liste wie die Spuren, nicht als
|
||||||
// eigener Schalter daneben: es ist eine Auswahl unter
|
// eigener Schalter daneben: es ist eine Auswahl unter
|
||||||
@@ -92,23 +121,23 @@ struct TrackPickerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text(kind.title))
|
.navigationTitle(Text(kind.title))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.close") { dismiss() }
|
Button("action.close") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flixyMediumSheet()
|
.haloMediumSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||||
@@ -117,7 +146,7 @@ struct TrackPickerSheet: View {
|
|||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import AVKit
|
import AVKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
// Siehe `PlaybackSettings`: `!os(tvOS)` muss dazu, sonst bricht der
|
|
||||||
// Abhängigkeits-Sucher an VLCKit ab, das für dieses Ziel gar nicht gebaut wird.
|
|
||||||
#if canImport(FlixyVLC) && !os(tvOS)
|
|
||||||
import FlixyVLC
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Die Ansichtsklasse der jeweiligen Plattform.
|
/// Die Ansichtsklasse der jeweiligen Plattform.
|
||||||
///
|
///
|
||||||
@@ -22,12 +17,7 @@ typealias PlatformView = UIView
|
|||||||
typealias PlatformView = NSView
|
typealias PlatformView = NSView
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Die Bildfläche des laufendie Engine.
|
/// Die Bildfläche der laufenden Engine.
|
||||||
///
|
|
||||||
/// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden
|
|
||||||
/// Engines sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
|
|
||||||
/// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche
|
|
||||||
/// `UIView`.
|
|
||||||
struct VideoSurface: View {
|
struct VideoSurface: View {
|
||||||
let engine: (any VideoPlayerEngine)?
|
let engine: (any VideoPlayerEngine)?
|
||||||
/// Die Bildebene des Systemplayers, gehalten vom Modell.
|
/// Die Bildebene des Systemplayers, gehalten vom Modell.
|
||||||
@@ -36,8 +26,6 @@ struct VideoSurface: View {
|
|||||||
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
|
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
|
||||||
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
|
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
|
||||||
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
|
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
|
||||||
/// VLC macht es seit jeher richtig und reicht seine `videoView` durch;
|
|
||||||
/// hier ist es jetzt derselbe Weg.
|
|
||||||
let surface: PlayerLayerView
|
let surface: PlayerLayerView
|
||||||
|
|
||||||
/// Ob **diese** Ansicht die Bildfläche gerade haben soll.
|
/// Ob **diese** Ansicht die Bildfläche gerade haben soll.
|
||||||
@@ -61,21 +49,11 @@ struct VideoSurface: View {
|
|||||||
var bottomInset: CGFloat = 0
|
var bottomInset: CGFloat = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if canImport(FlixyVLC) && !os(tvOS)
|
|
||||||
if let vlcEngine = engine as? VLCPlayerEngine {
|
|
||||||
SurfaceContainer(view: vlcEngine.videoView, isActive: isActive, bottomInset: bottomInset)
|
|
||||||
} else if engine is AVPlayerEngine {
|
|
||||||
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
|
|
||||||
} else {
|
|
||||||
Color.black
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
if engine is AVPlayerEngine {
|
if engine is AVPlayerEngine {
|
||||||
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
|
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
|
||||||
} else {
|
} else {
|
||||||
Color.black
|
Color.black
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Sucht über alle Mediatheken des Servers.
|
/// Sucht über alle Mediatheken des Servers.
|
||||||
@@ -8,7 +8,7 @@ import MediaKit
|
|||||||
/// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server
|
/// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server
|
||||||
/// stellt. Siehe dort, warum das kein Beiwerk ist.
|
/// stellt. Siehe dort, warum das kein Beiwerk ist.
|
||||||
struct SearchView: View {
|
struct SearchView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
|
|
||||||
@State private var text = ""
|
@State private var text = ""
|
||||||
@State private var feed = SearchFeed()
|
@State private var feed = SearchFeed()
|
||||||
@@ -23,9 +23,9 @@ struct SearchView: View {
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("search.title"))
|
.navigationTitle(Text("search.title"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.searchable(text: $text)
|
.searchable(text: $text)
|
||||||
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
|
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
|
||||||
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
|
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
|
||||||
@@ -35,12 +35,12 @@ struct SearchView: View {
|
|||||||
// in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus
|
// in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus
|
||||||
// dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah
|
// dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah
|
||||||
// aus, als wäre nichts passiert.
|
// aus, als wäre nichts passiert.
|
||||||
.environment(\.flixyItemChanged, { await feed.search(text, from: session.backend) })
|
.environment(\.haloItemChanged, { await feed.search(text, from: session.backend) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private var results: some View {
|
private var results: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||||
ForEach(feed.hubs) { hub in
|
ForEach(feed.hubs) { hub in
|
||||||
// **Antippen führt auf die Seite, nicht in den Film.** Die
|
// **Antippen führt auf die Seite, nicht in den Film.** Die
|
||||||
// Suche benutzt dieselbe Reihe wie die Startseite — und
|
// Suche benutzt dieselbe Reihe wie die Startseite — und
|
||||||
@@ -51,9 +51,9 @@ struct SearchView: View {
|
|||||||
MediaRow(row: hub, opensDetails: true)
|
MediaRow(row: hub, opensDetails: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, FlixySpacing.loose)
|
.padding(.vertical, HaloSpacing.loose)
|
||||||
}
|
}
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -72,7 +72,7 @@ struct SearchView: View {
|
|||||||
text = entry
|
text = entry
|
||||||
} label: {
|
} label: {
|
||||||
Label(entry, systemImage: "clock.arrow.circlepath")
|
Label(entry, systemImage: "clock.arrow.circlepath")
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -81,12 +81,12 @@ struct SearchView: View {
|
|||||||
Button("search.forgetRecent", role: .destructive) {
|
Button("search.forgetRecent", role: .destructive) {
|
||||||
feed.forgetRecent()
|
feed.forgetRecent()
|
||||||
}
|
}
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+28
-28
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// Die Farbeinstellungen.
|
/// Die Farbeinstellungen.
|
||||||
///
|
///
|
||||||
@@ -8,22 +8,22 @@ import FlixyDesign
|
|||||||
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
|
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
|
||||||
/// stillschweigend zu verändern.
|
/// stillschweigend zu verändern.
|
||||||
struct AppearanceView: View {
|
struct AppearanceView: View {
|
||||||
@Environment(FlixyTheme.self) private var theme
|
@Environment(HaloTheme.self) private var theme
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
@State private var customHex: String = ""
|
@State private var customHex: String = ""
|
||||||
@State private var showsCustomField = false
|
@State private var showsCustomField = false
|
||||||
|
|
||||||
private let columns = [GridItem(.adaptive(minimum: 64), spacing: FlixySpacing.regular)]
|
private let columns = [GridItem(.adaptive(minimum: 64), spacing: HaloSpacing.regular)]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
LazyVGrid(columns: columns, spacing: FlixySpacing.regular) {
|
LazyVGrid(columns: columns, spacing: HaloSpacing.regular) {
|
||||||
ForEach(AccentOption.palette) { option in
|
ForEach(AccentOption.palette) { option in
|
||||||
swatch(option)
|
swatch(option)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
} header: {
|
} header: {
|
||||||
Text("appearance.accent")
|
Text("appearance.accent")
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -38,7 +38,7 @@ struct AppearanceView: View {
|
|||||||
|
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0))))
|
Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0))))
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
} label: {
|
} label: {
|
||||||
Text("appearance.cardScale")
|
Text("appearance.cardScale")
|
||||||
@@ -52,7 +52,7 @@ struct AppearanceView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("appearance.cardScaleExplanation")
|
Text("appearance.cardScaleExplanation")
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
|
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
|
||||||
@@ -60,7 +60,7 @@ struct AppearanceView: View {
|
|||||||
if showsCustomField {
|
if showsCustomField {
|
||||||
HStack {
|
HStack {
|
||||||
TextField("appearance.hexPlaceholder", text: $customHex)
|
TextField("appearance.hexPlaceholder", text: $customHex)
|
||||||
.flixyPlainEntry()
|
.haloPlainEntry()
|
||||||
.onSubmit(applyCustomColor)
|
.onSubmit(applyCustomColor)
|
||||||
|
|
||||||
Button("action.apply", action: applyCustomColor)
|
Button("action.apply", action: applyCustomColor)
|
||||||
@@ -69,8 +69,8 @@ struct AppearanceView: View {
|
|||||||
|
|
||||||
if !customHex.isEmpty, RGB(string: customHex) == nil {
|
if !customHex.isEmpty, RGB(string: customHex) == nil {
|
||||||
Text("appearance.invalidHex")
|
Text("appearance.invalidHex")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyDestructive)
|
.foregroundStyle(Color.haloDestructive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +80,11 @@ struct AppearanceView: View {
|
|||||||
// als im Farbwähler, und niemand wüsste warum.
|
// als im Farbwähler, und niemand wüsste warum.
|
||||||
Label {
|
Label {
|
||||||
Text("appearance.adjusted \(theme.accent.hexString)")
|
Text("appearance.adjusted \(theme.accent.hexString)")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
}
|
}
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,11 +92,11 @@ struct AppearanceView: View {
|
|||||||
PreviewRow()
|
PreviewRow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flixyForm()
|
.haloForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("appearance.title"))
|
.navigationTitle(Text("appearance.title"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if theme.selection.id == AccentOption.customIdentifier {
|
if theme.selection.id == AccentOption.customIdentifier {
|
||||||
customHex = theme.selection.color.hexString
|
customHex = theme.selection.color.hexString
|
||||||
@@ -119,13 +119,13 @@ struct AppearanceView: View {
|
|||||||
// Dunkel auf der Farbe, nicht weiß: die Palette
|
// Dunkel auf der Farbe, nicht weiß: die Palette
|
||||||
// enthält helle Töne wie Amber, auf denen Weiß
|
// enthält helle Töne wie Amber, auf denen Weiß
|
||||||
// verschwinden würde.
|
// verschwinden würde.
|
||||||
.foregroundStyle(Color.flixyCanvas)
|
.foregroundStyle(Color.haloCanvas)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
Circle()
|
Circle()
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
theme.selection == option ? Color.flixyTextPrimary : .clear,
|
theme.selection == option ? Color.haloTextPrimary : .clear,
|
||||||
lineWidth: 2
|
lineWidth: 2
|
||||||
)
|
)
|
||||||
.padding(-4)
|
.padding(-4)
|
||||||
@@ -144,27 +144,27 @@ struct AppearanceView: View {
|
|||||||
|
|
||||||
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
|
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
|
||||||
private struct PreviewRow: View {
|
private struct PreviewRow: View {
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
Label("hero.play", systemImage: "play.fill")
|
Label("hero.play", systemImage: "play.fill")
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.padding(.horizontal, FlixySpacing.loose)
|
.padding(.horizontal, HaloSpacing.loose)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.background(accent, in: .rect(cornerRadius: FlixyRadius.control))
|
.background(accent, in: .rect(cornerRadius: HaloRadius.control))
|
||||||
.foregroundStyle(Color.flixyCanvas)
|
.foregroundStyle(Color.haloCanvas)
|
||||||
|
|
||||||
Label("sidebar.downloads", systemImage: "arrow.down.circle")
|
Label("sidebar.downloads", systemImage: "arrow.down.circle")
|
||||||
.font(.flixyCardTitle)
|
.font(.haloCardTitle)
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
WatchProgressBar(fraction: 0.45)
|
WatchProgressBar(fraction: 0.45)
|
||||||
}
|
}
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+9
-9
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
|
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
|
||||||
///
|
///
|
||||||
@@ -23,15 +23,15 @@ struct AudioLanguagePicker: View {
|
|||||||
let title: LocalizedStringKey
|
let title: LocalizedStringKey
|
||||||
|
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
Spacer(minLength: FlixySpacing.regular)
|
Spacer(minLength: HaloSpacing.regular)
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
||||||
@@ -53,11 +53,11 @@ struct AudioLanguagePicker: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlixySpacing.tight) {
|
HStack(spacing: HaloSpacing.tight) {
|
||||||
Text(currentLabel)
|
Text(currentLabel)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
}
|
}
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
}
|
}
|
||||||
+23
-67
@@ -1,6 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Was beim letzten Abspielversuch passiert ist.
|
/// Was beim letzten Abspielversuch passiert ist.
|
||||||
///
|
///
|
||||||
@@ -8,29 +7,24 @@ import PlexKit
|
|||||||
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
|
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
|
||||||
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
|
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
|
||||||
struct DiagnosticsView: View {
|
struct DiagnosticsView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
@State private var liveTVStructure: String?
|
|
||||||
@State private var isInspectingLiveTV = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section("diagnostics.connection") {
|
Section("diagnostics.connection") {
|
||||||
if let server = session.server {
|
if session.backend != nil {
|
||||||
LabeledContent("settings.serverName", value: server.name)
|
LabeledContent("settings.serverName", value: session.serverName)
|
||||||
LabeledContent("settings.address", value: server.connection.uri)
|
if let address = session.serverAddress {
|
||||||
.font(.flixyCaption)
|
LabeledContent("settings.address", value: address)
|
||||||
|
.font(.haloCaption)
|
||||||
if server.connection.relay {
|
}
|
||||||
// Der häufigste Grund dafür, dass nichts anläuft. Ohne
|
if let beschreibung = session.connectionDescription {
|
||||||
// Hinweis sucht man den Fehler in der App.
|
LabeledContent("settings.connection", value: beschreibung)
|
||||||
Label("diagnostics.relayWarning", systemImage: "exclamationmark.triangle")
|
.font(.haloCaption)
|
||||||
.font(.flixyCaption)
|
|
||||||
.foregroundStyle(Color.flixyDestructive)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("diagnostics.noServer").foregroundStyle(Color.flixyTextMuted)
|
Text("diagnostics.noServer").foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +36,11 @@ struct DiagnosticsView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("diagnostics.source")
|
Text("diagnostics.source")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
Text(attempt.sourceDescription)
|
Text(attempt.sourceDescription)
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,13 +48,13 @@ struct DiagnosticsView: View {
|
|||||||
ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in
|
ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in
|
||||||
Text(step)
|
Text(step)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
FlixyClipboard.copy(report(for: attempt))
|
HaloClipboard.copy(report(for: attempt))
|
||||||
} label: {
|
} label: {
|
||||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||||
}
|
}
|
||||||
@@ -69,40 +63,10 @@ struct DiagnosticsView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Section("diagnostics.lastAttempt") {
|
Section("diagnostics.lastAttempt") {
|
||||||
Text("diagnostics.nothingYet").foregroundStyle(Color.flixyTextMuted)
|
Text("diagnostics.nothingYet").foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
|
||||||
Button {
|
|
||||||
Task { await inspectLiveTV() }
|
|
||||||
} label: {
|
|
||||||
if isInspectingLiveTV {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Label("diagnostics.liveTV", systemImage: "antenna.radiowaves.left.and.right")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(isInspectingLiveTV)
|
|
||||||
|
|
||||||
if let liveTVStructure {
|
|
||||||
Text(liveTVStructure)
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
FlixyClipboard.copy(liveTVStructure)
|
|
||||||
} label: {
|
|
||||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("sidebar.liveTV")
|
|
||||||
} footer: {
|
|
||||||
Text("diagnostics.liveTVHint")
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("settings.app") {
|
Section("settings.app") {
|
||||||
LabeledContent("settings.version", value: Branding.version)
|
LabeledContent("settings.version", value: Branding.version)
|
||||||
LabeledContent("settings.build", value: Branding.buildStamp)
|
LabeledContent("settings.build", value: Branding.buildStamp)
|
||||||
@@ -115,19 +79,11 @@ struct DiagnosticsView: View {
|
|||||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.flixyForm()
|
.haloForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("diagnostics.title"))
|
.navigationTitle(Text("diagnostics.title"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
}
|
|
||||||
|
|
||||||
/// Fragt den Server, wie seine Antwort auf `/livetv/dvrs` aufgebaut ist.
|
|
||||||
private func inspectLiveTV() async {
|
|
||||||
guard let server = session.server else { return }
|
|
||||||
isInspectingLiveTV = true
|
|
||||||
defer { isInspectingLiveTV = false }
|
|
||||||
liveTVStructure = await server.liveTVStructure()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Bericht als Text zum Weitergeben.
|
/// Der Bericht als Text zum Weitergeben.
|
||||||
@@ -136,7 +92,7 @@ struct DiagnosticsView: View {
|
|||||||
/// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches.
|
/// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches.
|
||||||
private func report(for attempt: PlaybackDiagnostics.Attempt) -> String {
|
private func report(for attempt: PlaybackDiagnostics.Attempt) -> String {
|
||||||
"""
|
"""
|
||||||
Flixy \(Branding.version) · Build \(Branding.buildStamp)
|
Halo \(Branding.version) · Build \(Branding.buildStamp)
|
||||||
Verbindung: \(attempt.connection)
|
Verbindung: \(attempt.connection)
|
||||||
Titel: \(attempt.title)
|
Titel: \(attempt.title)
|
||||||
Weg: \(attempt.method)
|
Weg: \(attempt.method)
|
||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyDownloads
|
import HaloDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Was beim Herunterladen gilt.
|
/// Was beim Herunterladen gilt.
|
||||||
@@ -11,7 +11,7 @@ import MediaKit
|
|||||||
/// rutschen.
|
/// rutschen.
|
||||||
struct DownloadSettingsView: View {
|
struct DownloadSettingsView: View {
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var downloads = downloads
|
@Bindable var downloads = downloads
|
||||||
@@ -73,11 +73,11 @@ struct DownloadSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.flixyForm()
|
.haloForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("sidebar.downloads"))
|
.navigationTitle(Text("sidebar.downloads"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var storageLimitBinding: Binding<Int64> {
|
private var storageLimitBinding: Binding<Int64> {
|
||||||
+17
-17
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
||||||
struct HomeLayoutView: View {
|
struct HomeLayoutView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
|
|
||||||
@State private var rows: [HomeRow] = []
|
@State private var rows: [HomeRow] = []
|
||||||
@@ -21,9 +21,9 @@ struct HomeLayoutView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
list
|
list
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("homeLayout.title"))
|
.navigationTitle(Text("homeLayout.title"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ struct HomeLayoutView: View {
|
|||||||
// gerade so weit hereinlugt, wie man es mag.
|
// gerade so weit hereinlugt, wie man es mag.
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
|
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
} label: {
|
} label: {
|
||||||
Text("home.heroHeight")
|
Text("home.heroHeight")
|
||||||
@@ -64,7 +64,7 @@ struct HomeLayoutView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("home.heroExplanation")
|
Text("home.heroExplanation")
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
|
|
||||||
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
|
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
|
||||||
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
|
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
|
||||||
@@ -84,7 +84,7 @@ struct HomeLayoutView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(library.title)
|
Text(library.title)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
}
|
}
|
||||||
// Damit der ganze Streifen trifft und nicht nur
|
// Damit der ganze Streifen trifft und nicht nur
|
||||||
// die Schrift — eine Zeile, bei der nur ein Wort
|
// die Schrift — eine Zeile, bei der nur ein Wort
|
||||||
@@ -99,7 +99,7 @@ struct HomeLayoutView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("home.heroLibraries")
|
Text("home.heroLibraries")
|
||||||
} footer: {
|
} footer: {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||||
Text("home.heroLibrariesExplanation")
|
Text("home.heroLibrariesExplanation")
|
||||||
// Lieber eine Erklärung als ein Aufmacher, der
|
// Lieber eine Erklärung als ein Aufmacher, der
|
||||||
// heimlich etwas anderes zeigt: trifft die Auswahl
|
// heimlich etwas anderes zeigt: trifft die Auswahl
|
||||||
@@ -107,18 +107,18 @@ struct HomeLayoutView: View {
|
|||||||
// dann muss dastehen, warum.
|
// dann muss dastehen, warum.
|
||||||
if greiftInsLeere {
|
if greiftInsLeere {
|
||||||
Text("home.heroLibrariesNoMatch")
|
Text("home.heroLibrariesNoMatch")
|
||||||
.foregroundStyle(Color.flixyDestructive)
|
.foregroundStyle(Color.haloDestructive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
||||||
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -126,8 +126,8 @@ struct HomeLayoutView: View {
|
|||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
} else if rows.isEmpty {
|
} else if rows.isEmpty {
|
||||||
Text("homeLayout.empty")
|
Text("homeLayout.empty")
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -140,9 +140,9 @@ struct HomeLayoutView: View {
|
|||||||
}
|
}
|
||||||
)) {
|
)) {
|
||||||
Text(row.title)
|
Text(row.title)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
}
|
}
|
||||||
.onMove { indices, destination in
|
.onMove { indices, destination in
|
||||||
rows.move(fromOffsets: indices, toOffset: destination)
|
rows.move(fromOffsets: indices, toOffset: destination)
|
||||||
@@ -162,7 +162,7 @@ struct HomeLayoutView: View {
|
|||||||
if !rows.isEmpty {
|
if !rows.isEmpty {
|
||||||
Spacer()
|
Spacer()
|
||||||
EditButton()
|
EditButton()
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.textCase(nil)
|
.textCase(nil)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
+10
-10
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// Welche Sprachen der Player von sich aus wählen soll.
|
/// Welche Sprachen der Player von sich aus wählen soll.
|
||||||
///
|
///
|
||||||
@@ -9,7 +9,7 @@ import FlixyDesign
|
|||||||
/// Vorliebe, die niemand äußern kann, ist keine.
|
/// Vorliebe, die niemand äußern kann, ist keine.
|
||||||
struct LanguagesView: View {
|
struct LanguagesView: View {
|
||||||
@Environment(LanguagePreferences.self) private var settings
|
@Environment(LanguagePreferences.self) private var settings
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
@State private var isAdding = false
|
@State private var isAdding = false
|
||||||
|
|
||||||
@@ -61,11 +61,11 @@ struct LanguagesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.flixyForm()
|
.haloForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("languages.title"))
|
.navigationTitle(Text("languages.title"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
// Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht
|
// Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht
|
||||||
// das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form`
|
// das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form`
|
||||||
// genügt, sobald die Liste bearbeitbar ist.
|
// genügt, sobald die Liste bearbeitbar ist.
|
||||||
@@ -86,19 +86,19 @@ struct LanguagesView: View {
|
|||||||
Section {
|
Section {
|
||||||
if settings.fallbacks.isEmpty {
|
if settings.fallbacks.isEmpty {
|
||||||
Text("languages.fallbackEmpty")
|
Text("languages.fallbackEmpty")
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in
|
ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
// Die Nummer, weil die Reihenfolge der ganze Zweck
|
// Die Nummer, weil die Reihenfolge der ganze Zweck
|
||||||
// ist — eine Liste ohne sichtbaren Rang liest sich
|
// ist — eine Liste ohne sichtbaren Rang liest sich
|
||||||
// wie eine Menge.
|
// wie eine Menge.
|
||||||
Text("\(position + 1)")
|
Text("\(position + 1)")
|
||||||
.font(.flixyCaption.monospacedDigit())
|
.font(.haloCaption.monospacedDigit())
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
.frame(minWidth: 18, alignment: .trailing)
|
.frame(minWidth: 18, alignment: .trailing)
|
||||||
Text(name(of: code))
|
Text(name(of: code))
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onMove { von, nach in
|
.onMove { von, nach in
|
||||||
+9
-9
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
|
|
||||||
/// Alles, was die Wiedergabe betrifft.
|
/// Alles, was die Wiedergabe betrifft.
|
||||||
///
|
///
|
||||||
@@ -9,7 +9,7 @@ import FlixyPlayback
|
|||||||
/// einmal durchgeht und dann in Ruhe lässt.
|
/// einmal durchgeht und dann in Ruhe lässt.
|
||||||
struct PlaybackSettingsView: View {
|
struct PlaybackSettingsView: View {
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var settings = settings
|
@Bindable var settings = settings
|
||||||
@@ -36,7 +36,7 @@ struct PlaybackSettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("settings.quality")
|
Text("settings.quality")
|
||||||
} footer: {
|
} footer: {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||||
Text("settings.qualityExplanation")
|
Text("settings.qualityExplanation")
|
||||||
|
|
||||||
// **Nur mit Beleg.** Eine Warnung, die immer dasteht,
|
// **Nur mit Beleg.** Eine Warnung, die immer dasteht,
|
||||||
@@ -55,8 +55,8 @@ struct PlaybackSettingsView: View {
|
|||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
}
|
}
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,11 +108,11 @@ struct PlaybackSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.flixyForm()
|
.haloForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("settings.playback"))
|
.navigationTitle(Text("settings.playback"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rateLabel(_ rate: Double) -> String {
|
private func rateLabel(_ rate: Double) -> String {
|
||||||
+22
-32
@@ -1,27 +1,25 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
||||||
struct ServerSettingsView: View {
|
struct ServerSettingsView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section("settings.server") {
|
Section("settings.server") {
|
||||||
LabeledContent("settings.serverName", value: session.serverName)
|
LabeledContent("settings.serverName", value: session.serverName)
|
||||||
// Welche Art von Server — bei zwei möglichen ist das keine
|
// Nah oder fern: daran hängt, ob „Auto" gedeckelt anfängt,
|
||||||
// Nebensache mehr, sondern die erste Frage bei jeder
|
// und damit die erste Frage bei jeder Rückmeldung über
|
||||||
// Rückmeldung.
|
// Ruckeln.
|
||||||
LabeledContent("settings.serverKind", value: kindLabel)
|
if session.backend != nil {
|
||||||
if let connection = session.server?.connection {
|
LabeledContent("settings.connection", value: connectionLabel)
|
||||||
LabeledContent("settings.connection", value: connectionLabel(connection))
|
|
||||||
}
|
}
|
||||||
if let address = session.serverAddress {
|
if let address = session.serverAddress {
|
||||||
LabeledContent("settings.address", value: address)
|
LabeledContent("settings.address", value: address)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
if let accountName = session.accountName {
|
if let accountName = session.accountName {
|
||||||
@@ -46,27 +44,19 @@ struct ServerSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.flixyForm()
|
.haloForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("settings.server"))
|
.navigationTitle(Text("settings.server"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var kindLabel: String {
|
/// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
|
||||||
switch session.kind {
|
/// ist das die erste Frage.
|
||||||
case .plex: "Plex"
|
private var connectionLabel: String {
|
||||||
case .jellyfin: "Jellyfin"
|
session.isLocalConnection
|
||||||
case nil: "—"
|
? String(localized: "settings.connectionLocal")
|
||||||
}
|
: String(localized: "settings.connectionRemote")
|
||||||
}
|
|
||||||
|
|
||||||
/// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
|
|
||||||
/// Ruckeln ist das die erste Frage.
|
|
||||||
private func connectionLabel(_ connection: PlexConnection) -> String {
|
|
||||||
if connection.local { return String(localized: "settings.connectionLocal") }
|
|
||||||
if connection.relay { return String(localized: "settings.connectionRelay") }
|
|
||||||
return String(localized: "settings.connectionRemote")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,20 +64,20 @@ struct ServerSettingsView: View {
|
|||||||
struct LibraryRow: View {
|
struct LibraryRow: View {
|
||||||
let library: MediaLibrary
|
let library: MediaLibrary
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@State private var phase: Phase = .idle
|
@State private var phase: Phase = .idle
|
||||||
|
|
||||||
private enum Phase { case idle, running, done }
|
private enum Phase { case idle, running, done }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
|
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
|
||||||
// „Filme" stand sonst als „Filme — Filme" da.
|
// „Filme" stand sonst als „Filme — Filme" da.
|
||||||
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
|
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
|
||||||
Text(library.kind.label)
|
Text(library.kind.label)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.backend?.capabilities.contains(.libraryScan) == true {
|
if session.backend?.capabilities.contains(.libraryScan) == true {
|
||||||
+8
-8
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// Das farbige Symbol vor einer Einstellungszeile.
|
/// Das farbige Symbol vor einer Einstellungszeile.
|
||||||
///
|
///
|
||||||
@@ -35,7 +35,7 @@ struct SettingsHeader: View {
|
|||||||
let explanation: LocalizedStringKey
|
let explanation: LocalizedStringKey
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.font(.title2.weight(.semibold))
|
.font(.title2.weight(.semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
@@ -43,16 +43,16 @@ struct SettingsHeader: View {
|
|||||||
.background(colour.gradient, in: .rect(cornerRadius: 13))
|
.background(colour.gradient, in: .rect(cornerRadius: 13))
|
||||||
|
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.flixyTitle)
|
.font(.haloTitle)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
Text(explanation)
|
Text(explanation)
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, HaloSpacing.snug)
|
||||||
.listRowBackground(Color.flixySurface)
|
.listRowBackground(Color.haloSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
|
|
||||||
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
|
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
|
||||||
///
|
///
|
||||||
@@ -10,8 +10,8 @@ import FlixyDesign
|
|||||||
///
|
///
|
||||||
/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört.
|
/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
@State private var showsSignOutConfirmation = false
|
@State private var showsSignOutConfirmation = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -23,7 +23,7 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
Text(session.serverName)
|
Text(session.serverName)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
} label: {
|
} label: {
|
||||||
row(
|
row(
|
||||||
"settings.server",
|
"settings.server",
|
||||||
@@ -83,12 +83,12 @@ struct SettingsView: View {
|
|||||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.flixyForm()
|
.haloForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.flixyRevealsTopChrome()
|
.haloRevealsTopChrome()
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("sidebar.settings"))
|
.navigationTitle(Text("sidebar.settings"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
Text("settings.signOutConfirm"),
|
Text("settings.signOutConfirm"),
|
||||||
isPresented: $showsSignOutConfirmation,
|
isPresented: $showsSignOutConfirmation,
|
||||||
@@ -115,7 +115,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View {
|
private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View {
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
HStack(spacing: HaloSpacing.regular) {
|
||||||
SettingsIcon(symbol: symbol, colour: colour)
|
SettingsIcon(symbol: symbol, colour: colour)
|
||||||
Text(title)
|
Text(title)
|
||||||
}
|
}
|
||||||
+8
-8
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
|
|
||||||
/// Wie Untertitel aussehen sollen.
|
/// Wie Untertitel aussehen sollen.
|
||||||
struct SubtitleAppearanceView: View {
|
struct SubtitleAppearanceView: View {
|
||||||
@Environment(SubtitleAppearance.self) private var appearance
|
@Environment(SubtitleAppearance.self) private var appearance
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var appearance = appearance
|
@Bindable var appearance = appearance
|
||||||
@@ -51,11 +51,11 @@ struct SubtitleAppearanceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.flixyForm()
|
.haloForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("subtitleStyle.title"))
|
.navigationTitle(Text("subtitleStyle.title"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Beispielsatz über grauer Fläche.
|
/// Ein Beispielsatz über grauer Fläche.
|
||||||
@@ -84,10 +84,10 @@ struct SubtitleAppearanceView: View {
|
|||||||
radius: 2,
|
radius: 2,
|
||||||
y: 1
|
y: 1
|
||||||
)
|
)
|
||||||
.padding(FlixySpacing.regular)
|
.padding(HaloSpacing.regular)
|
||||||
}
|
}
|
||||||
.frame(height: 96)
|
.frame(height: 96)
|
||||||
.clipShape(.rect(cornerRadius: FlixyRadius.card))
|
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import HaloDesign
|
||||||
|
|
||||||
|
/// Während ein Weg zum Server gesucht wird.
|
||||||
|
struct ConnectingView: View {
|
||||||
|
let serverName: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: HaloSpacing.loose) {
|
||||||
|
ProgressView()
|
||||||
|
Text(serverName.isEmpty
|
||||||
|
? String(localized: "connecting.searching")
|
||||||
|
: String(localized: "connecting.to \(serverName)"))
|
||||||
|
.font(.haloBody)
|
||||||
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.haloCanvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wenn etwas schiefgegangen ist.
|
||||||
|
struct ConnectionFailureView: View {
|
||||||
|
let message: String
|
||||||
|
let onRetry: () -> Void
|
||||||
|
let onSignOut: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||||
|
} description: {
|
||||||
|
Text(message)
|
||||||
|
} actions: {
|
||||||
|
Button("action.retry", action: onRetry).buttonStyle(.borderedProminent)
|
||||||
|
Button("action.signOut", action: onSignOut)
|
||||||
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
|
}
|
||||||
|
.background(Color.haloCanvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-60
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
|
|
||||||
/// Die Anmeldung an einem Jellyfin-Server.
|
/// Die Anmeldung an einem Jellyfin-Server.
|
||||||
@@ -9,9 +9,9 @@ import JellyfinKit
|
|||||||
/// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort
|
/// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort
|
||||||
/// durch diese App läuft.
|
/// durch diese App läuft.
|
||||||
struct JellyfinSignInView: View {
|
struct JellyfinSignInView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(HaloSession.self) private var session
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.haloAccent) private var accent
|
||||||
|
|
||||||
@State private var model: JellyfinSignInModel?
|
@State private var model: JellyfinSignInModel?
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@@ -23,16 +23,16 @@ struct JellyfinSignInView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
if let model {
|
if let model {
|
||||||
content(for: model)
|
content(for: model)
|
||||||
.padding(.horizontal, FlixySpacing.section)
|
.padding(.horizontal, HaloSpacing.section)
|
||||||
.padding(.vertical, FlixySpacing.section)
|
.padding(.vertical, HaloSpacing.section)
|
||||||
.frame(maxWidth: 420)
|
.frame(maxWidth: 420)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.haloCanvas)
|
||||||
.navigationTitle(Text("signIn.jellyfin.title"))
|
.navigationTitle(Text("signIn.jellyfin.title"))
|
||||||
.flixyInlineTitle()
|
.haloInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("action.cancel") {
|
Button("action.cancel") {
|
||||||
@@ -66,7 +66,7 @@ struct JellyfinSignInView: View {
|
|||||||
private func content(for model: JellyfinSignInModel) -> some View {
|
private func content(for model: JellyfinSignInModel) -> some View {
|
||||||
@Bindable var model = model
|
@Bindable var model = model
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
VStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||||
switch model.step {
|
switch model.step {
|
||||||
case .address:
|
case .address:
|
||||||
addressStep(model: model)
|
addressStep(model: model)
|
||||||
@@ -82,8 +82,8 @@ struct JellyfinSignInView: View {
|
|||||||
|
|
||||||
if let message = model.errorMessage {
|
if let message = model.errorMessage {
|
||||||
Label(message, systemImage: "exclamationmark.triangle")
|
Label(message, systemImage: "exclamationmark.triangle")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyDestructive)
|
.foregroundStyle(Color.haloDestructive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,39 +94,39 @@ struct JellyfinSignInView: View {
|
|||||||
private func addressStep(model: JellyfinSignInModel) -> some View {
|
private func addressStep(model: JellyfinSignInModel) -> some View {
|
||||||
@Bindable var model = model
|
@Bindable var model = model
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
Text("signIn.jellyfin.addressPrompt")
|
Text("signIn.jellyfin.addressPrompt")
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
|
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.textContentType(.URL)
|
.textContentType(.URL)
|
||||||
.flixyPlainEntry(isAddress: true)
|
.haloPlainEntry(isAddress: true)
|
||||||
.submitLabel(.go)
|
.submitLabel(.go)
|
||||||
.focused($focusedField, equals: .address)
|
.focused($focusedField, equals: .address)
|
||||||
.onSubmit { Task { await model.findServer() } }
|
.onSubmit { Task { await model.findServer() } }
|
||||||
// Hier statt beim Öffnen des Sheets: so bekommt das Feld den
|
// Hier statt beim Öffnen des Sheets: so bekommt das Feld den
|
||||||
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
|
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
|
||||||
.task { focusedField = .address }
|
.task { focusedField = .address }
|
||||||
.padding(FlixySpacing.regular)
|
.padding(HaloSpacing.regular)
|
||||||
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
|
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
|
||||||
// niemandem etwas, „192.168.1.50" jedem.
|
// niemandem etwas, „192.168.1.50" jedem.
|
||||||
Text("signIn.jellyfin.addressHint")
|
Text("signIn.jellyfin.addressHint")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
focusedField = nil
|
focusedField = nil
|
||||||
Task { await model.findServer() }
|
Task { await model.findServer() }
|
||||||
} label: {
|
} label: {
|
||||||
Text("signIn.jellyfin.connect")
|
Text("signIn.jellyfin.connect")
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
.padding(.vertical, HaloSpacing.regular)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
@@ -138,40 +138,40 @@ struct JellyfinSignInView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View {
|
private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
foundServerHeader(model: model)
|
foundServerHeader(model: model)
|
||||||
|
|
||||||
Text("signIn.jellyfin.quickConnectHint")
|
Text("signIn.jellyfin.quickConnectHint")
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
|
|
||||||
Text(code)
|
Text(code)
|
||||||
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
|
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
|
||||||
.tracking(8)
|
.tracking(8)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
.padding(.vertical, HaloSpacing.regular)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
||||||
|
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
Text("signIn.jellyfin.waitingForApproval")
|
Text("signIn.jellyfin.waitingForApproval")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("signIn.jellyfin.privacy")
|
Text("signIn.jellyfin.privacy")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
|
|
||||||
Divider().overlay(Color.flixySeparator)
|
Divider().overlay(Color.haloSeparator)
|
||||||
|
|
||||||
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
|
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,12 +181,12 @@ struct JellyfinSignInView: View {
|
|||||||
private func credentialsStep(model: JellyfinSignInModel) -> some View {
|
private func credentialsStep(model: JellyfinSignInModel) -> some View {
|
||||||
@Bindable var model = model
|
@Bindable var model = model
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||||
foundServerHeader(model: model)
|
foundServerHeader(model: model)
|
||||||
|
|
||||||
TextField("signIn.jellyfin.userName", text: $model.userName)
|
TextField("signIn.jellyfin.userName", text: $model.userName)
|
||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
.flixyPlainEntry()
|
.haloPlainEntry()
|
||||||
.submitLabel(.next)
|
.submitLabel(.next)
|
||||||
.focused($focusedField, equals: .userName)
|
.focused($focusedField, equals: .userName)
|
||||||
.onSubmit { focusedField = .password }
|
.onSubmit { focusedField = .password }
|
||||||
@@ -200,8 +200,8 @@ struct JellyfinSignInView: View {
|
|||||||
focusedField = .password
|
focusedField = .password
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.regular)
|
.padding(HaloSpacing.regular)
|
||||||
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
||||||
|
|
||||||
SecureField("signIn.jellyfin.password", text: $model.password)
|
SecureField("signIn.jellyfin.password", text: $model.password)
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
@@ -214,17 +214,17 @@ struct JellyfinSignInView: View {
|
|||||||
focusedField = .userName
|
focusedField = .userName
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
.padding(FlixySpacing.regular)
|
.padding(HaloSpacing.regular)
|
||||||
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
focusedField = nil
|
focusedField = nil
|
||||||
Task { await model.signInWithPassword() }
|
Task { await model.signInWithPassword() }
|
||||||
} label: {
|
} label: {
|
||||||
Text("signIn.jellyfin.submit")
|
Text("signIn.jellyfin.submit")
|
||||||
.font(.flixyRowHeader)
|
.font(.haloRowHeader)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, FlixySpacing.regular)
|
.padding(.vertical, HaloSpacing.regular)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
@@ -234,46 +234,46 @@ struct JellyfinSignInView: View {
|
|||||||
// verworfen. Gespeichert wird nur der Token, und der im
|
// verworfen. Gespeichert wird nur der Token, und der im
|
||||||
// Schlüsselbund.
|
// Schlüsselbund.
|
||||||
Text("signIn.jellyfin.passwordNote")
|
Text("signIn.jellyfin.passwordNote")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
|
|
||||||
Divider().overlay(Color.flixySeparator)
|
Divider().overlay(Color.haloSeparator)
|
||||||
|
|
||||||
if model.offersQuickConnect {
|
if model.offersQuickConnect {
|
||||||
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
|
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
}
|
}
|
||||||
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bausteine
|
// MARK: - Bausteine
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func foundServerHeader(model: JellyfinSignInModel) -> some View {
|
private func foundServerHeader(model: JellyfinSignInModel) -> some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.tight) {
|
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
|
||||||
Text("signIn.jellyfin.foundServer")
|
Text("signIn.jellyfin.foundServer")
|
||||||
.font(.flixyCaption)
|
.font(.haloCaption)
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
Text(model.serverName ?? model.address)
|
Text(model.serverName ?? model.address)
|
||||||
.font(.flixyTitle)
|
.font(.haloTitle)
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func waiting(text: LocalizedStringKey) -> some View {
|
private func waiting(text: LocalizedStringKey) -> some View {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: HaloSpacing.snug) {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.flixyBody)
|
.font(.haloBody)
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.vertical, FlixySpacing.section)
|
.padding(.vertical, HaloSpacing.section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import HaloDesign
|
||||||
|
|
||||||
|
/// Der Anmeldebildschirm.
|
||||||
|
///
|
||||||
|
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
|
||||||
|
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
|
||||||
|
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
|
||||||
|
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
|
||||||
|
///
|
||||||
|
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
|
||||||
|
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
|
||||||
|
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
|
||||||
|
struct SignInView: View {
|
||||||
|
@Environment(\.haloAccent) private var accent
|
||||||
|
@State private var showsJellyfinSignIn = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: HaloSpacing.section) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: HaloSpacing.regular) {
|
||||||
|
Image(systemName: "play.rectangle.on.rectangle")
|
||||||
|
.font(.system(size: 52))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
|
||||||
|
Text(Branding.appName)
|
||||||
|
.font(.haloHero)
|
||||||
|
.foregroundStyle(Color.haloTextPrimary)
|
||||||
|
|
||||||
|
Text("signIn.tagline")
|
||||||
|
.font(.haloBody)
|
||||||
|
.foregroundStyle(Color.haloTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: HaloSpacing.regular) {
|
||||||
|
Button {
|
||||||
|
showsJellyfinSignIn = true
|
||||||
|
} label: {
|
||||||
|
Text("signIn.withJellyfin")
|
||||||
|
.font(.haloRowHeader)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, HaloSpacing.regular)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accent)
|
||||||
|
|
||||||
|
Text("signIn.privacy")
|
||||||
|
.font(.haloCaption)
|
||||||
|
.foregroundStyle(Color.haloTextMuted)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer().frame(height: HaloSpacing.section)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, HaloSpacing.section)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.haloCanvas)
|
||||||
|
.sheet(isPresented: $showsJellyfinSignIn) {
|
||||||
|
JellyfinSignInView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.scarriffleservices.flixy</string>
|
<string>group.com.scarriffleservices.halo</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import HaloDesign
|
||||||
import FlixyPlayback
|
import HaloPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Die App auf dem Fernseher.
|
/// Die App auf dem Fernseher.
|
||||||
///
|
///
|
||||||
/// **Eine eigene Wurzel statt eines Zweigs in `FlixyApp`.** Der Unterschied
|
/// **Eine eigene Wurzel statt eines Zweigs in `HaloApp`.** Der Unterschied
|
||||||
/// zwischen Telefon und Fernseher ist keine Feinheit der Darstellung, sondern
|
/// zwischen Telefon und Fernseher ist keine Feinheit der Darstellung, sondern
|
||||||
/// die Bedienart: kein Finger, keine Grössenklasse, keine Werkzeugleiste, kein
|
/// die Bedienart: kein Finger, keine Grössenklasse, keine Werkzeugleiste, kein
|
||||||
/// Ziehen zum Aktualisieren, keine halbhohen Sheets — und dafür ein Fokus, den
|
/// Ziehen zum Aktualisieren, keine halbhohen Sheets — und dafür ein Fokus, den
|
||||||
@@ -15,7 +14,7 @@ import PlexKit
|
|||||||
/// wäre kein Zweig, sondern ein zweiter Rumpf in derselben Datei, und der
|
/// wäre kein Zweig, sondern ein zweiter Rumpf in derselben Datei, und der
|
||||||
/// nächste, der die iOS-Seite ändert, bräche die andere, ohne es zu merken.
|
/// nächste, der die iOS-Seite ändert, bräche die andere, ohne es zu merken.
|
||||||
///
|
///
|
||||||
/// **Die Modelle dagegen werden geteilt**, nicht kopiert: `Flixy/Session/`
|
/// **Die Modelle dagegen werden geteilt**, nicht kopiert: `Halo/Session/`
|
||||||
/// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft
|
/// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft
|
||||||
/// zwischen Modell und Ansicht, nicht zwischen den Plattformen.
|
/// zwischen Modell und Ansicht, nicht zwischen den Plattformen.
|
||||||
///
|
///
|
||||||
@@ -23,9 +22,9 @@ import PlexKit
|
|||||||
/// iOS-App trägt ausschliesslich Downloads, und die gibt es hier nicht — damit
|
/// iOS-App trägt ausschliesslich Downloads, und die gibt es hier nicht — damit
|
||||||
/// fällt beides weg.
|
/// fällt beides weg.
|
||||||
@main
|
@main
|
||||||
struct FlixyTVApp: App {
|
struct HaloTVApp: App {
|
||||||
@State private var session: FlixySession
|
@State private var session: HaloSession
|
||||||
@State private var theme = FlixyTheme()
|
@State private var theme = HaloTheme()
|
||||||
@State private var playback = PlaybackCoordinator()
|
@State private var playback = PlaybackCoordinator()
|
||||||
@State private var playbackSettings = PlaybackSettings()
|
@State private var playbackSettings = PlaybackSettings()
|
||||||
@State private var languages = LanguagePreferences()
|
@State private var languages = LanguagePreferences()
|
||||||
@@ -41,11 +40,7 @@ struct FlixyTVApp: App {
|
|||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
_session = State(initialValue: FlixySession(
|
_session = State(initialValue: HaloSession(
|
||||||
identity: PlexClientIdentity.current(
|
|
||||||
product: Branding.plexProduct,
|
|
||||||
version: Branding.version
|
|
||||||
),
|
|
||||||
jellyfinIdentity: JellyfinIdentity.current(
|
jellyfinIdentity: JellyfinIdentity.current(
|
||||||
client: Branding.appName,
|
client: Branding.appName,
|
||||||
version: Branding.version
|
version: Branding.version
|
||||||
@@ -67,7 +62,7 @@ struct FlixyTVApp: App {
|
|||||||
.environment(homeLayout)
|
.environment(homeLayout)
|
||||||
.environment(diagnostics)
|
.environment(diagnostics)
|
||||||
.environment(network)
|
.environment(network)
|
||||||
.flixyAccent(theme)
|
.haloAccent(theme)
|
||||||
.tint(theme.accentColor)
|
.tint(theme.accentColor)
|
||||||
// Ein Fernseher ist immer dunkel. Der Schalter in der
|
// Ein Fernseher ist immer dunkel. Der Schalter in der
|
||||||
// Info.plist gilt fürs System, diese Zeile für die Vorschauen.
|
// Info.plist gilt fürs System, diese Zeile für die Vorschauen.
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Flixy</string>
|
<string>Halo</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -22,10 +22,10 @@
|
|||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLName</key>
|
<key>CFBundleURLName</key>
|
||||||
<string>com.scarriffleservices.flixy.topshelf</string>
|
<string>com.scarriffleservices.halo.topshelf</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>flixy</string>
|
<string>halo</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen.</string>
|
<string>Halo benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen.</string>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<string>Dark</string>
|
<string>Dark</string>
|
||||||
</dict>
|
</dict>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user