Compare commits

..
10 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +02:00
ScarriffleandClaude Opus 5 2fcf6821c8 Plex ist raus — und nahm zwei stille Fehler mit
Vollständig entfernt: das Paket `PlexKit` samt 108 Tests, der
Web-Authenticator, die Anmeldung über plex.tv, die Serverauswahl und alles,
was daran hing. 43 Dateien gelöscht, 68 Stellen in 28 weiteren bereinigt.

**Zwei Dinge hätten beim blossen Streichen still das Falsche getan.**

`playbackUnavailableMessage` deckte im Else-Zweig zwei Lagen gleichzeitig ab
— „es ist Plex" und „niemand ist angemeldet". Wer die Serverart naiv
herausstreicht, bekommt als nicht angemeldeter Nutzer „Jellyfin-Wiedergabe
steht aus" statt „Server nicht erreichbar". Die Unterscheidung hängt jetzt
daran, ob überhaupt ein Server da ist.

Die Schlüsselbund-Einträge `plex.account` und `plex.server` wurden allein
über den Authenticator aus dem gelöschten Paket entfernt. Danach stünde
`plex.account` in keiner Zeile dieses Projekts mehr: wer sich nie abgemeldet
hat, trüge ein gültiges Token am fremden Konto bis in alle Ewigkeit mit sich,
ohne dass irgendein Weg es noch löschen könnte. `purgePlexLeftovers()` räumt
beide Token und fünf Voreinstellungen einmalig ab — und fasst nichts an, wo
nichts liegt, damit kein Jellyfin-Nutzer dafür seinen Mediathekenspeicher
verliert.

**Was Nutzer sehen.** Die Berechtigungsfrage fürs lokale Netz sagte wörtlich,
die App brauche es, „um deinen Plex-Server zu finden" — das stand so im
Systemdialog. Der Anmeldebildschirm hatte zwei Knöpfe, und der hervorgehobene
war der für Plex; der verbliebene erbt die Hervorhebung, denn ein Knopf, der
aussieht wie die zweite Wahl, lässt nach der ersten suchen. 14 verwaiste
Textbausteine sind weg, vier umformuliert.

Darunter einer, der schlicht nicht mehr stimmte: `signIn.privacy` versprach,
das Passwort erreiche diese App nie. Beim Jellyfin-Rückfall auf das Passwort
tut es das sehr wohl. Die Zusage, die weiterhin gilt, ist die über fremde
Dienste — und nur die steht jetzt da.

**Was ausdrücklich bleibt:** `MediaBackend`. Diese Abstraktion hat zwei
Server gleichzeitig getragen, und der Steckplatz, den Plex räumt, ist genau
der, auf den Halo kommt. `ServerKind` fällt dagegen weg — mit einem Fall ist
es ein Bool, das `backend != nil` schon beantwortet.

Nebenbei zog die README nach. Sie beschrieb noch einen „nativen Plex-Client"
mit zwei Playern und VLCKit, beides seit Längerem nicht mehr wahr.

368 Tests grün (von 476 minus die 108 aus PlexKitTests, kein anderer).
iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:10:20 +02:00
ScarriffleandClaude Opus 5 93695dc332 Beim Umpacken wird nach der Tonspur gefragt, nicht geraten
Mein Fehler, und ein vermeidbarer. Beim Umpacken legt der Server genau eine
Tonspur in die Datei, und welche, hat bisher allein die Abwägung entschieden
— bei einer Aufnahme mit Audiodeskription die falsche. Nach dem Herunterladen
gibt es nichts mehr zu korrigieren; drei Gigabyte Datenvolumen waren weg.

Wo eine Spur verlorengeht, wird jetzt gefragt. Nur dort: bei einer Datei, die
der Systemplayer ohnehin öffnet, kommt alles mit, und eine Frage ohne Folgen
ist nur ein Klick mehr.

**Der Vorschlag kommt aus dem, was schon bekannt ist.** Die gemerkte Sprache
der Serie zuerst, dann die Serieneinstellung, dann die allgemeine — dieselbe
Abwägung wie beim Abspielen (`AudioTrackChooser`), über denselben Weg. Sie
ist jetzt Vorschlag statt Entscheidung und steht als „· empfohlen" dabei.

Beim Stapel aus der Mehrfachauswahl wird einmal gefragt, nicht je Folge: die
Folgen einer Staffel sind gleich aufgebaut, und zwanzigmal dieselbe Frage
wäre keine Wahl, sondern eine Prüfung der Geduld.

**Was am Server gemessen wurde, und warum es nicht besser geht:**

- `static=true` liefert die Datei unverändert — alle Spuren, aber Matroska;
  die Magie am Dateianfang ist `1a45dfa3`, auch wenn im Pfad `.mp4` steht.
- `stream.mp4` bildet genau eine Tonspur ab.
- Die HLS-Liste des Servers führt **eine** Variante und **keine**
  `EXT-X-MEDIA`-Tonspuren.

Mehrere Tonspuren offline gibt dieser Server auf keinem seiner Wege her. Das
liegt nicht an MP4 — das kann mehrere — und nicht an AVFoundation, sondern am
Transcoder, der je Sitzung eine Spur abbildet.

iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 10:14:14 +02:00
ScarriffleandClaude Opus 5 24d80e5c95 Offline fragte die Spurauswahl den Server — den es dort nicht gibt
Deine Beobachtung, Schritt für Schritt nachgemessen: der Untertitel-Knopf war
da, das Sheet zeigte nur „Aus".

**Zwei Quellen für dieselbe Frage.** Der Knopf richtete sich nach den Spuren,
die der Player in der Datei findet (`subtitleTracks`); die Liste nach den
Spuren, die der Server gemeldet hat (`subtitleStreams`). Die kommen aus
`chooseAudioStream`, und das läuft nur auf dem Online-Weg. Offline war die
Liste deshalb leer — Knopf da, nichts dahinter.

Offline listet das Sheet jetzt die Spuren der Datei und schaltet sie
unmittelbar in der Engine um. Online bleibt es beim bisherigen Weg: dort
heisst Umstellen „neu bestellen", weil ein umgerechneter Stream genau eine
Spur mitbringt.

**Der Tonspur-Knopf verschwindet, wo es nichts zu wählen gibt.** Eine Spur
ist keine Wahl — ein Angebot, das nach dem Antippen nichts anbietet. Bei den
Untertiteln genügt dagegen eine: „an oder aus" ist auch eine Wahl.

**Belegt, dass die Spur wirklich ankommt.** Die heruntergeladene Datei direkt
mit AVFoundation gelesen:

    Spuren in der Datei: 3
      vide / soun / sbtl   (sbtl = tx3g, also mov_text)
    Gruppe AVMediaCharacteristicLegible: 2 Auswahlmöglichkeiten

Der Untertitel ist drin — er wurde nur nie angeboten.

**Was dabei auffällt und offen bleibt:** alle Spuren der umgepackten Datei
tragen die Sprache `und`. Jellyfin reicht die Sprachkennung beim Umpacken
nicht durch, also heissen sie „Unknown language". Das ist eine eigene Sache;
die App weiss, welche Sprache sie bestellt hat, und könnte sie beschriften.

iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 09:07:04 +02:00
ScarriffleandClaude Opus 5 25e6ada0ce Herunterladen steht jetzt im Kontextmenü
Bisher führte der Weg über die Detailseite — zwei Schritte für etwas, das man
beim Vorbeiscrollen entscheidet. Langes Drücken auf eine Kachel bietet es
jetzt direkt an, samt derselben Qualitätsauswahl.

Die Kachel kennt nur eine Zusammenfassung ohne Dateiangaben; geholt wird
deshalb erst beim Tippen. Eine Reihe aus zwanzig Kacheln wären sonst zwanzig
Anfragen für ein Menü, das selten aufgeht.

**Nur bei Filmen und Folgen.** Bei einer Serie oder Staffel lädt man nicht
*den* Titel, sondern seine Folgen — dafür gibt es auf der Seite „Alle Folgen
laden". Und nicht bei dem, was schon in der Warteschlange steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 08:46:02 +02:00
ScarriffleandClaude Opus 5 6b6bc6ad02 Der Server lieferte nichts — drei Angaben fehlten in der Adresse
Am echten Server gemessen, nachdem der erste Versuch mit „Fehler 500"
abgelehnt wurde. Drei Befunde, alle reproduziert:

**`deviceId` und `playSessionId` fehlten.** Ohne sie antwortet Jellyfin mit
`200` und `Content-Length: 0` — kein Fehler, keine Datei, nichts. Mit ihnen
kamen in fünfzehn Sekunden 66 MB.

Das betraf **auch die verkleinerten Fassungen**: `compressedDownloadURL`
hatte sie nie. „In 480p herunterladen" war damit die ganze Zeit folgenlos —
ein Fehler, der älter ist als alles von heute.

**Die Sitzungskennung muss je Auftrag neu sein.** Zuerst hatte ich sie an die
Titelkennung gehängt, damit ein Wiederholversuch dieselbe Sitzung bleibt.
Zwei Anfragen unter derselben Kennung hielt der Server aber für dieselbe
Sitzung und antwortete der zweiten mit einer leeren Datei. Jetzt eine UUID
beim Anlegen des Auftrags — eindeutig je Download, gleich über alle
Wiederholversuche, weil die Adresse gespeichert ist.

**`subtitleCodec=mov_text` muss dabeistehen.** Das war der Fehler 500. Mit
`subtitleMethod=Embed` allein lehnt der Server ab; derselbe Aufruf ohne
Untertitel liefert 61 MB, mit Untertitel und `mov_text` 42 MB samt Spur. MP4
kennt kein SubRip — wer den Untertitel hineinlegen will, muss sagen, als was.

Nachgewiesen im Simulator gegen den echten Server: „100,0 MB / 917,3 MB ·
6,1 MB/s · noch 2 Min · 11 %".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 08:46:02 +02:00
ScarriffleandClaude Opus 5 a432592271 Beim Umpacken fällt die Spurwahl an — also wird sie dort getroffen
Dein Befund an „Notruf Hafenkante": keine Spuren, nichts auswählbar. Die
technischen Angaben derselben Folge sagen, warum:

    Container    MKV
    Tonspur      AAC - Stereo - Standard
    Tonspur      Deutsch - German - AAC - Stereo - Standard
    Untertitel   German - Standard - SUBRIP

Die Quelle ist Matroska. AVFoundation öffnet das nicht, also lief der
Download bisher über die zweite Engine — und die meldete die Spurliste leer.
Offline wählen ging damit nie.

**Umpacken allein hätte es nicht gelöst.** Jellyfin bildet in die neue Hülle
genau **eine** Tonspur ab und übernimmt den Untertitel nur, wenn man ihn
nennt. Ohne Angabe nimmt der Server seine eigene Vorgabe — und das ist bei
deutschen Aufnahmen regelmäßig die Spur mit Audiodeskription.

Also fällt die Wahl jetzt beim Herunterladen, mit derselben Abwägung wie beim
Abspielen (`AudioTrackChooser`, der für genau diesen Fall gebaut wurde: die
als deutsch ausgezeichnete Spur ist die mit Audiodeskription). Der Untertitel
geht als `subtitleMethod=Embed` mit — als **Spur**, nicht ins Bild gebrannt,
sonst liesse er sich nicht abschalten.

Die Mediathek bleibt bei der Abwägung aussen vor: sie nachzuschlagen kostet
eine Anfrage je Folge, und Serie und allgemeine Einstellung tragen den Wunsch
ohnehin.

Der Offline-Abzug behauptet nicht mehr, alle Sprachen seien dabei — beim
Umpacken bleibt je eine übrig, und alle zu notieren wäre ein Versprechen, das
die Datei nicht hält.

**Nebenbei richtiggestellt:** die technischen Angaben rechneten mit
`.directPlayOnly` und zeigten deshalb „Direkt (Systemplayer)" direkt über
„iOS öffnet den Container MKV nicht" — zwei Angaben, die sich widersprechen.
Das war früher richtig (ohne Serverhilfe übernahm die zweite Engine und hiess
auch so); jetzt wird mit Serverhilfe gerechnet.

NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT.

iOS und tvOS bauen, 494 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 08:32:41 +02:00
ScarriffleandClaude Opus 5 6b927c21b0 VLC ist weg — es gibt nur noch einen Spieler
Du hattest recht mit der Erwartung, nur nicht mit dem Stand: entfernt war er
nicht. Was wir gemacht haben, war, ihm Arbeit wegzunehmen — DTS, TrueHD und
Vorbis gehen seit Kurzem über das Umpacken beim Server. Übrig blieb er an
einer Stelle, `PlaybackDecider.fallback`.

Jetzt ist er ganz weg, samt dem 920-MB-Binärpaket.

**Was an seine Stelle tritt.** Die Entscheidung sagt nicht mehr, *wer* spielt,
sondern *was ankommt*: direkt, umgepackt oder umgerechnet. `PlaybackEngineKind`
fällt damit weg, `directPlay` trägt keine Engine mehr, und `PlaybackPolicy`
kennt kein `allowsFallbackEngine`.

**Das Auffangnetz bleibt, nur anders geknüpft.** Scheiterte das Umpacken am
Player, sprang bisher VLC ein — der Server entscheidet erst beim Ausliefern,
ob er die Ströme wirklich kopieren kann, und manche Antworten nimmt AVPlayer
nicht an. Statt der zweiten Engine rechnet der Server jetzt neu
(`fallBackToTranscode`). Teurer für ihn, aber es kommt etwas an, statt einer
schwarzen Wand mit einer Fehlernummer.

**Offline wäre das ein Rückschritt gewesen** — dort gibt es keinen Server, der
einspringt. Deshalb ging der vorige Commit voraus: was heruntergeladen wird,
kommt schon in einer Hülle, die der Systemplayer öffnet.

Was dadurch überall gilt, auch für Dateien, die bisher bei VLC landeten:
Bild-in-Bild, AirPlay, Bedienung im Sperrbildschirm — und dasselbe Verhalten
auf dem Apple TV, wo es VLC ohnehin nie gab.

Die Attrappe heißt ihren Schalter jetzt `--dts` statt `--vlc`: der Fall ist
geblieben, sein Ziel nicht.

iOS und tvOS bauen, 494 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 08:04:23 +02:00
ScarriffleandClaude Opus 5 ef2596e2bb Was heruntergeladen wird, muss der Systemplayer öffnen können
**Vorbereitung dafür, dass VLC verschwindet.** Online ist der Fall längst
gelöst: steht nur die Schachtel im Weg, packt der Server um — dieselben
Ströme, eine MP4-Hülle. Offline gibt es diesen Ausweg nicht. Was einmal als
Matroska auf dem Gerät liegt, kann hinterher niemand mehr umpacken, und ohne
zweite Engine bliebe die Datei schwarz.

Also entscheidet sich das beim Herunterladen. Fragt jemand das Original an und
AVFoundation könnte die Datei nicht öffnen, wird `remuxDownloadURL` benutzt:
`/Videos/{id}/stream.mp4` mit `allowVideoStreamCopy` und
`allowAudioStreamCopy`. Der Server reicht durch, was passt, und rechnet nur
um, was nicht passt — das Bild bleibt das Original, nur DTS oder TrueHD
werden zu AC3 oder AAC.

Drei Dinge hängen daran und sind mitgezogen:

- Die **Größe** ist dann keine Zusage mehr: dieselben Ströme in einer anderen
  Hülle wiegen anders. `expectedIsExact` fällt auf falsch, und es gilt wieder
  die weiche Prüfung.
- Der **Container** im Offline-Abzug ist „mp4", nicht die alte Schachtel.
  Sonst entschiede der Player anhand einer Datei, die es so nicht mehr gibt.
- Der **Tonkodierer** bleibt leer. Ob aus DTS dann AAC oder AC3 wird, sagt der
  Server vorher nicht; eine Behauptung wäre schlechter als die Lücke, und die
  Spuren liest der Player ohnehin aus der Datei.

**Die Frage „kann das der Systemplayer?" steht jetzt an einer Stelle.**
`DeviceCapabilities.opens(container:videoCodec:audioCodec:)` — dieselbe Liste,
nach der auch beim Abspielen entschieden wird. Was der Server nicht nennt,
gilt als passend: vorsichtshalber umzupacken hiesse, aus einer tadellosen MP4
eine neu gerechnete Datei zu machen. Fünf Tests.

NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT: ob Jellyfin das Kopieren hier
tatsächlich wählt, verrät erst die Dateigröße des ersten Downloads.

140 Tests in FlixyPlayback, iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 07:54:46 +02:00
ScarriffleandClaude Opus 5 b1973c6890 Bild-in-Bild gab es nur beim Weggehen — jetzt auch auf Zuruf
Du hast keinen Knopf gefunden, weil es keinen gab: `AVPictureInPictureController`
stand da, aber nur mit `canStartPictureInPictureAutomaticallyFromInline`. Das
greift, wenn man die App verlässt — und auf dem Mac gar nicht.

Der Knopf erscheint nur, wenn es wirklich ginge. Das ist nicht immer so:
`attachSurface` steigt für alles ausser `AVPlayerEngine` sofort aus, und
`AVPictureInPictureController` braucht eine `AVPlayerLayer`. Läuft ein Titel
über VLC, gibt es kein Bild-in-Bild — nur Ton im Hintergrund. Genau das war
deine Beobachtung.

**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 über KVO — vom
Input-Thread, deshalb der Sprung auf den Hauptakteur, wie bei den übrigen
Beobachtungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 07:54:46 +02:00
330 changed files with 2939 additions and 12037 deletions
+4 -3
View File
@@ -9,7 +9,8 @@ xcuserdata/
# Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine
# 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
# nötig ist, steht in Flixy.xcworkspace/README.md.
Flixy.xcodeproj/
# nötig ist, steht in Halo.xcworkspace/README.md.
Halo.xcodeproj/
node_modules/
+2 -2
View File
@@ -1,8 +1,8 @@
import ActivityKit
import SwiftUI
import WidgetKit
import FlixyDesign
import FlixyDownloads
import HaloDesign
import HaloDownloads
/// Der Download-Fortschritt im Sperrbildschirm und in der Dynamic Island.
struct DownloadLiveActivity: Widget {
+1 -1
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Flixy Downloads</string>
<string>Halo Downloads</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<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
}
-727
View File
@@ -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 }
}
-152
View File
@@ -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)
}
}
-130
View File
@@ -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
`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,
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,
> or close it?
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace version = "1.0">
<FileRef location = "group:Flixy.xcodeproj"></FileRef>
<FileRef location = "group:Halo.xcodeproj"></FileRef>
</Workspace>

Before

Width:  |  Height:  |  Size: 675 B

After

Width:  |  Height:  |  Size: 675 B

+4 -25
View File
@@ -9,42 +9,21 @@ import Foundation
///
/// Der Name steht noch nicht fest. Damit ein späterer Wechsel nicht zur Suche
/// quer durch den Quelltext wird, greift die App nirgends auf ein hart
/// geschriebenes „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,
/// 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 {
/// Wie die App sich selbst nennt.
static let appName = "Flixy"
/// 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
static let appName = "Halo"
/// Unter welchem Namen die Tokens im Schlüsselbund liegen.
///
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
/// Umbenennung der App soll niemanden abmelden.
static var keychainService: String {
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flixy"
}
/// 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")!
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.halo"
}
/// Wann dieser Build entstanden ist.
+10 -16
View File
@@ -3,15 +3,14 @@ import SwiftUI
#if os(iOS)
import UIKit
#endif
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import HaloDesign
import HaloDownloads
import HaloPlayback
import JellyfinKit
import MediaKit
import PlexKit
@main
struct FlixyApp: App {
struct HaloApp: App {
#if os(iOS)
/// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von
/// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen
@@ -24,8 +23,8 @@ struct FlixyApp: App {
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
/// Downloads zurücksetzen.
@State private var theme = FlixyTheme()
@State private var session: FlixySession
@State private var theme = HaloTheme()
@State private var session: HaloSession
@State private var playback = PlaybackCoordinator()
@State private var playbackSettings = PlaybackSettings()
@State private var languages = LanguagePreferences()
@@ -41,12 +40,7 @@ struct FlixyApp: App {
private let modelContainer: ModelContainer
init() {
let identity = PlexClientIdentity.current(
product: Branding.plexProduct,
version: Branding.version
)
_session = State(initialValue: FlixySession(
identity: identity,
_session = State(initialValue: HaloSession(
jellyfinIdentity: JellyfinIdentity.current(
client: Branding.appName,
version: Branding.version
@@ -80,8 +74,8 @@ struct FlixyApp: App {
// Notizzettelgröße, und darin fällt der Aufmacher in sich
// zusammen, weil seine Abstände dehnbar sind.
.frame(
minWidth: FlixyPlatform.isMac ? 900 : nil,
minHeight: FlixyPlatform.isMac ? 620 : nil
minWidth: HaloPlatform.isMac ? 900 : nil,
minHeight: HaloPlatform.isMac ? 620 : nil
)
// `defaultSize` gibt es hier nicht; die Größe hängt an der
// Szene.
@@ -139,7 +133,7 @@ struct FlixyApp: App {
}
#if os(iOS)
extension FlixyApp {
extension HaloApp {
/// Gibt dem Fenster eine vernünftige Mindestgröße.
///
/// Auf dem Mac öffnet das System sonst ein Fenster von der Größe eines
+2 -13
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Flixy</string>
<string>Halo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -18,17 +18,6 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<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>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
@@ -39,7 +28,7 @@
<true/>
</dict>
<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>
<true/>
<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": {
"extractionState": "stale",
"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": {
"localizations": {
"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": {
"localizations": {
"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": {
"extractionState": "stale",
"localizations": {
@@ -1855,13 +1842,13 @@
"de": {
"stringUnit": {
"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": {
"stringUnit": {
"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": {
"stringUnit": {
"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": {
"stringUnit": {
"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": {
"extractionState": "stale",
"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": {
"extractionState": "manual",
"localizations": {
@@ -4346,13 +4333,13 @@
"de": {
"stringUnit": {
"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": {
"stringUnit": {
"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": {
"extractionState": "stale",
"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": {
"extractionState": "stale",
"localizations": {
@@ -5727,13 +5647,13 @@
"de": {
"stringUnit": {
"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": {
"stringUnit": {
"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": {
"extractionState": "stale",
"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": {
"localizations": {
"de": {
"stringUnit": {
"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": {
"stringUnit": {
"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": {
"localizations": {
"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": {
"extractionState": "stale",
"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": {
"localizations": {
"de": {
@@ -1,5 +1,5 @@
import Foundation
import FlixyDownloads
import HaloDownloads
import MediaKit
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
@@ -1,5 +1,6 @@
import Foundation
import FlixyDownloads
import HaloDownloads
import HaloPlayback
import MediaKit
/// Übersetzt Titel vom Server in Download-Aufträge.
@@ -81,6 +82,16 @@ enum DownloadQuality: String, CaseIterable, Identifiable {
struct DownloadService {
let backend: (any MediaBackend)?
let coordinator: DownloadCoordinator
/// Welche Sprache gewünscht ist — nur fürs Umpacken.
///
/// **Weil beim Umpacken eine Tonspur übrig bleibt.** Online wählt man im
/// Player; offline gibt es nach dem Herunterladen nichts mehr zu wählen,
/// also fällt die Entscheidung hier. Dieselbe Abwägung wie beim Abspielen
/// (`AudioTrackChooser`) — eine zweite Regel liefe irgendwann auseinander.
///
/// Optional, damit Aufrufer ohne Spracheinstellungen weiter bauen: dann
/// gilt, was der Server als Standard meldet.
var languages: LanguagePreferences?
/// Baut den Auftrag für einen einzelnen Titel.
///
@@ -91,7 +102,8 @@ struct DownloadService {
func plan(
for item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
quality: DownloadQuality = .original,
audioStream: MediaStream? = nil
) throws(DownloadRefusal) -> DownloadPlan {
guard let backend else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
@@ -105,8 +117,32 @@ struct DownloadService {
guard let part = media.files.first else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
}
// **Was offline liegt, muss der Systemplayer öffnen können.** Online
// springt für eine Matroska das Umpacken ein; auf dem Gerät gibt es
// hinterher niemanden mehr, der das nachholen könnte — und seit es
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
// schwarz. Also wird schon beim Herunterladen umgepackt.
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
let url: URL?
switch quality {
case .original where umpacken:
// **Gewählt schlägt geraten.** Hier stand nur die Abwägung aus
// `AudioTrackChooser` — und die hat bei einer Aufnahme mit
// Audiodeskription die falsche Spur genommen, ohne zu fragen.
// Nach dem Download gibt es nichts mehr zu korrigieren; drei
// Gigabyte sind dann weg. Jetzt entscheidet, wer gefragt wurde,
// und die Abwägung ist nur noch der Vorschlag.
let ton = audioStream ?? preferredAudioStream(in: part, of: item)
let untertitel = part.subtitleStreams.first { $0.isSelected }
?? part.subtitleStreams.first
url = backend.remuxDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
audioStreamIndex: ton?.index,
subtitleStreamIndex: untertitel?.index
) ?? backend.downloadURL(for: part)
case .original:
url = backend.downloadURL(for: part)
case .fullHD, .hd, .low:
@@ -143,8 +179,11 @@ struct DownloadService {
// den Metadaten des Servers. Die umgerechneten Stufen sind aus
// Bitrate und Dauer hochgerechnet — und was hochgerechnet ist,
// darf abweichen, ohne dass die Datei kaputt wäre.
expectedIsExact: quality == .original && part.size != nil,
offlineMedia: offlineMedia(for: item, media: media, part: part)
// Umgepackt stimmt die Größe des Servers nicht mehr: dieselben
// Ströme in einer anderen Hülle wiegen anders. Dann ist sie wieder
// eine Schätzung — und die weiche Prüfung die richtige.
expectedIsExact: quality == .original && !umpacken && part.size != nil,
offlineMedia: offlineMedia(for: item, media: media, part: part, remuxed: umpacken)
)
}
@@ -153,9 +192,30 @@ struct DownloadService {
func download(
_ item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
quality: DownloadQuality = .original,
audioStream: MediaStream? = nil
) 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.
@@ -186,7 +246,8 @@ struct DownloadService {
/// stillschweigend.
func download(
_ episodes: [MediaItem],
quality: DownloadQuality = .original
quality: DownloadQuality = .original,
audioStreamIndex: Int? = nil
) async -> BatchResult {
guard let backend else { return (0, [], 0) }
@@ -205,7 +266,16 @@ struct DownloadService {
let detailed = (try? await backend.item(id: episode.id)) ?? episode
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
} catch DownloadRefusal.noDownloadableFile {
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
@@ -239,15 +309,58 @@ struct DownloadService {
}
/// Der Abzug der Serverangaben, den der Player offline braucht.
private func offlineMedia(for item: MediaItem, media: MediaVersion, part: MediaFile) -> OfflineMedia {
OfflineMedia(
/// Welche Tonspur vorgeschlagen wird.
///
/// **Die gemerkte der Serie zuerst.** Wer eine Folge schon einmal auf
/// Deutsch gesehen hat, will sie nicht auf Englisch herunterladen — und
/// die App merkt sich diese Entscheidung ohnehin schon fürs Abspielen
/// (`LanguagePreferences.rememberedTrack`). Dieselbe Abwägung also, über
/// denselben Weg: `wish` trägt das Gemerkte, die Serieneinstellung und
/// die allgemeine Sprache in dieser Reihenfolge.
///
/// Die Mediathek bleibt aussen vor: sie nachzuschlagen kostet eine
/// Anfrage je Folge, und die Ebenen darüber tragen den Wunsch ohnehin.
func preferredAudioStream(in part: MediaFile, of item: MediaItem) -> MediaStream? {
guard let languages else { return part.selectedAudioStream }
return AudioTrackChooser.choose(
from: part.streams,
wish: languages.wish(seriesID: item.seriesID, libraryID: nil)
) ?? part.selectedAudioStream
}
/// Ob AVFoundation die Datei so, wie sie auf dem Server liegt, öffnet.
private func systemPlayerOpens(media: MediaVersion, part: MediaFile) -> Bool {
DeviceCapabilities.avFoundation.opens(
container: part.container ?? media.container,
videoCodec: media.videoCodec,
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec,
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec
)
}
private func offlineMedia(
for item: MediaItem,
media: MediaVersion,
part: MediaFile,
remuxed: Bool
) -> OfflineMedia {
OfflineMedia(
// Umgepackt liegt eine MP4 auf dem Gerät, keine Matroska. Stünde
// hier weiter die alte Schachtel, entschiede der Player anhand
// einer Datei, die es so nicht mehr gibt.
container: remuxed ? "mp4" : (part.container ?? media.container),
videoCodec: media.videoCodec,
// **Beim Umpacken wissen wir es nicht.** Der Server rechnet nur
// um, was nicht durchpasst — ob aus DTS dann AAC oder AC3 wird,
// sagt er vorher nicht. Eine Behauptung wäre schlechter als die
// Lücke: der Player liest die Spuren ohnehin aus der Datei.
audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec),
videoResolution: media.videoResolution,
bitrate: media.bitrate,
audioLanguages: part.audioStreams.compactMap(\.language),
subtitleLanguages: part.subtitleStreams.compactMap(\.language),
// **Beim Umpacken bleibt je eine übrig.** Alle zu notieren wäre
// ein Versprechen, das die Datei nicht hält — und offline ist es
// nicht nachprüfbar, bevor der Player sie öffnet.
audioLanguages: remuxed ? [] : part.audioStreams.compactMap(\.language),
subtitleLanguages: remuxed ? [] : part.subtitleStreams.compactMap(\.language),
markers: item.markers.map {
OfflineMarker(
type: $0.kind.rawValue,
+403
View File
@@ -0,0 +1,403 @@
import Foundation
import JellyfinKit
import MediaKit
import Observation
/// Der Zustand der Verbindung zum Server.
///
/// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß,
/// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort,
/// an dem sich entscheidet, was auf dem Bildschirm erscheint.
///
/// Seit es zwei Serverarten gibt, hält diese Klasse **einen** aktiven Server,
/// nicht zwei. Zwei gleichzeitig hießen: zwei Startseiten, zwei Mediatheken,
/// zwei Suchergebnisse — und bei jedem Titel die Frage, woher er stammt.
/// Umschalten ja, nebeneinander nein.
@MainActor
@Observable
final class HaloSession {
enum Phase: Equatable {
/// Beim Start: liegt ein Token im Schlüsselbund?
case launching
case signedOut
/// Ein Weg zum Server wird gesucht.
case connecting(serverName: String)
case ready
case failed(message: String)
// Kein eigener ==-Operator mehr: er war nur nötig, weil `PlexResource`
// nicht `Equatable` war und über die Kennung verglichen werden musste.
// Alle verbliebenen Nutzlasten sind `String`, also leitet der Compiler
// die Gleichheit selbst ab — und ein künftiger Fall wird nicht
// stillschweigend als „ungleich" behandelt.
}
private enum Key {
static let jellyfinAddress = "halo.jellyfin.address"
static let jellyfinUserID = "halo.jellyfin.userID"
static let jellyfinUserName = "halo.jellyfin.userName"
static let jellyfinServerName = "halo.jellyfin.serverName"
static let jellyfinIsAdministrator = "halo.jellyfin.isAdministrator"
}
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
///
/// Nicht in den Voreinstellungen: es gibt vollen Zugriff auf die
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
/// später daneben passt, ohne diesen zu verdrängen.
private static let jellyfinTokenAccount = "jellyfin.server"
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
///
/// **Diese Namen stehen sonst nirgends mehr.** Mit dem Plex-Code ging auch
/// das Einzige, was die beiden Token je wieder entfernt hätte — wer sich
/// nie abgemeldet hat, trüge ein gültiges plex.tv-Token bis in alle
/// Ewigkeit im Schlüsselbund, ohne dass irgendein Weg es noch löschen
/// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
/// verschwinden.
private enum Altlast {
static let merker = "halo.migration.plexPurged"
static let tokenKonten = ["plex.account", "plex.server"]
static let schlüssel = [
"halo.backend.kind",
"halo.server.clientIdentifier",
"halo.server.connectionURI",
"halo.server.name",
"halo.server.connectionIsLocal",
]
}
/// Ob der Server in **demselben** Netz steht wie wir.
///
/// Entscheidet, ob „Auto" gedeckelt anfängt: zuhause trägt die Leitung
/// das Original, unterwegs nicht. Der Server wird danach gefragt, weil
/// ein Blick auf die Adresse nicht genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
/// auch von der Couch aus unter einem öffentlichen Namen.
///
/// Im Zweifel `false`: lieber einmal zu vorsichtig gedeckelt als eine
/// Wiedergabe, die über eine Fernverbindung gar nicht erst anläuft.
private(set) var isLocalConnection = false
private(set) var phase: Phase = .launching
/// Der aktive Server hinter dem neutralen Vertrag.
///
/// Die Ansichten sprechen ausschließlich hierüber und sehen nicht, wer
/// dahintersteckt. Dass dieser Vertrag einmal zwei Server gleichzeitig
/// getragen hat, ist kein Zufall, sondern der Grund, warum für einen
/// weiteren keine einzige Ansicht neu geschrieben werden muss.
private(set) var backend: (any MediaBackend)?
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
var accountName: String? { jellyfinAccount?.userName }
/// Die Mediatheken, die Halo darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
private(set) var libraries: [MediaLibrary] = []
/// Wie der Server heißt, so wie er sich selbst nennt.
var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird.
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
/// Wie die Verbindung zum Server zu beschreiben ist.
///
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
/// die Sitzung. Nah oder fern ist dabei die Angabe, die etwas erklärt —
/// an ihr hängt, ob „Auto" gedeckelt anfängt, und damit die Hälfte aller
/// Fragen nach Rucklern. Die blosse Adresse steht schon eine Zeile höher.
var connectionDescription: String? {
guard let serverAddress else { return nil }
return isLocalConnection
? "Lokal · \(serverAddress)"
: "Von außen · \(serverAddress)"
}
/// Ob der aktive Server Live-TV anbietet.
///
/// Die Fähigkeit selbst, nicht der Umweg über die Serverart. Einen
/// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine
/// Zusage, die niemand einlöst — so gibt sie nur, wer sie auch einlöst.
var supportsLiveTV: Bool { backend?.capabilities.contains(.liveTV) == true }
/// Ob er darüber hinaus aufnehmen kann.
///
/// Getrennt gefragt: ein Server kann empfangen, ohne aufnehmen zu können.
var supportsRecordings: Bool { backend?.capabilities.contains(.liveTVRecordings) == true }
/// Der Server als Live-TV-Server — sofern er einer ist.
var liveTV: (any LiveTVBackend)? { backend as? any LiveTVBackend }
/// Der Server als Aufnahme-Server — sofern er einer ist.
var recorder: (any LiveTVRecordingBackend)? { backend as? any LiveTVRecordingBackend }
/// Warum gerade nicht abgespielt werden kann.
///
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
///
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
var playbackUnavailableMessage: String {
backend != nil
? String(localized: "player.jellyfinPending")
: String(localized: "error.serverUnreachable")
}
private let jellyfinIdentity: JellyfinIdentity
private let tokenStore: any TokenStoring
private let defaults: UserDefaults
init(
jellyfinIdentity: JellyfinIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.jellyfinIdentity = jellyfinIdentity
self.tokenStore = tokenStore
self.defaults = defaults
}
// MARK: - Start
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
func restore() async {
purgePlexLeftovers()
#if DEBUG
if await adoptStubServerIfRequested() { return }
#endif
if await restoreJellyfin() { return }
phase = .signedOut
}
/// Räumt einmal weg, was die Plex-Zeit hinterlassen hat.
///
/// **Der einzige Weg, auf dem das je wieder passiert.** Vorher entfernte
/// `signOut()` die Plex-Token über den Authenticator; den gibt es nicht
/// mehr, und `plex.account` stünde sonst in keiner Zeile dieses Projekts.
/// Wer bis heute angemeldet war und nie auf „Abmelden" gedrückt hat,
/// behielte ein gültiges Token am fremden Konto — unsichtbar und
/// unlöschbar.
///
/// Der Merker wird **vor** der Prüfung gesetzt: ein Aufräumen, das
/// scheitert, soll nicht bei jedem Start wiederkehren.
private func purgePlexLeftovers() {
guard !defaults.bool(forKey: Altlast.merker) else { return }
defaults.set(true, forKey: Altlast.merker)
// Nur anfassen, wo auch etwas liegt. Sonst verlöre jeder
// Jellyfin-Nutzer beim ersten Start seinen Mediatheken-Speicher für
// nichts.
let hattePlex = Altlast.tokenKonten.contains { tokenStore.token(for: $0) != nil }
|| Altlast.schlüssel.contains { defaults.object(forKey: $0) != nil }
guard hattePlex else { return }
for konto in Altlast.tokenKonten { tokenStore.remove(for: konto) }
for schlüssel in Altlast.schlüssel { defaults.removeObject(forKey: schlüssel) }
// Titel und Sehfortschritt gehörten zu einem Konto, das es hier nicht
// mehr gibt. Gelesen würden sie ohnehin nie wieder.
Task { await LibraryCache.shared.clearAll() }
}
/// Meldet ab und räumt dabei alles ab, was zur Anmeldung gehörte.
///
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
func signOut() {
forgetJellyfin()
clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
// verloren.
Task { await LibraryCache.shared.clearAll() }
phase = .signedOut
}
// MARK: - Verbinden mit Jellyfin
/// Übernimmt eine fertige Jellyfin-Anmeldung.
///
/// Die Anmeldung selbst führt `JellyfinSignInModel` — sie besteht aus
/// mehreren Schritten mit eigener Bedienung (Adresse, Code, Rückfall auf
/// Passwort) und gehört deshalb in ein eigenes Modell. Hier kommt nur das
/// Ergebnis an: ein Konto und ein Weg dorthin.
#if DEBUG
/// Meldet sich an der Attrappe aus `Tools/JellyfinStub` an.
///
/// Nur in der Entwicklung und nur auf ausdrückliches Kommando:
/// `-HaloStubServer http://127.0.0.1:8096` als Startargument. Der Weg
/// führt bewusst **am Schlüsselbund vorbei** und schreibt nichts fort —
/// so lässt sich die Oberfläche ansehen, prüfen und fotografieren, ohne
/// dass je ein echter Server, ein echtes Konto oder ein echter Token im
/// Spiel wäre. Genau daran hat es bisher gefehlt: Aussagen über das
/// Aussehen waren Vermutungen, weil niemand hinsehen konnte.
private func adoptStubServerIfRequested() async -> Bool {
guard let raw = defaults.string(forKey: "HaloStubServer"),
let baseURL = URL(string: raw)
else { return false }
let account = JellyfinAccount(
userID: "user-1",
userName: "Attrappe",
token: "stub-token",
isAdministrator: true
)
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: account.token)
let backend = JellyfinBackend(client: client, account: account, serverName: "Attrappe")
jellyfinAccount = account
self.backend = backend
do {
libraries = try await loadLibraries(from: backend)
phase = .ready
} catch {
phase = .failed(message: ErrorText.describe(error))
}
return true
}
#endif
func adoptJellyfin(
client: JellyfinClient,
account: JellyfinAccount,
serverName: String
) async {
phase = .connecting(serverName: serverName)
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
self.backend = backend
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
} catch {
self.backend = nil
jellyfinAccount = nil
phase = .failed(message: ErrorText.describe(error))
return
}
rememberJellyfin(account: account, serverName: serverName, baseURL: client.baseURL)
phase = .ready
}
/// Nimmt eine gespeicherte Jellyfin-Anmeldung wieder auf.
///
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Token nicht
/// mehr gilt — dann steht die Anmeldung wieder am Anfang. Ist der Server
/// bloß gerade nicht erreichbar, bleibt die Anmeldung dagegen bestehen und
/// es gibt einen Fehler mit „Erneut versuchen": auf dem Weg zur Arbeit ist
/// jeder Server unerreichbar, und das ist kein Grund, jemanden abzumelden.
private func restoreJellyfin() async -> Bool {
guard let address = defaults.string(forKey: Key.jellyfinAddress),
let baseURL = URL(string: address),
let userID = defaults.string(forKey: Key.jellyfinUserID),
let token = tokenStore.token(for: Self.jellyfinTokenAccount)
else { return false }
let serverName = defaults.string(forKey: Key.jellyfinServerName) ?? ""
phase = .connecting(serverName: serverName)
let account = JellyfinAccount(
userID: userID,
userName: defaults.string(forKey: Key.jellyfinUserName) ?? "",
token: token,
isAdministrator: defaults.bool(forKey: Key.jellyfinIsAdministrator)
)
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: token)
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
phase = .ready
return true
} catch MediaBackendError.unauthorized {
// Der Token wurde am Server zurückgezogen. Ihn zu behalten würde
// bei jedem Start denselben Fehler erzeugen.
forgetJellyfin()
clearActiveServer()
return false
} catch {
phase = .failed(message: ErrorText.describe(error))
return true
}
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName)
defaults.set(serverName, forKey: Key.jellyfinServerName)
defaults.set(account.isAdministrator, forKey: Key.jellyfinIsAdministrator)
try? tokenStore.store(account.token, for: Self.jellyfinTokenAccount)
}
private func forgetJellyfin() {
for key in [Key.jellyfinAddress, Key.jellyfinUserID, Key.jellyfinUserName,
Key.jellyfinServerName, Key.jellyfinIsAdministrator] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.jellyfinTokenAccount)
}
// MARK: - Der aktive Server
/// Holt die Mediathekenliste und merkt sie sich.
///
/// **Der Speicher ist hier nur ein Rettungsanker**, kein Beschleuniger:
/// die Liste ist eine einzige kleine Anfrage, und sie sofort zu zeigen
/// hiesse, eine Sitzung für fertig zu erklären, bevor feststeht, dass der
/// Token noch gilt. Antwortet der Server dagegen **nicht**, ist eine
/// Seitenleiste von gestern besser als eine leere — im Zug ist das der
/// Unterschied zwischen „das Heruntergeladene ansehen" und „gar nichts".
private func loadLibraries(from backend: any MediaBackend) async throws -> [MediaLibrary] {
let kennung = backend.identity
do {
let geladen = try await backend.libraries().filter(\.isSupported)
await LibraryCache.shared.storeLibraries(geladen, server: kennung)
return geladen
} catch {
guard let gemerkt = await LibraryCache.shared.libraries(server: kennung),
!gemerkt.isEmpty
else { throw error }
return gemerkt
}
}
private func clearActiveServer() {
backend = nil
jellyfinAccount = nil
libraries = []
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
if await restoreJellyfin() { return }
phase = .signedOut
}
/// Die Kennung für Jellyfin-Anmeldungen.
///
/// Der Anmeldebildschirm baut sich seinen eigenen Client, bevor es eine
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
}
@@ -1,5 +1,5 @@
import Foundation
import FlixyDesign
import HaloDesign
import MediaKit
/// Woher der Aufmacher seine Bilder nimmt.
@@ -64,8 +64,8 @@ enum HeroArtwork {
}
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
if isPortrait(placement, compact: compact) { return FlixyAspectRatio.poster }
return compact ? FlixyAspectRatio.heroCompact : FlixyAspectRatio.hero
if isPortrait(placement, compact: compact) { return HaloAspectRatio.poster }
return compact ? HaloAspectRatio.heroCompact : HaloAspectRatio.hero
}
static func backdropURL(
@@ -1,7 +1,6 @@
import Foundation
import Observation
import MediaKit
import PlexKit
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
///
@@ -13,15 +12,15 @@ import PlexKit
@Observable
final class HomeLayoutSettings {
private enum Key {
static let hidden = "flixy.home.hiddenHubs"
static let order = "flixy.home.hubOrder"
static let heroCount = "flixy.home.heroCount"
static let heroSource = "flixy.home.heroSource"
static let heroLibraries = "flixy.home.heroLibraries"
static let combinesContinueRows = "flixy.home.combinesContinueRows"
static let hidesWatchedInLatest = "flixy.home.hidesWatchedInLatest"
static let cardScale = "flixy.home.cardScale"
static let heroHeight = "flixy.home.heroHeight"
static let hidden = "halo.home.hiddenHubs"
static let order = "halo.home.hubOrder"
static let heroCount = "halo.home.heroCount"
static let heroSource = "halo.home.heroSource"
static let heroLibraries = "halo.home.heroLibraries"
static let combinesContinueRows = "halo.home.combinesContinueRows"
static let hidesWatchedInLatest = "halo.home.hidesWatchedInLatest"
static let cardScale = "halo.home.cardScale"
static let heroHeight = "halo.home.heroHeight"
}
/// Woraus sich der Aufmacher speist.
@@ -5,14 +5,14 @@ import Observation
/// 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
/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort
/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am
/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin.
///
/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits
/// angemeldeten Client bestätigt; das Passwort erreicht 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.
/// 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
@@ -1,7 +1,7 @@
import Foundation
import Observation
import MediaKit
import FlixyPlayback
import HaloPlayback
/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand
/// gesagt hat.
@@ -28,13 +28,13 @@ final class LanguagePreferences {
private enum Key {
/// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der
/// Besitzer gewechselt, nicht der Wert.
static let audio = "flixy.playback.audioLanguage"
static let subtitles = "flixy.playback.subtitleLanguage"
static let fallbacks = "flixy.languages.fallbacks"
static let avoidsAudioDescription = "flixy.languages.avoidsAudioDescription"
static let perLibrary = "flixy.languages.library"
static let perSeries = "flixy.languages.series"
static let rememberedTracks = "flixy.languages.seriesTrack"
static let audio = "halo.playback.audioLanguage"
static let subtitles = "halo.playback.subtitleLanguage"
static let fallbacks = "halo.languages.fallbacks"
static let avoidsAudioDescription = "halo.languages.avoidsAudioDescription"
static let perLibrary = "halo.languages.library"
static let perSeries = "halo.languages.series"
static let rememberedTracks = "halo.languages.seriesTrack"
}
/// Die Sprachen, die zur Auswahl stehen.
@@ -10,7 +10,7 @@ import MediaKit
/// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild.
///
/// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den
/// übrigen Modellen und nicht in FlixyDesign, das `MediaItem` gar nicht kennt.
/// übrigen Modellen und nicht in HaloDesign, das `MediaItem` gar nicht kennt.
extension MediaItem {
/// Der Titel auf einer Kachel in einer Reihe.
///
@@ -13,8 +13,8 @@ import MediaKit
@Observable
final class NavigationLayoutSettings {
private enum Key {
static let hidden = "flixy.navigation.hidden"
static let order = "flixy.navigation.order"
static let hidden = "halo.navigation.hidden"
static let order = "halo.navigation.order"
}
private let defaults: UserDefaults
@@ -133,7 +133,7 @@ final class NowPlaying {
///
/// Die Handlung läuft über einen Sprung auf den Hauptakteur statt über
/// `assumeIsolated`: dass diese Rückrufe dort ankommen, ist nirgends
/// zugesagt, und dieselbe Annahme hat die App im VLC-Engine schon einmal
/// zugesagt, und dieselbe Annahme hat die App schon einmal
/// zum Absturz gebracht. Der Umweg kostet einen Durchlauf — unbemerkbar
/// neben dem Tastendruck, der ihn ausgelöst hat.
private func add(
@@ -1,13 +1,13 @@
import Foundation
import Observation
import FlixyPlayback
import HaloPlayback
/// Hält fest, was beim letzten Abspielversuch passiert ist.
///
/// Der Player verschwindet, wenn man ihn schließt — und mit ihm jeder Hinweis
/// darauf, woran es lag. Dieser Mitschnitt überlebt das und steht in den
/// Einstellungen. Aus „es läuft nicht" wird damit „VLC, lokale Verbindung,
/// hängt bei Öffnen" — und das ist ein Unterschied, der Stunden spart.
/// Einstellungen. Aus „es läuft nicht" wird damit „umgepackt, lokale
/// Verbindung, hängt bei Öffnen" — ein Unterschied, der Stunden spart.
@MainActor
@Observable
final class PlaybackDiagnostics {
@@ -65,11 +65,8 @@ final class PlaybackDiagnostics {
private static func describe(_ decision: PlaybackDecision) -> String {
switch decision.method {
case .directPlay(let engine):
switch engine {
case .avPlayer: "Direkt · AVPlayer"
case .vlc: "Direkt · VLC"
}
case .directPlay:
"Direkt"
case .remux:
decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } }
? "Umgepackt · Bild unverändert, Ton umgerechnet"
@@ -1,14 +1,6 @@
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 FlixyPlayback
import HaloPlayback
/// Was der Nutzer über die Wiedergabe eingestellt hat.
///
@@ -36,19 +28,19 @@ final class PlaybackSettings {
}
private enum Key {
static let wifiQuality = "flixy.playback.quality.wifi"
static let cellularQuality = "flixy.playback.quality.cellular"
static let autoSkipIntro = "flixy.playback.autoSkipIntro"
static let autoSkipCredits = "flixy.playback.autoSkipCredits"
static let autoPlayNext = "flixy.playback.autoPlayNext"
static let locksLandscape = "flixy.playback.locksLandscape"
static let rewindSeconds = "flixy.playback.rewindSeconds"
static let forwardSeconds = "flixy.playback.forwardSeconds"
static let defaultRate = "flixy.playback.defaultRate"
static let remembersQuality = "flixy.playback.remembersQuality"
static let rememberedQuality = "flixy.playback.rememberedQuality"
static let showsBitrate = "flixy.playback.showsBitrate"
static let lastPlaybackStruggled = "flixy.playback.struggled"
static let wifiQuality = "halo.playback.quality.wifi"
static let cellularQuality = "halo.playback.quality.cellular"
static let autoSkipIntro = "halo.playback.autoSkipIntro"
static let autoSkipCredits = "halo.playback.autoSkipCredits"
static let autoPlayNext = "halo.playback.autoPlayNext"
static let locksLandscape = "halo.playback.locksLandscape"
static let rewindSeconds = "halo.playback.rewindSeconds"
static let forwardSeconds = "halo.playback.forwardSeconds"
static let defaultRate = "halo.playback.defaultRate"
static let remembersQuality = "halo.playback.remembersQuality"
static let rememberedQuality = "halo.playback.rememberedQuality"
static let showsBitrate = "halo.playback.showsBitrate"
static let lastPlaybackStruggled = "halo.playback.struggled"
}
/// Die Sprünge, die es zur Auswahl gibt.
@@ -60,25 +52,6 @@ final class PlaybackSettings {
/// Die Geschwindigkeiten, mit denen eine Wiedergabe beginnen kann.
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
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
@@ -165,10 +138,6 @@ final class PlaybackSettings {
return PlaybackPolicy(
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
)
}
@@ -15,7 +15,7 @@ import Observation
final class SearchFeed {
/// Wie viele zuletzt gesuchte Begriffe gemerkt werden.
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 isSearching = false
@@ -1,6 +1,6 @@
import Foundation
import Observation
import FlixyPlayback
import HaloPlayback
/// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg.
///
@@ -14,8 +14,8 @@ import FlixyPlayback
@Observable
final class SubtitleAppearance {
private enum Key {
static let size = "flixy.subtitles.size"
static let palette = "flixy.subtitles.palette"
static let size = "halo.subtitles.size"
static let palette = "halo.subtitles.palette"
}
private let defaults: UserDefaults
@@ -1,4 +1,4 @@
import FlixyPlayback
import HaloPlayback
import Foundation
/// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe
@@ -1,11 +1,11 @@
// **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
// einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft
// ihn trotzdem und bricht mit „Unable to resolve module dependency:
// TVServices" ab, ohne den Fernseher zu erwähnen. Dieselbe Falle wie bei
// VLCKit, siehe `PlaybackSettings`.
// siehe `PlaybackSettings`.
#if os(tvOS)
import Foundation
import MediaKit
@@ -21,7 +21,7 @@ import TVServices
@MainActor
enum TopShelfWriter {
/// 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.
///
@@ -99,5 +99,5 @@ extension EnvironmentValues {
///
/// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es
/// auch nichts neu zu laden.
@Entry var flixyItemChanged: () async -> Void = {}
@Entry var haloItemChanged: () async -> Void = {}
}
@@ -1,5 +1,6 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import HaloDownloads
import MediaKit
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
@@ -10,7 +11,7 @@ import MediaKit
/// Zwei Schritte für eine Absicht.
///
/// **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
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
/// und wäre für VoiceOver gar nicht mehr da.
@@ -26,21 +27,32 @@ struct QuickPlayTile<Card: View>: View {
var opensDetails: Bool = false
@ViewBuilder let card: () -> Card
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@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 errorMessage: String?
/// Der nachgeladene Titel, sobald die Qualitätsauswahl offen sein soll.
///
/// **Zwei Dinge in einem Zustand**, und das mit Absicht: die Kachel kennt
/// nur eine Zusammenfassung ohne Dateiangaben, also muss vor der Frage
/// nachgeladen werden. Ein eigenes `Bool` daneben wäre ein zweiter
/// Zustand, der mit diesem gleichziehen müsste.
@State private var downloadChoice: MediaItem?
/// Stufe steht, Tonspur fehlt — siehe `DownloadButton`.
@State private var pendingQuality: (item: MediaItem, quality: DownloadQuality)?
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
/// hat. Siehe `ItemActionEntries`.
@Environment(\.flixyItemChanged) private var itemChanged
@Environment(\.haloItemChanged) private var itemChanged
/// 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.
///
@@ -93,6 +105,15 @@ struct QuickPlayTile<Card: View>: View {
Divider()
}
Button("tile.play", systemImage: "play.fill") { trigger() }
// **Herunterladen gehört hierher.** Bisher führte der Weg
// über die Detailseite — zwei Schritte für etwas, das man
// beim Vorbeiscrollen entscheidet.
if isDownloadable {
Button("downloads.download", systemImage: "arrow.down.circle") {
Task { await prepareDownload() }
}
}
Divider()
}
ItemActionEntries(
@@ -117,6 +138,51 @@ struct QuickPlayTile<Card: View>: View {
// Aktion, die Millisekunden dauert, zu spät und ginge zu früh. Geht
// sie dagegen schief, muss man es erfahren — sonst bliebe der Haken
// einfach aus, und das sieht aus wie ein Fehler der App.
.confirmationDialog(
Text("downloads.quality.title"),
isPresented: Binding(
get: { downloadChoice != nil },
set: { if !$0 { downloadChoice = nil } }
),
titleVisibility: .visible
) {
if let ziel = downloadChoice {
let wahl = DownloadQualityChoice(item: ziel, backend: session.backend)
ForEach(wahl.available) { quality in
Button(wahl.label(for: quality)) { chose(ziel, quality: quality) }
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.quality.hint")
}
.confirmationDialog(
Text("downloads.audio.title"),
isPresented: Binding(
get: { pendingQuality != nil },
set: { if !$0 { pendingQuality = nil } }
),
titleVisibility: .visible
) {
if let offen = pendingQuality {
let spuren = offen.item.preferredVersion?.files.first?.audioStreams ?? []
let empfohlen = offen.item.preferredVersion?.files.first.flatMap {
service.preferredAudioStream(in: $0, of: offen.item)
}
ForEach(spuren) { stream in
Button(
stream == empfohlen
? String(localized: "downloads.audio.recommended \(stream.name)")
: stream.name
) {
enqueue(offen.item, quality: offen.quality, audioStream: stream)
}
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.audio.hint")
}
.alert(
Text("error.title"),
isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
@@ -170,13 +236,54 @@ struct QuickPlayTile<Card: View>: View {
.shadow(color: .black.opacity(0.35), radius: 3, y: 1)
}
.buttonStyle(.plain)
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
.opacity(isPointerInside ? 1 : 0)
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
.accessibilityHidden(true)
}
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
///
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
/// *den* Titel, sondern seine Folgen, und dafür gibt es auf der Seite
/// „Alle Folgen laden".
private var isDownloadable: Bool {
guard item.kind == .movie || item.kind == .episode else { return false }
return !downloads.items.contains { $0.ratingKey == item.id }
}
/// Holt die Dateiangaben nach und öffnet dann die Auswahl.
///
/// Erst beim Tippen, nicht beim Zeichnen: eine Reihe aus zwanzig Kacheln
/// wären sonst zwanzig Anfragen für ein Menü, das selten aufgeht.
private func prepareDownload() async {
downloadChoice = (try? await session.backend?.item(id: item.id)) ?? item
}
private var service: DownloadService {
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
}
/// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge.
private func chose(_ ziel: MediaItem, quality: DownloadQuality) {
downloadChoice = nil
if service.needsAudioChoice(for: ziel, quality: quality) {
pendingQuality = (ziel, quality)
} else {
enqueue(ziel, quality: quality, audioStream: nil)
}
}
private func enqueue(_ ziel: MediaItem, quality: DownloadQuality, audioStream: MediaStream?) {
pendingQuality = nil
do {
_ = try service.download(ziel, quality: quality, audioStream: audioStream)
} catch {
errorMessage = error.explanation
}
}
private func trigger() {
quickPlay.start(item, backend: session.backend, coordinator: playback)
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Das Aktionsmenü auf einer Detailseite.
@@ -17,7 +17,7 @@ struct ItemActionsMenu: View {
/// Detailseite neu geladen werden sollte.
let onChange: () async -> Void
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@State private var isWorking = false
@State private var errorMessage: String?
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Die Detailseite eines Titels.
@@ -10,7 +10,7 @@ import MediaKit
struct MediaDetailView: View {
let route: MediaRoute
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@State private var state: LoadState<MediaItem> = .loading
var body: some View {
@@ -37,12 +37,12 @@ struct MediaDetailView: View {
}
}
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(state.value?.title ?? route.title)
.flixyInlineTitle()
.haloInlineTitle()
.toolbar {
if let item = state.value {
ToolbarItem(placement: .flixyTrailing) {
ToolbarItem(placement: .haloTrailing) {
ItemActionsMenu(item: item) { await load() }
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// 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 {
if !destinations.isEmpty {
ScrollView(.horizontal) {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
ForEach(destinations, id: \.route.ratingKey) { destination in
NavigationLink(value: destination.route) {
Label {
@@ -24,16 +24,16 @@ struct ParentLinks: View {
} icon: {
Image(systemName: destination.symbol)
}
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.snug)
.background(Color.flixyElevated, in: .capsule)
.font(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.snug)
.background(Color.haloElevated, in: .capsule)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.horizontal, HaloSpacing.screenMargin)
}
.scrollIndicators(.never)
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
@@ -7,9 +7,9 @@ import MediaKit
struct PlayableDetailView: View {
let item: MediaItem
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selectedVersion = 0
@@ -17,7 +17,7 @@ struct PlayableDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.section) {
VStack(alignment: .leading, spacing: HaloSpacing.section) {
header
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
@@ -29,14 +29,14 @@ struct PlayableDetailView: View {
if item.hasMultipleVersions {
VersionPicker(media: item.versions, selection: $selectedVersion)
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.horizontal, HaloSpacing.screenMargin)
}
if let summary = item.summary, !summary.isEmpty {
Text(summary)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.screenMargin)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
.padding(.horizontal, HaloSpacing.screenMargin)
}
if !item.genres.isEmpty {
@@ -53,13 +53,13 @@ struct PlayableDetailView: View {
part: part,
isExpanded: $showsTechnicalDetails
)
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.horizontal, HaloSpacing.screenMargin)
}
}
.padding(.bottom, FlixySpacing.section)
.padding(.bottom, HaloSpacing.section)
}
.scrollIndicators(.never)
.flixyRevealsTopChrome()
.haloRevealsTopChrome()
}
private var currentMedia: MediaVersion? {
@@ -104,9 +104,9 @@ struct PlayableDetailView: View {
item.watchProgress == nil ? "hero.play" : "hero.resume",
systemImage: "play.fill"
)
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
@@ -118,11 +118,11 @@ struct PlayableDetailView: View {
} label: {
Label("detail.playFromStart", systemImage: "gobackward")
.labelStyle(.iconOnly)
.font(.flixyCardTitle)
.padding(FlixySpacing.snug)
.font(.haloCardTitle)
.padding(HaloSpacing.snug)
}
.buttonStyle(.bordered)
.tint(Color.flixyTextSecondary)
.tint(Color.haloTextSecondary)
}
DownloadButton(item: item, mediaIndex: selectedVersion)
@@ -210,10 +210,10 @@ struct VersionPicker: View {
@Binding var selection: Int
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
Text("detail.versions")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
Picker("detail.versions", selection: $selection) {
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
@@ -232,17 +232,17 @@ struct TagStrip: View {
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
ForEach(tags, id: \.self) { tag in
Text(tag)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.background(Color.flixyElevated, in: .capsule)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.tight)
.background(Color.haloElevated, in: .capsule)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.horizontal, HaloSpacing.screenMargin)
}
.scrollIndicators(.never)
}
@@ -251,20 +251,20 @@ struct TagStrip: View {
/// Die Besetzung mit Porträts.
struct CastRow: View {
let cast: [MediaPerson]
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text("detail.cast")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.screenMargin)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.screenMargin)
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: FlixySpacing.regular) {
HStack(alignment: .top, spacing: HaloSpacing.regular) {
ForEach(cast.prefix(20)) { role in
VStack(spacing: FlixySpacing.snug) {
FlixyArtwork(
VStack(spacing: HaloSpacing.snug) {
HaloArtwork(
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
aspectRatio: 1,
fallbackSymbol: "person"
@@ -273,15 +273,15 @@ struct CastRow: View {
.clipShape(.circle)
Text(role.name)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
.multilineTextAlignment(.center)
if let character = role.role {
Text(character)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
@@ -289,7 +289,7 @@ struct CastRow: View {
.accessibilityElement(children: .combine)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.horizontal, HaloSpacing.screenMargin)
}
.scrollIndicators(.never)
}
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import HaloDesign
import HaloDownloads
import MediaKit
/// Die Detailseite einer Serie oder Staffel.
@@ -10,9 +10,10 @@ import MediaKit
struct SeriesDetailView: View {
let item: MediaItem
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@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
@State private var children: LoadState<[MediaItem]> = .loading
@@ -27,21 +28,23 @@ struct SeriesDetailView: View {
@State private var selection: Set<String> = []
@State private var sample: MediaItem?
@State private var showsQualityChoice = false
/// Stufe steht, Tonspur fehlt — gefragt wird einmal für den ganzen Stapel.
@State private var pendingQuality: DownloadQuality?
@State private var isWorking = false
@State private var summary: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.section) {
VStack(alignment: .leading, spacing: HaloSpacing.section) {
header
ParentLinks(item: item)
if let summary = item.summary, !summary.isEmpty {
Text(summary)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.screenMargin)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
.padding(.horizontal, HaloSpacing.screenMargin)
}
if !item.genres.isEmpty { TagStrip(tags: item.genres) }
@@ -55,17 +58,17 @@ struct SeriesDetailView: View {
scope: .series(item.id),
title: "languages.forThisSeries"
)
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.horizontal, HaloSpacing.screenMargin)
}
childList
if !item.cast.isEmpty { CastRow(cast: item.cast) }
}
.padding(.bottom, FlixySpacing.section)
.padding(.bottom, HaloSpacing.section)
}
.scrollIndicators(.never)
.flixyRevealsTopChrome()
.haloRevealsTopChrome()
.safeAreaInset(edge: .bottom, spacing: 0) { selectionBar }
.confirmationDialog(
Text("downloads.quality.title"),
@@ -77,14 +80,41 @@ struct SeriesDetailView: View {
backend: session.backend
)
ForEach(choice.available) { quality in
Button(choice.label(for: quality)) {
Task { await loadSelected(quality: quality) }
}
Button(choice.label(for: quality)) { chose(quality) }
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.quality.hint")
}
.confirmationDialog(
Text("downloads.audio.title"),
isPresented: Binding(
get: { pendingQuality != nil },
set: { if !$0 { pendingQuality = nil } }
),
titleVisibility: .visible
) {
// **Einmal für den Stapel.** Die Folgen einer Staffel sind gleich
// aufgebaut; zwanzigmal dieselbe Frage wäre keine Wahl, sondern
// eine Prüfung der Geduld.
if let muster = sample?.preferredVersion?.files.first {
let empfohlen = sample.map { downloadService.preferredAudioStream(in: muster, of: $0) }
ForEach(muster.audioStreams) { stream in
Button(
stream == empfohlen
? String(localized: "downloads.audio.recommended \(stream.name)")
: stream.name
) {
if let pendingQuality {
Task { await loadSelected(quality: pendingQuality, audioStreamIndex: stream.index) }
}
}
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.audio.hint")
}
.alert(
Text("downloads.batch.title"),
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
@@ -133,34 +163,34 @@ struct SeriesDetailView: View {
private var childList: some View {
switch children {
case .loading:
ProgressView().frame(maxWidth: .infinity).padding(.vertical, FlixySpacing.section)
ProgressView().frame(maxWidth: .infinity).padding(.vertical, HaloSpacing.section)
case .failed(let message):
Text(message)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.padding(.horizontal, FlixySpacing.screenMargin)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.padding(.horizontal, HaloSpacing.screenMargin)
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.**
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
// als von allem anderen auf dieser Seite.
HStack {
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
Spacer()
if entries.contains(where: { $0.kind == .episode }) {
selectionControls(for: entries)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.horizontal, HaloSpacing.screenMargin)
LazyVStack(spacing: 0) {
ForEach(entries) { entry in
row(for: entry)
Divider().overlay(Color.flixySeparator)
Divider().overlay(Color.haloSeparator)
}
}
}
@@ -177,16 +207,16 @@ struct SeriesDetailView: View {
Button {
if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) }
} label: {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
ChildRow(item: entry)
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(gewaehlt ? accent : Color.flixyTextMuted)
.foregroundStyle(gewaehlt ? accent : Color.haloTextMuted)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.horizontal, HaloSpacing.screenMargin)
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
} else {
// **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
// Layout den Platz auf, und der Titel kann gar nicht erst unter
// dem Knopf verschwinden.
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
ChildRow(item: entry)
}
@@ -211,7 +241,7 @@ struct SeriesDetailView: View {
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 {
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
HStack(spacing: FlixySpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
if isSelecting {
Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") {
selection = selection.count == folgen.count ? [] : Set(folgen)
@@ -228,12 +258,12 @@ struct SeriesDetailView: View {
isSelecting = false
selection = []
}
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
} else {
Button("detail.select") { isSelecting = true }
}
}
.font(.flixyCaption)
.font(.haloCaption)
.buttonStyle(.plain)
.foregroundStyle(accent)
}
@@ -258,14 +288,14 @@ struct SeriesDetailView: View {
)
}
}
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(isWorking)
.padding(.vertical, FlixySpacing.regular)
.padding(.vertical, HaloSpacing.regular)
.frame(maxWidth: .infinity)
.background(.bar)
}
@@ -291,12 +321,27 @@ struct SeriesDetailView: View {
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
defer { isWorking = false }
let service = DownloadService(backend: session.backend, coordinator: downloads)
let result = await service.download(selectedEpisodes, quality: quality)
let result = await downloadService.download(
selectedEpisodes, quality: quality, audioStreamIndex: audioStreamIndex
)
var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")]
if let erster = result.refused.first {
@@ -342,11 +387,11 @@ struct SeriesDetailView: View {
/// Eine Staffel- oder Folgenzeile.
private struct ChildRow: View {
let item: MediaItem
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
var body: some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(
path: item.thumb,
width: item.kind == .season ? 120 : 240,
@@ -366,14 +411,14 @@ private struct ChildRow: View {
VStack(alignment: .leading, spacing: 2) {
Text(rowTitle)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
if let subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
@@ -382,14 +427,14 @@ private struct ChildRow: View {
if item.isWatched {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("item.watched"))
}
}
// Das waagerechte Polster liegt jetzt eine Ebene höher, bei der
// Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am
// Bildschirmrand.
.padding(.vertical, FlixySpacing.snug)
.padding(.vertical, HaloSpacing.snug)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
@@ -1,11 +1,11 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import HaloDesign
import HaloPlayback
import MediaKit
/// 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
/// 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.
@@ -14,19 +14,33 @@ struct TechnicalDetails: View {
let part: MediaFile
@Binding var isExpanded: Bool
/// Was mit dieser Datei passieren würde.
///
/// **Mit Serverhilfe gerechnet, nicht ohne.** Hier stand `.directPlayOnly`
/// — damals eine brauchbare Auskunft, weil ohne Serverhilfe die zweite
/// Engine übernahm und auch so hiess. Seit es sie nicht mehr gibt, kam
/// dort „Direkt (Systemplayer)" heraus, direkt über der Zeile „iOS öffnet
/// den Container MKV nicht" — zwei Angaben, die sich widersprechen, und
/// die zweite stimmte.
///
/// Ohne Bandbreitengrenze, weil sie von der Verbindung abhängt und diese
/// Seite sie nicht kennt. Gezeigt wird damit der Fall im WLAN.
private var decision: PlaybackDecision {
PlaybackDecider().decide(profile: media.profile(file: part), policy: .directPlayOnly)
PlaybackDecider().decide(
profile: media.profile(file: part),
policy: PlaybackPolicy(allowsTranscoding: true)
)
}
var body: some View {
DisclosureGroup(isExpanded: $isExpanded) {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
row("technical.playbackMethod", playbackMethodLabel)
if let reason = rationaleLabel {
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 codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
@@ -41,7 +55,7 @@ struct TechnicalDetails: View {
}
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
row("technical.audio", trackLabel(stream))
}
@@ -54,36 +68,36 @@ struct TechnicalDetails: View {
}
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) {
Text("technical.file")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Text(file)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
.textSelection(.enabled)
}
}
}
.padding(.top, FlixySpacing.snug)
.padding(.top, HaloSpacing.snug)
} label: {
Text("technical.title")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
}
.tint(Color.flixyTextSecondary)
.tint(Color.haloTextSecondary)
}
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
HStack(alignment: .top) {
Text(label)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Spacer()
Text(value)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
.multilineTextAlignment(.trailing)
}
}
@@ -96,11 +110,8 @@ struct TechnicalDetails: View {
private var playbackMethodLabel: String {
switch decision.method {
case .directPlay(let engine):
switch engine {
case .avPlayer: String(localized: "technical.directPlayNative")
case .vlc: String(localized: "technical.directPlayVLC")
}
case .directPlay:
String(localized: "technical.directPlayNative")
// **Zwei Fälle unter einem Namen wären eine Lüge.** Umgepackt bleibt
// das Bild unberührt — der Ton nur dann, wenn AVPlayer ihn kennt.
case .remux:
@@ -1,8 +1,7 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import HaloDesign
import HaloDownloads
import MediaKit
import PlexKit
/// Der Download-Knopf auf einer Detailseite.
///
@@ -26,15 +25,24 @@ struct DownloadButton: View {
/// nicht drückt.
var loadsDetails: Bool = false
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@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 showsQualityChoice = false
/// Der nachgeholte Titel — gilt ab dann statt `item`.
@State private var detailed: MediaItem?
@State private var isLoadingDetails = false
/// Die Stufe steht fest, die Tonspur fehlt noch.
///
/// **Weil die Wahl nach dem Herunterladen nicht mehr zu korrigieren ist.**
/// Beim Umpacken legt der Server genau eine Tonspur in die Datei; welche,
/// hat vorher die Abwägung entschieden — und bei einer Aufnahme mit
/// Audiodeskription die falsche. Drei Gigabyte später gab es keinen Weg
/// zurück.
@State private var pendingQuality: DownloadQuality?
private var existing: DownloadItem? {
downloads.items.first { $0.ratingKey == item.id }
@@ -56,18 +64,18 @@ struct DownloadButton: View {
} else {
Label(label, systemImage: symbol)
.labelStyle(.iconOnly)
.font(.flixyCardTitle)
.font(.haloCardTitle)
}
}
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
.frame(width: 22, height: 22)
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
}
.disabled(isLoadingDetails)
.buttonStyle(.bordered)
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
.tint(existing?.state == .completed ? accent : Color.haloTextSecondary)
.accessibilityLabel(Text(label))
.confirmationDialog(
Text("downloads.quality.title"),
@@ -75,12 +83,29 @@ struct DownloadButton: View {
titleVisibility: .visible
) {
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) {}
} message: {
Text("downloads.quality.hint")
}
.confirmationDialog(
Text("downloads.audio.title"),
isPresented: Binding(
get: { pendingQuality != nil },
set: { if !$0 { pendingQuality = nil } }
),
titleVisibility: .visible
) {
ForEach(audioStreams) { stream in
Button(audioLabel(stream)) {
if let pendingQuality { enqueue(quality: pendingQuality, audioStream: stream) }
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.audio.hint")
}
.alert(
Text("downloads.refused.title"),
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
@@ -157,10 +182,45 @@ struct DownloadButton: View {
showsQualityChoice = true
}
private func enqueue(quality: DownloadQuality) {
let service = DownloadService(backend: session.backend, coordinator: downloads)
private var service: DownloadService {
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
}
private var audioStreams: [MediaStream] {
let media = source.versions.indices.contains(mediaIndex)
? source.versions[mediaIndex] : source.preferredVersion
return media?.files.first?.audioStreams ?? []
}
/// Die Spur, die vorgeschlagen würde — gemerkte Sprache der Serie zuerst.
private var recommended: MediaStream? {
let media = source.versions.indices.contains(mediaIndex)
? source.versions[mediaIndex] : source.preferredVersion
guard let part = media?.files.first else { return nil }
return service.preferredAudioStream(in: part, of: source)
}
private func audioLabel(_ stream: MediaStream) -> String {
stream == recommended
? String(localized: "downloads.audio.recommended \(stream.name)")
: stream.name
}
/// Nach der Stufe: entweder fragen oder einreihen.
private func chose(_ quality: DownloadQuality) {
if service.needsAudioChoice(for: source, mediaIndex: mediaIndex, quality: quality) {
pendingQuality = quality
} else {
enqueue(quality: quality, audioStream: nil)
}
}
private func enqueue(quality: DownloadQuality, audioStream: MediaStream?) {
pendingQuality = nil
do {
_ = try service.download(source, mediaIndex: mediaIndex, quality: quality)
_ = try service.download(
source, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream
)
} catch {
refusal = error
}
@@ -187,9 +247,10 @@ extension DownloadQuality {
struct DownloadAllButton: View {
let container: MediaItem
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@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 summary: String?
@@ -208,7 +269,7 @@ struct DownloadAllButton: View {
}
}
.buttonStyle(.bordered)
.tint(Color.flixyTextSecondary)
.tint(Color.haloTextSecondary)
.disabled(isWorking)
.alert(
Text("downloads.batch.title"),
@@ -224,7 +285,9 @@ struct DownloadAllButton: View {
isWorking = true
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)
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
@@ -1,5 +1,5 @@
import Foundation
import FlixyDownloads
import HaloDownloads
import MediaKit
/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen.
@@ -1,14 +1,14 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import HaloDesign
import HaloDownloads
import HaloPlayback
/// Was offline ist und was gerade geladen wird.
struct DownloadsView: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackCoordinator.self) private var playback
@Environment(NetworkConditions.self) private var network
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
@@ -24,16 +24,16 @@ struct DownloadsView: View {
list
}
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("sidebar.downloads"))
.toolbar {
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
// mehr, und dies ist der Weg dorthin.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() }
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
}
}
.flixyInlineTitle()
.haloInlineTitle()
// Keine Rückfrage beim Entfernen.
//
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
@@ -88,8 +88,8 @@ struct DownloadsView: View {
}
}
.scrollContentBackground(.hidden)
.listRowBackground(Color.flixySurface)
.flixyRevealsTopChrome()
.listRowBackground(Color.haloSurface)
.haloRevealsTopChrome()
}
private var active: [DownloadItem] {
@@ -125,7 +125,7 @@ struct DownloadsView: View {
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
private struct StorageSection: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
@Bindable var downloads = downloads
@@ -133,10 +133,10 @@ private struct StorageSection: View {
Section {
HStack {
Text("downloads.used")
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
Spacer()
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
.foregroundStyle(Color.flixyTextPrimary)
.foregroundStyle(Color.haloTextPrimary)
}
if let limit = downloads.storageLimitBytes, limit > 0 {
@@ -157,20 +157,20 @@ private struct DownloadRow: View {
let onRemove: () -> Void
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
HStack(spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
HStack(spacing: HaloSpacing.regular) {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle = item.subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
@@ -190,19 +190,19 @@ private struct DownloadRow: View {
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
if let message = item.failureMessage {
Text(message)
.font(.flixyCaption)
.foregroundStyle(Color.flixyDestructive)
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
.lineLimit(2)
}
}
.padding(.vertical, FlixySpacing.tight)
.listRowBackground(Color.flixySurface)
.padding(.vertical, HaloSpacing.tight)
.listRowBackground(Color.haloSurface)
.swipeActions(edge: .trailing) {
Button("action.delete", role: .destructive, action: onRemove)
}
@@ -240,18 +240,18 @@ private struct DownloadRow: View {
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("downloads.available"))
case .queued, .waiting:
Image(systemName: "clock")
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("downloads.queued"))
}
}
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
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
// Download.
@@ -273,17 +273,17 @@ private struct DownloadRow: View {
// gemeinsames Mass für beide Zahlen (siehe `unit`), und die
// Prozente am rechten Rand — die wachsen nach innen und schieben
// nichts.
HStack(alignment: .firstTextBaseline, spacing: FlixySpacing.snug) {
HStack(alignment: .firstTextBaseline, spacing: HaloSpacing.snug) {
Text(statusLine)
.lineLimit(1)
Spacer(minLength: FlixySpacing.snug)
Spacer(minLength: HaloSpacing.snug)
if let percentage {
Text(percentage)
}
}
.font(.flixyCaption)
.font(.haloCaption)
.monospacedDigit()
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
}
}
@@ -387,28 +387,28 @@ private struct DownloadRow: View {
private struct HoldNotice: View {
let hold: TransferHold
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
HStack(alignment: .top, spacing: FlixySpacing.regular) {
HStack(alignment: .top, spacing: HaloSpacing.regular) {
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
.font(.title3)
.foregroundStyle(accent)
VStack(alignment: .leading, spacing: 2) {
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
Text(hold == .offline
? "downloads.hold.offline.description"
: "downloads.hold.wifi.description")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
.padding(.vertical, FlixySpacing.tight)
.listRowBackground(Color.flixySurface)
.padding(.vertical, HaloSpacing.tight)
.listRowBackground(Color.haloSurface)
.accessibilityElement(children: .combine)
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
@@ -65,8 +65,8 @@ struct HeroCarousel: View {
@State private var isHoveringDots = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.flixyAccent) private var accent
@Environment(FlixySession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(HaloSession.self) private var session
@Environment(\.horizontalSizeClass) private var sizeClass
@Environment(TopChrome.self) private var chrome
@Environment(HomeLayoutSettings.self) private var layout
@@ -118,7 +118,7 @@ struct HeroCarousel: View {
}
var body: some View {
VStack(spacing: FlixySpacing.regular) {
VStack(spacing: HaloSpacing.regular) {
heroSection
// 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.
///
@@ -203,7 +203,7 @@ struct HeroCarousel: View {
Spacer()
arrow(symbol: "chevron.right", direction: 1)
}
.padding(.horizontal, FlixySpacing.regular)
.padding(.horizontal, HaloSpacing.regular)
.opacity(isPointerInside ? 1 : 0)
.animation(.easeOut(duration: 0.2), value: isPointerInside)
.onContinuousHover { phase in
@@ -219,7 +219,7 @@ struct HeroCarousel: View {
Image(systemName: symbol)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.padding(FlixySpacing.regular)
.padding(HaloSpacing.regular)
.background(.black.opacity(0.45), in: .circle)
}
.buttonStyle(.plain)
@@ -237,10 +237,10 @@ struct HeroCarousel: View {
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
private var indicator: some View {
HStack(spacing: isEnlarged ? FlixySpacing.regular : FlixySpacing.snug) {
HStack(spacing: isEnlarged ? HaloSpacing.regular : HaloSpacing.snug) {
ForEach(items.indices, id: \.self) { position in
Capsule()
.fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4))
.fill(position == currentIndex ? accent : Color.haloTextMuted.opacity(0.4))
.frame(
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (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
// beim Annähern etwas bewirken, nicht erst beim Treffen.
.padding(.vertical, FlixySpacing.snug)
.padding(.vertical, HaloSpacing.snug)
.contentShape(.rect)
.onContinuousHover { phase in
guard isMac else { return }
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Der Aufmacher oben auf der Startseite.
@@ -12,10 +12,10 @@ struct HeroSection: View {
/// Wie weit es dabei hereinrutscht.
var incomingOffset: CGFloat = 0
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
@@ -56,21 +56,21 @@ struct HeroSection: View {
quickPlay.start(item, backend: session.backend, coordinator: playback)
} label: {
Label(playLabel, systemImage: "play.fill")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
Label("hero.details", systemImage: "info.circle")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.bordered)
.tint(Color.flixyTextSecondary)
.tint(Color.haloTextSecondary)
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// 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
/// ausgerechnet der Teil gewesen, an dem schon mehrere Fehler steckten.
struct HomeView: View {
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@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
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
// ohnehin mehr.
.navigationTitle("")
.flixyInlineTitle()
.haloInlineTitle()
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
// Aufmacher.
.flixyClearToolbarBackground()
.haloClearToolbarBackground()
// Gar keine Navigationsleiste auf der Startseite.
//
// 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) }
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
// 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 {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
let featured = feed.heroItems(for: layout)
if !featured.isEmpty {
HeroCarousel(items: featured)
@@ -78,9 +78,9 @@ struct HomeView: View {
MediaRow(row: hub)
}
}
.padding(.bottom, FlixySpacing.section)
.padding(.bottom, HaloSpacing.section)
}
.scrollIndicators(.never)
.flixyRevealsTopChrome()
.haloRevealsTopChrome()
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Eine waagerechte Reihe von Titeln.
@@ -8,7 +8,7 @@ struct MediaRow: View {
/// Wird durchgereicht — siehe `QuickPlayTile`.
var opensDetails: Bool = false
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@@ -23,19 +23,19 @@ struct MediaRow: View {
private var posterWidth: CGFloat { 116 * layout.cardScale }
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text(row.title)
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.screenMargin)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.screenMargin)
ScrollView(.horizontal) {
LazyHStack(spacing: FlixySpacing.regular) {
LazyHStack(spacing: HaloSpacing.regular) {
ForEach(row.items) { item in
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
// bleibt an beliebiger Stelle stehen.
.scrollTargetLayout()
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// 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
/// gezeichnete Leiste kennt diese Grenze nicht.
struct LibraryBrowser: View {
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
/// Was gerade gezeigt wird.
///
@@ -36,7 +36,7 @@ struct LibraryBrowser: View {
var body: some View {
inhalt
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
// **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste
// über der Leiste, statt dahinter zu verschwinden — und das gilt
// auch für die Ladeanzeige und den Fuss „alles geladen".
@@ -86,11 +86,11 @@ struct LibraryBrowser: View {
}
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
// kein nachgebautes Aussehen.
.background { FlixySidebar.surface(in: .capsule) }
.background { HaloSidebar.surface(in: .capsule) }
.clipShape(.capsule)
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
.padding(.horizontal, FlixySpacing.regular)
.padding(.bottom, FlixySpacing.snug)
.padding(.horizontal, HaloSpacing.regular)
.padding(.bottom, HaloSpacing.snug)
}
private var reihe: some View {
@@ -99,8 +99,8 @@ struct LibraryBrowser: View {
pille(eintrag)
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.snug)
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.snug)
}
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
// Einträge, hier bis zu fünf — und auf einem Telefon.
.font(.flixyCaption)
.foregroundStyle(gewaehlt ? Color.flixyTextPrimary : Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.snug)
.font(.haloCaption)
.foregroundStyle(gewaehlt ? Color.haloTextPrimary : Color.haloTextSecondary)
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.snug)
.background {
if gewaehlt { Capsule().fill(Color.flixyElevated) }
if gewaehlt { Capsule().fill(Color.haloElevated) }
}
.contentShape(Capsule())
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Filter und Sortierung einer Mediathek.
@@ -14,8 +14,8 @@ struct LibraryFilterSheet: View {
let library: MediaLibrary
@Environment(\.dismiss) private var dismiss
@Environment(\.flixyAccent) private var accent
@Environment(FlixySession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(HaloSession.self) private var session
@State private var genres: [MediaFilterOption] = []
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
@@ -91,11 +91,11 @@ struct LibraryFilterSheet: View {
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent)
.flixyForm()
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("filter.title"))
.flixyInlineTitle()
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { dismiss() }
@@ -109,7 +109,7 @@ struct LibraryFilterSheet: View {
}
.task { await loadGenres() }
}
.flixyResizableSheet()
.haloResizableSheet()
}
private func loadGenres() async {
@@ -1,16 +1,16 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Eine Mediathek zum Durchblättern.
struct LibraryView: View {
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
/// zwischen Raster und Liste.
@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
@State private var items: [MediaItem] = []
@@ -59,9 +59,9 @@ struct LibraryView: View {
}
}
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(library.title)
.flixyInlineTitle()
.haloInlineTitle()
.toolbar { toolbar }
.sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, library: library)
@@ -80,7 +80,7 @@ struct LibraryView: View {
.onChange(of: query) { _, _ in Task { await reload() } }
.refreshable { await reload() }
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
.environment(\.flixyItemChanged, { await reload() })
.environment(\.haloItemChanged, { await reload() })
}
@ViewBuilder
@@ -97,8 +97,8 @@ struct LibraryView: View {
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
// ohne dass die Größenklasse abgefragt werden muss.
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: FlixySpacing.regular)],
spacing: FlixySpacing.loose
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: HaloSpacing.regular)],
spacing: HaloSpacing.loose
) {
ForEach(items) { item in
QuickPlayTile(item: item) {
@@ -119,12 +119,12 @@ struct LibraryView: View {
PlaceholderCard(index: position)
}
}
.padding(FlixySpacing.screenMargin)
.padding(HaloSpacing.screenMargin)
moreButton.padding(.bottom, FlixySpacing.section)
footerCount.padding(.bottom, FlixySpacing.section)
moreButton.padding(.bottom, HaloSpacing.section)
footerCount.padding(.bottom, HaloSpacing.section)
}
.flixyRevealsTopChrome()
.haloRevealsTopChrome()
}
private var list: some View {
@@ -137,19 +137,19 @@ struct LibraryView: View {
.buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) }
Divider().overlay(Color.flixySeparator)
Divider().overlay(Color.haloSeparator)
}
}
if pendingCount > 0 {
ProgressView()
.padding(.vertical, FlixySpacing.section)
.padding(.vertical, HaloSpacing.section)
.frame(maxWidth: .infinity)
}
moreButton.padding(.vertical, FlixySpacing.section)
footerCount.padding(.bottom, FlixySpacing.section)
moreButton.padding(.vertical, HaloSpacing.section)
footerCount.padding(.bottom, HaloSpacing.section)
}
.flixyRevealsTopChrome()
.haloRevealsTopChrome()
}
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
@@ -173,8 +173,8 @@ struct LibraryView: View {
private var footerCount: some View {
if let page, !page.hasMore, !items.isEmpty {
Text("library.allLoaded \(items.count)")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.frame(maxWidth: .infinity)
}
}
@@ -183,10 +183,10 @@ struct LibraryView: View {
@ViewBuilder
private var moreButton: some View {
if loadMoreFailed, let page, page.hasMore {
VStack(spacing: FlixySpacing.snug) {
VStack(spacing: HaloSpacing.snug) {
Text("library.loadMoreFailed")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Button("library.loadMore") {
Task { await loadMore() }
}
@@ -206,7 +206,7 @@ struct LibraryView: View {
// steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste,
// und ein zweiter Weg dorthin wäre nur Verdopplung.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) {
ToolbarItem(placement: .haloTrailing) {
NavigationLink(value: SearchRoute()) {
Image(systemName: "magnifyingglass")
}
@@ -218,7 +218,7 @@ struct LibraryView: View {
// oben, und selbst dort wo es das gibt, ist es in einem Raster kaum
// zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen
// Knopf und keine Geste.
ToolbarItem(placement: .flixyTrailing) {
ToolbarItem(placement: .haloTrailing) {
Button {
Task { await reload() }
} label: {
@@ -228,7 +228,7 @@ struct LibraryView: View {
.disabled(isLoadingMore)
}
ToolbarItem(placement: .flixyTrailing) {
ToolbarItem(placement: .haloTrailing) {
Button {
showsFilters = true
} label: {
@@ -239,7 +239,7 @@ struct LibraryView: View {
.accessibilityLabel(Text("library.filters"))
}
ToolbarItem(placement: .flixyTrailing) {
ToolbarItem(placement: .haloTrailing) {
Button {
layout = layout == .grid ? .list : .grid
} label: {
@@ -255,7 +255,7 @@ struct LibraryView: View {
// Reihe etwas steht, kann nur der entscheiden, der die Reihe
// schreibt.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() }
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
}
}
@@ -380,11 +380,11 @@ enum LibraryLayout: String, CaseIterable {
/// Eine Zeile in der Listenansicht.
private struct LibraryListRow: View {
let item: MediaItem
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
var body: some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
aspectRatio: PosterCard.aspectRatio
)
@@ -393,13 +393,13 @@ private struct LibraryListRow: View {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle = item.cardSubtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
}
@@ -407,12 +407,12 @@ private struct LibraryListRow: View {
if item.isWatched {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("item.watched"))
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.snug)
.padding(.horizontal, HaloSpacing.screenMargin)
.padding(.vertical, HaloSpacing.snug)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Welche Sender überhaupt angezeigt werden.
@@ -12,8 +12,8 @@ struct ChannelSelectionSheet: View {
let channels: [LiveChannel]
@Binding var selection: Set<String>
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -28,9 +28,9 @@ struct ChannelSelectionSheet: View {
}
}
.navigationTitle(Text("liveTV.selection"))
.flixyInlineTitle()
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .flixyLeading) {
ToolbarItem(placement: .haloLeading) {
// „Alle" statt „keine": eine leere Auswahl bedeutet
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
// wäre verwirrend.
@@ -57,10 +57,10 @@ struct ChannelSelectionSheet: View {
selection.insert(channel.id)
}
} label: {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
aspectRatio: FlixyAspectRatio.channelLogo,
aspectRatio: HaloAspectRatio.channelLogo,
fallbackSymbol: "tv",
contentMode: .fit
)
@@ -69,17 +69,17 @@ struct ChannelSelectionSheet: View {
if let number = channel.number {
Text(number)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
}
Text(channel.title)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
Spacer()
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isShown(channel) ? accent : Color.flixyTextMuted)
.foregroundStyle(isShown(channel) ? accent : Color.haloTextMuted)
}
.contentShape(.rect)
}
@@ -1,11 +1,11 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import HaloDesign
import HaloPlayback
import MediaKit
/// Live-TV mit Programmführer.
struct LiveTVView: View {
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(NetworkConditions.self) private var network
@@ -74,7 +74,7 @@ struct LiveTVView: View {
} description: {
Text("liveTV.noChannels.description")
} actions: {
VStack(spacing: FlixySpacing.regular) {
VStack(spacing: HaloSpacing.regular) {
Button("action.retry") { Task { await load() } }
searchTrace
}
@@ -107,12 +107,12 @@ struct LiveTVView: View {
}
}
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("sidebar.liveTV"))
.flixyInlineTitle()
.haloInlineTitle()
.toolbar {
if case .available = availability, !channels.isEmpty {
ToolbarItem(placement: .flixyTrailing) {
ToolbarItem(placement: .haloTrailing) {
Button {
showsSelection = true
} label: {
@@ -120,7 +120,7 @@ struct LiveTVView: View {
}
.accessibilityLabel(Text("liveTV.selection"))
}
ToolbarItem(placement: .flixyTrailing) {
ToolbarItem(placement: .haloTrailing) {
Picker("liveTV.mode", selection: $mode) {
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
Image(systemName: "list.bullet").tag(LiveTVMode.channels)
@@ -169,11 +169,11 @@ struct LiveTVView: View {
@ViewBuilder
private var searchTrace: some View {
if !trace.isEmpty {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
if let channelSource {
Text(channelSource)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
ScrollView {
@@ -181,7 +181,7 @@ struct LiveTVView: View {
ForEach(trace) { probe in
Text("\(probe.path) → \(probe.result)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(2)
.textSelection(.enabled)
}
@@ -191,18 +191,18 @@ struct LiveTVView: View {
.frame(maxHeight: 200)
Button {
FlixyClipboard.copy(
HaloClipboard.copy(
trace
.map { "\($0.path) → \($0.result)" }
.joined(separator: "\n")
)
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.flixyCaption)
.font(.haloCaption)
}
.buttonStyle(.bordered)
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.horizontal, HaloSpacing.screenMargin)
}
}
@@ -216,11 +216,11 @@ struct LiveTVView: View {
isTuning: isTuning == channel.id,
onTune: { Task { await tune(channel) } }
)
Divider().overlay(Color.flixySeparator)
Divider().overlay(Color.haloSeparator)
}
}
}
.flixyRevealsTopChrome()
.haloRevealsTopChrome()
}
private func load() async {
@@ -329,43 +329,43 @@ private struct ChannelRow: View {
let isTuning: Bool
let onTune: () -> Void
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
private var current: LiveProgram? { programs.first { $0.isRunning() } }
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
var body: some View {
Button(action: onTune) {
HStack(spacing: FlixySpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
// fast nie quadratisch, und formatfüllend fehlten links und
// rechts genau die Teile, an denen man den Sender erkennt.
FlixyArtwork(
HaloArtwork(
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
aspectRatio: FlixyAspectRatio.channelLogo,
aspectRatio: HaloAspectRatio.channelLogo,
fallbackSymbol: "tv",
contentMode: .fit
)
.frame(width: 56, height: 34)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
if let number = channel.number {
Text(number)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
}
Text(channel.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
}
if let current {
Text(current.title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
.lineLimit(1)
// Der Balken zeigt, wie weit die Sendung ist — beim
// Umschalten die nützlichste Angabe überhaupt.
@@ -373,14 +373,14 @@ private struct ChannelRow: View {
.padding(.top, 2)
} else {
Text("liveTV.noProgram")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
if let next {
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
@@ -395,8 +395,8 @@ private struct ChannelRow: View {
.foregroundStyle(accent)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.regular)
.padding(.horizontal, HaloSpacing.screenMargin)
.padding(.vertical, HaloSpacing.regular)
.contentShape(.rect)
}
.buttonStyle(.plain)
@@ -427,43 +427,43 @@ private struct TuneFailureSheet: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text(failure.channel)
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
Text(failure.reason)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button {
FlixyClipboard.copy(failure.trace.joined(separator: "\n"))
HaloClipboard.copy(failure.trace.joined(separator: "\n"))
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.flixyCaption)
.font(.haloCaption)
}
.buttonStyle(.bordered)
}
.padding(FlixySpacing.screenMargin)
.padding(HaloSpacing.screenMargin)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("liveTV.tuneFailed"))
.flixyInlineTitle()
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.flixyResizableSheet()
.haloResizableSheet()
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
@@ -34,7 +34,7 @@ struct ProgramGuide: View {
var onRecord: ((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 selection: GuideSelection?
@@ -50,7 +50,7 @@ struct ProgramGuide: View {
var body: some View {
VStack(spacing: 0) {
axis
Divider().overlay(Color.flixySeparator)
Divider().overlay(Color.haloSeparator)
ScrollView(.vertical) {
HStack(alignment: .top, spacing: 0) {
@@ -65,7 +65,7 @@ struct ProgramGuide: View {
}
.scrollIndicators(.never)
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.sheet(item: $selection) { selection in
ProgramDetailSheet(
channel: selection.channel,
@@ -104,12 +104,12 @@ struct ProgramGuide: View {
private var axis: some View {
HStack(spacing: 0) {
Text("liveTV.channelCount \(channels.count)")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
// Der Abstand gehört in die Spaltenbreite hinein, nicht
// dazu: außen addiert er sich, und die ganze Zeitachse steht
// acht Punkte neben dem Programm darunter.
.padding(.leading, FlixySpacing.snug)
.padding(.leading, HaloSpacing.snug)
.frame(width: channelWidth, alignment: .leading)
.layoutPriority(1)
@@ -125,7 +125,7 @@ struct ProgramGuide: View {
ForEach(layout.ticks, id: \.self) { tick in
Text(tick.formatted(date: .omitted, time: .shortened))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
.frame(width: layout.tickWidth, alignment: .leading)
}
}
@@ -135,7 +135,7 @@ struct ProgramGuide: View {
.clipped()
}
.frame(height: axisHeight)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
}
/// Was auf diesem Sender gerade läuft.
@@ -147,20 +147,20 @@ struct ProgramGuide: View {
VStack(spacing: 0) {
ForEach(channels) { channel in
Button { onTune(channel, running(on: channel)) } label: {
HStack(spacing: FlixySpacing.snug) {
FlixyArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
HStack(spacing: HaloSpacing.snug) {
HaloArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
.frame(width: 30, height: 30)
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 1) {
Text(channel.title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
if let number = channel.number {
Text(number)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
}
}
@@ -170,7 +170,7 @@ struct ProgramGuide: View {
ProgressView().controlSize(.mini)
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.horizontal, HaloSpacing.snug)
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
.overlay(alignment: .bottom) { rowSeparator }
.contentShape(.rect)
@@ -179,7 +179,7 @@ struct ProgramGuide: View {
}
}
.frame(width: channelWidth)
.background(Color.flixyElevated.opacity(0.4))
.background(Color.haloElevated.opacity(0.4))
}
private var lanes: some View {
@@ -217,9 +217,9 @@ struct ProgramGuide: View {
return ZStack(alignment: .topLeading) {
if entries.isEmpty {
Text("liveTV.noProgram")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.padding(.horizontal, FlixySpacing.regular)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.padding(.horizontal, HaloSpacing.regular)
.frame(height: rowHeight, alignment: .leading)
}
@@ -240,7 +240,7 @@ struct ProgramGuide: View {
/// Zeile, die man antippt, gehört dann zum Nachbarsender.
private var rowSeparator: some View {
Rectangle()
.fill(Color.flixySeparator)
.fill(Color.haloSeparator)
.frame(height: 1)
}
@@ -250,21 +250,21 @@ struct ProgramGuide: View {
return Button { selection = GuideSelection(channel: channel, program: program) } label: {
VStack(alignment: .leading, spacing: 2) {
Text(program.title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, 6)
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
.background(
running ? accent.opacity(0.28) : Color.flixyElevated,
in: .rect(cornerRadius: FlixyRadius.control)
running ? accent.opacity(0.28) : Color.haloElevated,
in: .rect(cornerRadius: HaloRadius.control)
)
.overlay(alignment: .bottomLeading) {
// Der Balken zeigt, wie weit die laufende Sendung ist — beim
@@ -299,45 +299,45 @@ private struct ProgramDetailSheet: View {
let onRecord: (() -> Void)?
let onRecordSeries: (() -> Void)?
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
.frame(width: 44, height: 44)
.clipShape(.rect(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(channel.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
Spacer()
}
Text(program.title)
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
if let subtitle = program.subtitle {
Text(subtitle)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
}
if isRunning {
Button { onTune(); dismiss() } label: {
Label("liveTV.tune", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.snug)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
@@ -348,15 +348,15 @@ private struct ProgramDetailSheet: View {
// ohne Aufnahmen fehlt hier schlicht alles.
if program.seriesTimerID != nil {
Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
} else if onRecord != nil || onRecordSeries != nil {
HStack(spacing: FlixySpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
if let onRecord {
Button { onRecord(); dismiss() } label: {
Label("liveTV.record", systemImage: "record.circle")
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.snug)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.bordered)
}
@@ -364,7 +364,7 @@ private struct ProgramDetailSheet: View {
Button { onRecordSeries(); dismiss() } label: {
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.snug)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.bordered)
}
@@ -373,20 +373,20 @@ private struct ProgramDetailSheet: View {
if let summary = program.summary, !summary.isEmpty {
Text(summary)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
}
}
.padding(FlixySpacing.screenMargin)
.padding(HaloSpacing.screenMargin)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.flixyResizableSheet()
.haloResizableSheet()
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Aufnahmen und was noch vorgemerkt ist.
@@ -12,7 +12,7 @@ import MediaKit
/// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine
/// stellt, stellt meist gleich die andere mit.
struct RecordingsView: View {
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@State private var recordings: [LiveRecording] = []
@State private var timers: [LiveTimer] = []
@@ -47,7 +47,7 @@ struct RecordingsView: View {
list
}
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.task { await load() }
.refreshable { await load() }
.confirmationDialog(
@@ -90,7 +90,7 @@ struct RecordingsView: View {
}
}
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
} header: {
Text("liveTV.recordings")
}
@@ -118,23 +118,23 @@ struct RecordingsView: View {
}
}
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
} header: {
Text("liveTV.scheduled")
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.flixyRevealsTopChrome()
.haloRevealsTopChrome()
}
// MARK: - Zeilen
private func row(_ recording: LiveRecording) -> some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120),
aspectRatio: 16 / 9,
fallbackSymbol: "recordingtape",
@@ -145,23 +145,23 @@ struct RecordingsView: View {
VStack(alignment: .leading, spacing: 2) {
Text(recording.title)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
HStack(spacing: FlixySpacing.tight) {
HStack(spacing: HaloSpacing.tight) {
if recording.isInProgress {
// Eine laufende Aufnahme ist keine fertige: sie wächst
// noch, und wer sie jetzt öffnet, sieht ein Ende, das
// nicht das Ende ist.
Label("liveTV.recording.inProgress", systemImage: "record.circle")
.font(.flixyCaption)
.foregroundStyle(Color.flixyDestructive)
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
}
if let subtitle = subtitle(for: recording) {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
.lineLimit(1)
}
}
@@ -175,29 +175,29 @@ struct RecordingsView: View {
private func timerRow(_ timer: LiveTimer) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(timer.title)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
}
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
Image(systemName: "rectangle.stack.badge.record")
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(timer.name)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
}
@@ -1,6 +1,5 @@
import Foundation
import MediaKit
import PlexKit
/// Der Ladezustand eines Bildschirms.
///
@@ -53,20 +52,17 @@ extension LoadState {
/// Ü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
/// Bildschirm eine andere Erklärung.
enum ErrorText {
static func describe(_ error: any Error) -> String {
// Die neutrale Form zuerst, weil die Ansichten nur noch sie sehen.
// Plex' eigener Fehler kommt nur noch dort an, wo unmittelbar mit ihm
// gesprochen wird — Player, Downloads, Live-TV.
// Die neutrale Form zuerst: die Ansichten sehen nur noch sie, und
// jeder Server übersetzt seine eigenen Fehler schon an der Grenze
// seines Moduls dorthin.
if let backendError = error as? MediaBackendError {
return describe(backendError)
}
if let plexError = error as? PlexError {
return describe(plexError.asMediaBackendError)
}
return error.localizedDescription
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// Der erste Buchstabe auf einer Scheibe.
///
@@ -16,7 +16,7 @@ struct AccountAvatar: View {
let name: String
var diameter: CGFloat = 26
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
Text(name.prefix(1).uppercased())
@@ -25,7 +25,7 @@ struct AccountAvatar: View {
// Kleinschrift auf einer grossen Scheibe sieht aus wie ein
// Fehler.
.font(.system(size: diameter * 0.46, weight: .semibold))
.foregroundStyle(Color.flixyCanvas)
.foregroundStyle(Color.haloCanvas)
.frame(width: diameter, height: diameter)
.background(accent, in: .circle)
}
@@ -38,7 +38,7 @@ extension EnvironmentValues {
/// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs.
/// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst
/// als Argument durchgereicht bekommen.
@Entry var flixyOpenSettings: () -> Void = {}
@Entry var haloOpenSettings: () -> Void = {}
}
/// 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
/// steht, kann nur der entscheiden, der die Reihe schreibt.
struct AccountToolbarButton: View {
@Environment(FlixySession.self) private var session
@Environment(\.flixyOpenSettings) private var openSettings
@Environment(HaloSession.self) private var session
@Environment(\.haloOpenSettings) private var openSettings
var body: some View {
Button(action: openSettings) {
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// Wer angemeldet ist — unten in der Seitenleiste.
///
@@ -22,7 +22,7 @@ import FlixyDesign
struct AccountBar: View {
@Binding var selection: SidebarDestination
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@State private var isExpanded = false
@State private var showsSignOutConfirmation = false
@@ -55,7 +55,7 @@ struct AccountBar: View {
}
}
.frame(minWidth: 240)
.background(Color.flixySurface)
.background(Color.haloSurface)
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
// steht trotzdem da, damit aus dem Popover dort kein
// bildschirmfüllendes Sheet wird.
@@ -77,19 +77,19 @@ struct AccountBar: View {
Button {
isExpanded.toggle()
} label: {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
AccountAvatar(name: title)
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.lineLimit(1)
}
}
@@ -102,10 +102,10 @@ struct AccountBar: View {
// Zeigt nach oben: dorthin geht die Karte auf.
Image(systemName: "chevron.up")
.font(.caption2.weight(.semibold))
.foregroundStyle(Color.flixyTextMuted)
.foregroundStyle(Color.haloTextMuted)
}
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.snug)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.snug)
.contentShape(.rect)
}
.buttonStyle(.plain)
@@ -121,18 +121,18 @@ struct AccountBar: View {
) -> some View {
Button(action: perform) {
Label(title, systemImage: symbol)
.font(.flixyBody)
.foregroundStyle(isDestructive ? Color.flixyDestructive : Color.flixyTextPrimary)
.font(.haloBody)
.foregroundStyle(isDestructive ? Color.haloDestructive : Color.haloTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.regular)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.regular)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private var separator: some View {
Divider().overlay(Color.flixySeparator)
Divider().overlay(Color.haloSeparator)
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Wo die Navigation sitzt.
@@ -49,7 +49,7 @@ struct NavigationGroup: Identifiable {
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
/// „umrandet" und „aus Glas".
struct FlixySidebar: View {
struct HaloSidebar: View {
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
let groups: [NavigationGroup]
@@ -57,7 +57,7 @@ struct FlixySidebar: View {
@Binding var placement: NavigationPlacement
let layout: NavigationLayoutSettings
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
@State private var collapsed: Set<String> = []
@@ -80,7 +80,7 @@ struct FlixySidebar: View {
static func surface(in shape: some InsettableShape) -> some View {
shape
.fill(.ultraThinMaterial)
.overlay { shape.fill(Color.flixyCanvas.opacity(0.55)) }
.overlay { shape.fill(Color.haloCanvas.opacity(0.55)) }
.overlay {
shape.strokeBorder(
LinearGradient(
@@ -134,10 +134,10 @@ struct FlixySidebar: View {
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
if isEditing {
Text("sidebar.editHint")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.padding(.horizontal, FlixySpacing.regular)
.padding(.bottom, FlixySpacing.snug)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.padding(.horizontal, HaloSpacing.regular)
.padding(.bottom, HaloSpacing.snug)
List {
ForEach(groups) { group in
@@ -149,8 +149,8 @@ struct FlixySidebar: View {
} header: {
if let title = group.title {
Text(title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
}
.listRowBackground(Color.clear)
@@ -162,13 +162,13 @@ struct FlixySidebar: View {
.environment(\.editMode, .constant(.active))
} else {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
ForEach(groups) { group in
section(visible(group))
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.bottom, FlixySpacing.regular)
.padding(.horizontal, HaloSpacing.snug)
.padding(.bottom, HaloSpacing.regular)
}
.scrollBounceBehavior(.basedOnSize)
.scrollIndicators(.never)
@@ -176,14 +176,14 @@ struct FlixySidebar: View {
Spacer(minLength: 0)
Divider().overlay(Color.flixySeparator)
Divider().overlay(Color.haloSeparator)
AccountBar(selection: $selection)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.snug)
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.snug)
}
.frame(width: Self.width)
.background { Self.surface(in: .rect(cornerRadius: FlixyRadius.panel, style: .continuous)) }
.clipShape(.rect(cornerRadius: FlixyRadius.panel, style: .continuous))
.background { Self.surface(in: .rect(cornerRadius: HaloRadius.panel, style: .continuous)) }
.clipShape(.rect(cornerRadius: HaloRadius.panel, style: .continuous))
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
@@ -194,18 +194,18 @@ struct FlixySidebar: View {
}
private var header: some View {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
Button {
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
} label: {
Text(isEditing ? "action.done" : "sidebar.edit")
.font(.flixyCaption)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.font(.haloCaption)
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.tight)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.foregroundStyle(isEditing ? accent : Color.flixyTextSecondary)
.foregroundStyle(isEditing ? accent : Color.haloTextSecondary)
Spacer(minLength: 0)
@@ -214,21 +214,21 @@ struct FlixySidebar: View {
placement = .top
} label: {
Image(systemName: "rectangle.topthird.inset.filled")
.font(.flixyRowHeader)
.padding(FlixySpacing.tight)
.font(.haloRowHeader)
.padding(HaloSpacing.tight)
// Ohne eigene Klickfläche reicht das Ziel nur so weit
// wie das Zeichen selbst — auf dem Mac trifft man das
// mit dem Zeiger kaum.
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
.accessibilityLabel(Text("sidebar.moveToTop"))
}
}
.padding(.horizontal, FlixySpacing.regular)
.padding(.top, FlixySpacing.regular)
.padding(.bottom, FlixySpacing.snug)
.padding(.horizontal, HaloSpacing.regular)
.padding(.top, HaloSpacing.regular)
.padding(.bottom, HaloSpacing.snug)
}
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
@@ -271,16 +271,16 @@ struct FlixySidebar: View {
} label: {
HStack {
Text(title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Spacer()
Image(systemName: "chevron.down")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
}
.padding(.horizontal, FlixySpacing.regular)
.padding(.top, FlixySpacing.snug)
.padding(.horizontal, HaloSpacing.regular)
.padding(.top, HaloSpacing.snug)
}
.buttonStyle(.plain)
}
@@ -301,12 +301,12 @@ struct FlixySidebar: View {
private func editRow(_ entry: NavigationEntry) -> some View {
let isHidden = layout.isHidden(entry.arrangementID)
return HStack(spacing: FlixySpacing.snug) {
return HStack(spacing: HaloSpacing.snug) {
Button {
layout.setHidden(!isHidden, for: entry.arrangementID)
} label: {
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
.foregroundStyle(isHidden ? Color.flixyTextMuted : accent)
.foregroundStyle(isHidden ? Color.haloTextMuted : accent)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -318,8 +318,8 @@ struct FlixySidebar: View {
.lineLimit(1)
Spacer(minLength: 0)
}
.font(.flixyBody)
.foregroundStyle(isHidden ? Color.flixyTextMuted : Color.flixyTextPrimary)
.font(.haloBody)
.foregroundStyle(isHidden ? Color.haloTextMuted : Color.haloTextPrimary)
}
private func row(_ entry: NavigationEntry) -> some View {
@@ -328,23 +328,23 @@ struct FlixySidebar: View {
return Button {
selection = entry.destination
} label: {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
Image(systemName: entry.symbol)
.font(.flixyBody)
.font(.haloBody)
.frame(width: 22, alignment: .center)
entry.title
.font(.flixyBody)
.font(.haloBody)
// Ein langer Mediathekenname darf die Leiste nicht
// breiter machen; sie hat eine feste Breite.
.lineLimit(1)
Spacer(minLength: 0)
}
.foregroundStyle(isSelected ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.snug)
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.snug)
.background {
if isSelected {
Capsule().fill(Color.flixyElevated)
Capsule().fill(Color.haloElevated)
}
}
.contentShape(Capsule())
@@ -360,29 +360,29 @@ struct FlixySidebar: View {
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
/// hinauswächst, wäre schlechter als ein Menü.
struct FlixyTopBar: View {
struct HaloTopBar: View {
let groups: [NavigationGroup]
@Binding var selection: SidebarDestination
@Binding var placement: NavigationPlacement
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
HStack(spacing: FlixySpacing.tight) {
HStack(spacing: HaloSpacing.tight) {
Button {
placement = .sidebar
} label: {
Image(systemName: "sidebar.leading")
.font(.flixyBody)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.snug)
.font(.haloBody)
.padding(.horizontal, HaloSpacing.regular)
.padding(.vertical, HaloSpacing.snug)
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
// das Zeichen; auf dem Mac war der Umschalter damit
// praktisch nicht zu treffen.
.contentShape(Capsule())
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
.accessibilityLabel(Text("sidebar.moveToSide"))
ForEach(groups) { group in
@@ -395,12 +395,12 @@ struct FlixyTopBar: View {
}
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.snug)
.background { FlixySidebar.surface(in: .capsule) }
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.snug)
.background { HaloSidebar.surface(in: .capsule) }
.clipShape(.capsule)
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
.padding(.top, FlixySpacing.snug)
.padding(.top, HaloSpacing.snug)
}
private func pill(_ entry: NavigationEntry) -> some View {
@@ -410,13 +410,13 @@ struct FlixyTopBar: View {
selection = entry.destination
} label: {
entry.title
.font(.flixyBody)
.font(.haloBody)
.lineLimit(1)
.foregroundStyle(isSelected ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
.background {
if isSelected { Capsule().fill(Color.flixyElevated) }
if isSelected { Capsule().fill(Color.haloElevated) }
}
.contentShape(Capsule())
}
@@ -433,21 +433,21 @@ struct FlixyTopBar: View {
}
}
} label: {
HStack(spacing: FlixySpacing.tight) {
HStack(spacing: HaloSpacing.tight) {
if let title = group.title {
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
// Grund, an einem Wort zu sparen.
Text(title).fixedSize()
}
Image(systemName: "chevron.down").font(.flixyCaption)
Image(systemName: "chevron.down").font(.haloCaption)
}
.font(.flixyBody)
.foregroundStyle(containsSelection ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.font(.haloBody)
.foregroundStyle(containsSelection ? accent : Color.haloTextPrimary)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
.background {
if containsSelection { Capsule().fill(Color.flixyElevated) }
if containsSelection { Capsule().fill(Color.haloElevated) }
}
.contentShape(Capsule())
}
@@ -16,7 +16,7 @@ import MediaKit
struct MediaStack<Content: View>: View {
@ViewBuilder let content: () -> Content
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@State private var path = NavigationPath()
var body: some View {
@@ -31,7 +31,7 @@ struct MediaStack<Content: View>: View {
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
.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
/// 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 FlixyDesign
import HaloDesign
import MediaKit
import FlixyDownloads
import FlixyPlayback
import PlexKit
import HaloDownloads
import HaloPlayback
/// Die Wurzelansicht.
///
@@ -11,8 +10,8 @@ import PlexKit
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
struct RootView: View {
@Environment(FlixySession.self) private var session
@Environment(FlixyTheme.self) private var theme
@Environment(HaloSession.self) private var session
@Environment(HaloTheme.self) private var theme
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(LanguagePreferences.self) private var languages
@@ -36,13 +35,13 @@ struct RootView: View {
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
/// zum Aufbewahren.
@AppStorage("flixy.navigation.customization")
@AppStorage("halo.navigation.customization")
private var customization = TabViewCustomization()
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
///
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
@AppStorage("flixy.navigation.placement")
@AppStorage("halo.navigation.placement")
private var storedPlacement: NavigationPlacement = .sidebar
/// Dieselbe Wahl, aber als Zustand dieser Ansicht.
@@ -86,22 +85,10 @@ struct RootView: View {
case .launching:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
case .signedOut, .authenticating:
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 .signedOut:
SignInView()
case .connecting(let name):
ConnectingView(serverName: name)
@@ -117,7 +104,7 @@ struct RootView: View {
library
}
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.environment(topChrome)
// **Aus dem Fenster, nicht aus dem Layout.**
//
@@ -134,7 +121,7 @@ struct RootView: View {
// Durchgang.
//
// **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.
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
@@ -142,7 +129,7 @@ struct RootView: View {
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
// zuverlässig neu aus, ein Scene-Body nicht.
.flixyAccent(theme)
.haloAccent(theme)
.tint(theme.accentColor)
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
// 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
// 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
// (siehe `TransferHold`). Diese Ansicht kennt beide.
.onChange(of: network.isReachable) { _, erreichbar in
@@ -247,10 +234,11 @@ struct RootView: View {
settings: settings,
languages: languages,
subtitles: subtitleAppearance,
// **Nicht mehr über `server`.** Der ist ein `PlexServer` und bei
// Jellyfin immer `nil` — damit galt dort jede Wiedergabe als
// entfernt, auch zuhause am Kabel, und „Auto" hätte ein
// 67-Mbit-Remux ohne Not heruntergerechnet.
// **Vom Server erfragt, nicht aus der Adresse geschlossen.** Wer
// über einen Reverse Proxy geht, erreicht seinen Server auch von
// der Couch aus unter einem öffentlichen Namen — ihn deshalb als
// entfernt zu führen hiesse, „Auto" würde ein 67-Mbit-Remux
// zuhause am Kabel ohne Not herunterrechnen.
isLocalConnection: session.isLocalConnection,
connectionDescription: session.connectionDescription,
unavailableMessage: session.playbackUnavailableMessage
@@ -301,7 +289,7 @@ struct RootView: View {
.overlay(alignment: .topTrailing) { profileButton }
}
}
.customizationID("flixy.tab.home")
.customizationID("halo.tab.home")
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
@@ -319,12 +307,12 @@ struct RootView: View {
} label: {
sidebarLabel(Text(library.title), symbol: library.kind.symbol)
}
.customizationID("flixy.tab.library.\(library.id)")
.customizationID("halo.tab.library.\(library.id)")
}
} header: {
Text("sidebar.libraries")
}
.customizationID("flixy.tab.libraries")
.customizationID("halo.tab.libraries")
if session.supportsLiveTV {
Tab(value: SidebarDestination.liveTV) {
@@ -332,7 +320,7 @@ struct RootView: View {
} label: {
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
}
.customizationID("flixy.tab.liveTV")
.customizationID("halo.tab.liveTV")
}
} else {
Tab(value: SidebarDestination.libraryHub) {
@@ -340,7 +328,7 @@ struct RootView: View {
} label: {
tabLabel("sidebar.library", symbol: "books.vertical")
}
.customizationID("flixy.tab.libraryHub")
.customizationID("halo.tab.libraryHub")
}
Tab(value: SidebarDestination.downloads) {
@@ -348,7 +336,7 @@ struct RootView: View {
} label: {
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
// Monogramm oben rechts — siehe `profileButton`. Einstellungen
@@ -361,7 +349,7 @@ struct RootView: View {
} label: {
tabLabel("sidebar.settings", symbol: "gearshape")
}
.customizationID("flixy.tab.settings")
.customizationID("halo.tab.settings")
}
// Die Suche nur dort, wo Platz für sie ist.
@@ -374,7 +362,7 @@ struct RootView: View {
Tab(value: SidebarDestination.search, role: .search) {
mediaStack { SearchView() }
}
.customizationID("flixy.tab.search")
.customizationID("halo.tab.search")
}
}
.tabViewStyle(.sidebarAdaptable)
@@ -401,7 +389,7 @@ struct RootView: View {
if sizeClass == .regular {
HStack(spacing: 0) {
if placement == .sidebar {
FlixySidebar(
HaloSidebar(
groups: navigationGroups,
selection: $selection,
placement: $placement.animation(Self.placementAnimation),
@@ -445,7 +433,7 @@ struct RootView: View {
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
.overlay(alignment: .top) {
if placement == .top {
FlixyTopBar(
HaloTopBar(
groups: navigationGroups.map(visible),
selection: $selection,
placement: $placement.animation(Self.placementAnimation)
@@ -485,7 +473,7 @@ struct RootView: View {
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
// statt in einem Bereich, den man erst wieder verlassen muss.
.environment(\.flixyOpenSettings, { showsSettings = true })
.environment(\.haloOpenSettings, { showsSettings = true })
.sheet(isPresented: $showsSettings) {
NavigationStack {
SettingsView()
@@ -493,7 +481,7 @@ struct RootView: View {
// jeder weiss das, und wer in einem Unterbildschirm steht,
// zieht damit gleich alles weg. Ein Knopf sagt es.
.toolbar {
ToolbarItem(placement: .flixyTrailing) {
ToolbarItem(placement: .haloTrailing) {
Button("action.close") { showsSettings = false }
}
}
@@ -527,16 +515,16 @@ struct RootView: View {
showsSettings = true
} label: {
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
// Damit er auch über einem hellen Aufmacher steht.
.background(.black.opacity(0.28), in: .circle)
}
.buttonStyle(.plain)
.padding(.trailing, FlixySpacing.regular)
.padding(.trailing, HaloSpacing.regular)
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
// Knopf um ihre Höhe nach unten, mitten ins Bild.
.padding(.top, FlixySpacing.snug)
.padding(.top, HaloSpacing.snug)
.opacity(topChrome.isRevealed ? 1 : 0)
.allowsHitTesting(topChrome.isRevealed)
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
@@ -555,7 +543,7 @@ struct RootView: View {
/// schwebt nichts, worunter man greifen müsste.
private var topInset: CGFloat {
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] {
@@ -40,7 +40,7 @@ extension View {
/// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht
/// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite
/// weit unten steht.
func flixyRevealsTopChrome() -> some View {
func haloRevealsTopChrome() -> some View {
modifier(RevealsTopChrome())
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// Ein noch nicht gebauter Bereich.
///
@@ -15,9 +15,9 @@ struct PlaceholderScreen: View {
} description: {
Text("placeholder.description")
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(destination.title)
.flixyInlineTitle()
.haloInlineTitle()
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// Die schwebende Videokachel.
///
@@ -51,7 +51,7 @@ struct MiniPlayer: View {
// ist als das Fenster, ließe sich nicht mehr wegziehen.
let upper = max(160, min(bounds.width * 0.75, 560))
let width = min(max(chosen * zoom, 140), upper)
return CGSize(width: width, height: (width / FlixyAspectRatio.landscape).rounded())
return CGSize(width: width, height: (width / HaloAspectRatio.landscape).rounded())
}
var body: some View {
@@ -105,10 +105,10 @@ struct MiniPlayer: View {
}
}
}
.clipShape(.rect(cornerRadius: FlixyRadius.card))
.clipShape(.rect(cornerRadius: HaloRadius.card))
.overlay {
RoundedRectangle(cornerRadius: FlixyRadius.card)
.strokeBorder(Color.flixySeparator, lineWidth: 0.5)
RoundedRectangle(cornerRadius: HaloRadius.card)
.strokeBorder(Color.haloSeparator, lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
.contentShape(.rect)
@@ -158,9 +158,9 @@ struct MiniPlayer: View {
}
}
}
.padding(FlixySpacing.tight)
.padding(HaloSpacing.tight)
// Über dem Fortschrittsbalken, nicht darauf.
.padding(.bottom, FlixySpacing.tight)
.padding(.bottom, HaloSpacing.tight)
}
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// 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.
let onActivity: () -> Void
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
private static let trackHeight: CGFloat = 5
private static let thumbSize: CGFloat = 13
@@ -2,14 +2,8 @@ import AVKit
import Foundation
import MediaKit
import Observation
import FlixyDownloads
import FlixyPlayback
// 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
import HaloDownloads
import HaloPlayback
/// Führt einen Abspielwunsch aus.
///
@@ -162,9 +156,6 @@ final class PlayerModel {
/// das Bild zeichnet: auf dem Telefon hängt die gehaltene `AVPlayerLayer`
/// daran, auf dem Fernseher ein `AVPlayerViewController`. Beide holen sich
/// hier dasselbe ab.
///
/// `nil` bei VLC — kein Sonderfall, sondern dieselbe Auskunft, die
/// `VideoSurface` heute schon auswertet.
var systemPlayer: AVPlayer? { (engine as? AVPlayerEngine)?.player }
/// 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,
/// 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
/// 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
/// kann.
typealias VideoFit = FlixyPlayback.VideoFit
typealias VideoFit = HaloPlayback.VideoFit
/// 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.
@ObservationIgnored private var pictureInPicture: AVPictureInPictureController?
@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.
@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
/// gerade ausgelösten Sprung.
private var seekTarget: Duration?
/// Ob schon einmal auf VLC ausgewichen wurde.
/// Ob schon einmal auf den Transcoder ausgewichen wurde.
private var hasFallenBack = false
/// Ob der Vorratsspeicher für diese Wiedergabe aufgegeben wurde.
///
@@ -428,9 +416,6 @@ final class PlayerModel {
guard let ceiling = automaticCeiling ?? startingCeiling else { return base }
return PlaybackPolicy(
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),
forcesTranscoding: true
)
@@ -747,8 +732,8 @@ final class PlayerModel {
videoCodec: offline?.videoCodec,
audioCodec: offline?.audioCodec,
bitrate: offline?.bitrate,
// Was heruntergeladen wurde, ist untersucht. Ohne diese Zusage
// ginge jede Offline-Datei vorsichtshalber an VLC.
// Was heruntergeladen wurde, ist untersucht — und seit das
// Herunterladen bei Bedarf umpackt, auch zu öffnen.
hasKnownStreams: offline?.videoCodec != nil
),
// Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze.
@@ -769,10 +754,6 @@ final class PlayerModel {
let engine = makeEngine(for: decision)
self.engine = 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)
// **Vor dem Öffnen.** Ein frisch angelegter Gegenstand ist
// ungestaltet, und der erste Untertitel käme sonst in der Vorgabe.
@@ -1196,18 +1177,24 @@ final class PlayerModel {
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.
private func fallBackToVLC() async -> Bool {
guard !hasFallenBack, policy.allowsFallbackEngine else { return false }
private func fallBackToTranscode() async -> Bool {
let geltend = effectivePolicy
guard !hasFallenBack, geltend.allowsTranscoding else { return false }
hasFallenBack = true
diagnostics.note("Umpacken abgelehnt — noch einmal mit VLC")
diagnostics.note("Umpacken abgelehnt — noch einmal, diesmal umgerechnet")
await restart(forcing: PlaybackDecision(
method: .directPlay(engine: .vlc),
rationale: (decision?.rationale ?? []) + [.transcodingUnavailable]
method: .transcode(maximumBitrate: geltend.maximumBitrate),
rationale: decision?.rationale ?? []
))
if case .failed = phase { return false }
return true
@@ -1541,6 +1528,26 @@ final class PlayerModel {
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) {
engine?.selectAudioTrack(track)
selectedAudioTrack = track
@@ -1578,8 +1585,11 @@ final class PlayerModel {
streamServer = nil
streamCache = nil
prefetchStatus = nil
pictureInPictureObservation = nil
pictureInPicture = nil
pictureInPictureDelegate = nil
canUsePictureInPicture = false
isPictureInPictureActive = false
surface.playerLayer.player = nil
isPlaying = false
isBuffering = false
@@ -1726,14 +1736,10 @@ final class PlayerModel {
/// Eine eigene Funktion, weil es zwei Anlässe gibt: die Wahl des Nutzers
/// und jeder Engine-Wechsel. Beide müssen dasselbe tun.
///
/// **Zwei Zeilen, weil es zwei Engines gibt.** Die Bildebene gehört der
/// App und nicht dem Systemplayer — deshalb steht `videoGravity` hier und
/// 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.
/// Die Bildebene gehört der App und nicht dem Systemplayer — deshalb
/// steht `videoGravity` hier und nicht in `AVPlayerEngine`.
func applyVideoFit() {
surface.playerLayer.videoGravity = videoFit == .fill ? .resizeAspectFill : .resizeAspect
engine?.setVideoFit(videoFit)
}
/// 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.
private func attachSurface(to engine: any VideoPlayerEngine) {
guard let avEngine = engine as? AVPlayerEngine else { return }
surface.playerLayer.player = avEngine.player
// **Den Zustand lesen, keine Konstante.** Hier stand fest
// `.resizeAspect` — und weil diese Zeile nach jedem Neuaufbau erneut
@@ -1766,6 +1771,32 @@ final class PlayerModel {
controller?.delegate = bridge
pictureInPictureDelegate = bridge
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
}
/// **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 {
#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()
#endif
}
private func observe(_ engine: any VideoPlayerEngine) {
@@ -1812,19 +1837,17 @@ final class PlayerModel {
if case .failed(let failure) = state {
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
// die Ströme wirklich kopieren kann — und manche
// Antworten kann AVPlayer nicht annehmen, auch wenn
// sie formal richtig sind. Das hier vorher zu wissen
// ist nicht möglich; es hinterher aufzufangen schon.
// Er entscheidet erst beim Ausliefern, ob er die
// Ströme wirklich kopieren kann — und manche Antworten
// kann AVPlayer nicht annehmen, auch wenn sie formal
// richtig sind. Das vorher zu wissen ist nicht
// möglich; es hinterher aufzufangen schon.
//
// Ohne diesen Weg stünde der Nutzer vor einer
// schwarzen Wand mit einer Fehlernummer, obwohl ein
// Player danebensteht, der die Datei anstandslos
// spielt.
if case .remux = decision?.method, await fallBackToVLC() { return }
// schwarzen Wand mit einer Fehlernummer.
if case .remux = decision?.method, await fallBackToTranscode() { return }
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
// Der Grund gehört auf den Bildschirm, nicht nur ins
@@ -1,8 +1,7 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import PlexKit
import HaloDesign
import HaloDownloads
import HaloPlayback
/// Der Vollbildplayer.
struct PlayerView: View {
@@ -14,14 +13,14 @@ struct PlayerView: View {
/// Nutzers statt eine Nebenwirkung der Navigation.
let model: PlayerModel
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(SubtitleAppearance.self) private var subtitleAppearance
@Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackDiagnostics.self) private var diagnostics
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
/// Woran sich Quer- und Hochformat unterscheiden lassen.
///
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
@@ -73,7 +72,7 @@ struct PlayerView: View {
/// Knopf.
@State private var isFullScreen = false
/// 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.
///
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
@@ -201,7 +200,7 @@ struct PlayerView: View {
}
.opacity(zeigt ? 1 : 0)
.allowsHitTesting(zeigt)
.padding(FlixySpacing.loose)
.padding(HaloSpacing.loose)
// Siehe `controls(for:)`: die Kopfzeile lag sonst hinter
// der Dynamic Island, und der Zurück-Pfeil war tot.
.padding(.top, safeArea.top)
@@ -218,7 +217,7 @@ struct PlayerView: View {
// ein zweiter Klick auf dieselbe Stelle legte das Fenster
// ins Dock, weil das der Doppelklick auf eine
// 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.
.sheet(item: $trackPicker) { kind in
TrackPickerSheet(model: model, kind: kind)
.environment(\.flixyAccent, accent)
.environment(\.haloAccent, accent)
}
.sheet(isPresented: $showsQualityChoice) {
QualitySheet(model: model)
.environment(settings)
.environment(\.flixyAccent, accent)
.environment(\.haloAccent, accent)
}
.onChange(of: showsQualityChoice) { _, isShown in
// Nach dem Schließen die Bedienelemente stehen lassen und die
@@ -372,7 +371,7 @@ struct PlayerView: View {
Spacer()
scrubber(for: model)
}
.padding(FlixySpacing.loose)
.padding(HaloSpacing.loose)
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
// soll. Ersetzte eines das andere, klebte der Schieberegler im
@@ -396,7 +395,7 @@ struct PlayerView: 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
// Zweck der Übung: weiterschauen, während man stöbert.
Button {
@@ -404,7 +403,7 @@ struct PlayerView: View {
} label: {
Image(systemName: "chevron.down")
.font(.title3)
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
}
.accessibilityLabel(Text("player.minimize"))
@@ -415,17 +414,17 @@ struct PlayerView: View {
} label: {
Image(systemName: "xmark")
.font(.callout.weight(.semibold))
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
}
.accessibilityLabel(Text("player.stop"))
VStack(alignment: .leading, spacing: 2) {
Text(model.title)
.font(.flixyCardTitle)
.font(.haloCardTitle)
.lineLimit(1)
if let subtitle = model.subtitle {
Text(subtitle)
.font(.flixyCaption)
.font(.haloCaption)
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
}
@@ -448,7 +447,7 @@ struct PlayerView: View {
? "arrow.down.right.and.arrow.up.left"
: "arrow.up.left.and.arrow.down.right")
.font(.callout.weight(.semibold))
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
}
.accessibilityLabel(Text(isFullScreen ? "player.exitFullScreen" : "player.fullScreen"))
.keyboardShortcut("f", modifiers: [.command, .control])
@@ -466,7 +465,7 @@ struct PlayerView: View {
? "arrow.down.right.and.arrow.up.left"
: "arrow.up.left.and.arrow.down.right")
.font(.title3)
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
}
.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.
Image(systemName: "arrow.triangle.2.circlepath")
.font(.title3)
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
}
.accessibilityLabel(Text("player.rotate"))
}
@@ -498,7 +497,7 @@ struct PlayerView: View {
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
// Film. Ein gemeinsames Sheet bedeutete jedes Mal Scrollen an der
// anderen Liste vorbei.
if !model.audioTracks.isEmpty {
if model.hasAudioChoice {
trackButton(
symbol: "waveform",
label: "player.audioTracks",
@@ -506,7 +505,7 @@ struct PlayerView: View {
)
}
if !model.subtitleTracks.isEmpty {
if model.hasSubtitleChoice {
trackButton(
symbol: model.selectedSubtitleTrack == nil
? "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 {
AirPlayButton(tint: accent)
.frame(width: 28, height: 28)
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
.accessibilityLabel(Text("player.airPlay"))
}
@@ -541,7 +554,7 @@ struct PlayerView: View {
} label: {
Image(systemName: symbol)
.font(.title3)
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
}
.accessibilityLabel(Text(label))
}
@@ -565,15 +578,15 @@ struct PlayerView: View {
// sagen nicht, was eingestellt ist; „1080p" schon — und genau
// das ist die Frage, wegen der man hier hintippt.
Text(model.qualityLabel)
.font(.flixyCaption.weight(.semibold))
.font(.haloCaption.weight(.semibold))
.monospacedDigit()
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.padding(.horizontal, HaloSpacing.snug)
.padding(.vertical, HaloSpacing.tight)
.overlay {
RoundedRectangle(cornerRadius: FlixyRadius.card)
RoundedRectangle(cornerRadius: HaloRadius.card)
.strokeBorder(.white.opacity(0.5), lineWidth: 1)
}
.padding(FlixySpacing.snug)
.padding(HaloSpacing.snug)
}
}
.disabled(model.isChangingQuality)
@@ -581,7 +594,7 @@ struct PlayerView: View {
}
private func transport(for model: PlayerModel) -> some View {
HStack(spacing: FlixySpacing.section * 2) {
HStack(spacing: HaloSpacing.section * 2) {
Button {
model.skip(by: .seconds(-settings.rewindSeconds))
scheduleControlsHide()
@@ -639,7 +652,7 @@ struct PlayerView: View {
}
private func scrubber(for model: PlayerModel) -> some View {
VStack(spacing: FlixySpacing.tight) {
VStack(spacing: HaloSpacing.tight) {
PlaybackScrubber(
position: model.currentTime,
duration: model.duration ?? .seconds(1),
@@ -682,9 +695,9 @@ struct PlayerView: View {
}
} label: {
Label("player.nextEpisode", systemImage: "forward.end.fill")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.regular)
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
@@ -693,21 +706,21 @@ struct PlayerView: View {
model.skipActiveMarker()
} label: {
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.regular)
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.regular)
}
.buttonStyle(.bordered)
.tint(.white)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.padding(.horizontal, FlixySpacing.section)
.padding(.horizontal, HaloSpacing.section)
.padding(.leading, safeArea.leading)
.padding(.trailing, safeArea.trailing)
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
// 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.hasReachedEnd)
@@ -718,10 +731,10 @@ struct PlayerView: View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: FlixySpacing.regular) {
VStack(spacing: HaloSpacing.regular) {
ProgressView().tint(.white)
Text("player.restoring")
.font(.flixyCardTitle)
.font(.haloCardTitle)
.foregroundStyle(.white)
// Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass
// niemand denkt, er müsse sie wiederfinden.
@@ -734,45 +747,33 @@ struct PlayerView: View {
}
private func preparing(for model: PlayerModel) -> some View {
VStack(spacing: FlixySpacing.loose) {
VStack(spacing: HaloSpacing.loose) {
ProgressView().tint(.white)
Text(model.title)
.font(.flixyCardTitle)
.font(.haloCardTitle)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("player.preparing")
.font(.flixyCaption)
.font(.haloCaption)
.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 {
VStack(spacing: FlixySpacing.loose) {
VStack(spacing: HaloSpacing.loose) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.white.opacity(0.8))
Text(message)
.font(.flixyBody)
.font(.haloBody)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Button("action.close") { playback.close() }
.buttonStyle(.borderedProminent)
.tint(accent)
}
.padding(FlixySpacing.section)
.padding(HaloSpacing.section)
}
/// Die beiden Flächen zum Springen.
@@ -820,7 +821,7 @@ struct PlayerView: View {
.labelStyle(.iconOnly)
.font(.system(size: 44))
.foregroundStyle(.white)
.padding(FlixySpacing.section)
.padding(HaloSpacing.section)
.background(.black.opacity(0.35), in: .circle)
.transition(.opacity.combined(with: .scale))
}
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import HaloDesign
import HaloPlayback
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
///
@@ -11,7 +11,7 @@ import FlixyPlayback
struct QualitySheet: View {
let model: PlayerModel
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
/// **Als Fragezeichen**, nicht als Behauptung.
///
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
@@ -62,14 +62,14 @@ struct QualitySheet: View {
}
}
.navigationTitle(Text("player.quality"))
.flixyInlineTitle()
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.done") { dismiss() }
}
}
}
.flixyResizableSheet()
.haloResizableSheet()
}
/// Was gerade läuft — gemessen, nicht gewünscht.
@@ -158,11 +158,11 @@ struct QualitySheet: View {
} label: {
HStack {
Text(label(for: option))
.foregroundStyle(Color.flixyTextPrimary)
.foregroundStyle(Color.haloTextPrimary)
if let detail {
Text(detail)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
Spacer()
if option == model.quality {
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import HaloDesign
import HaloPlayback
import MediaKit
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
@@ -27,12 +27,25 @@ struct TrackPickerSheet: View {
let kind: Kind
@Environment(\.dismiss) private var dismiss
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
NavigationStack {
List {
switch kind {
case .audio where model.isOffline:
// **Offline zählt, was in der Datei liegt.** Die Liste des
// Servers gibt es hier nicht: `chooseAudioStream` läuft nur
// auf dem Online-Weg. Solange sie trotzdem gefragt wurde,
// blieb die Auswahl für heruntergeladene Titel leer — der
// Knopf war da, das Sheet zeigte nichts.
ForEach(model.audioTracks) { track in
row(title: track.displayName, isSelected: model.selectedAudioTrack == track) {
model.selectAudioTrack(track)
dismiss()
}
}
case .audio:
// **Die Spuren der Datei, nicht die des Players.** Rechnet
// der Server um, kennt der Player genau eine — die, für
@@ -55,7 +68,7 @@ struct TrackPickerSheet: View {
} label: {
Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward")
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
} header: {
Text("player.rememberedTrack")
} 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:
// „Aus" gehört in dieselbe Liste wie die Spuren, nicht als
// eigener Schalter daneben: es ist eine Auswahl unter
@@ -92,23 +121,23 @@ struct TrackPickerSheet: View {
}
}
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text(kind.title))
.flixyInlineTitle()
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.flixyMediumSheet()
.haloMediumSheet()
}
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text(title)
.foregroundStyle(Color.flixyTextPrimary)
.foregroundStyle(Color.haloTextPrimary)
Spacer()
if isSelected {
Image(systemName: "checkmark").foregroundStyle(accent)
@@ -117,7 +146,7 @@ struct TrackPickerSheet: View {
.contentShape(.rect)
}
.buttonStyle(.plain)
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
}
}
@@ -1,16 +1,11 @@
import AVKit
import SwiftUI
import FlixyPlayback
import HaloPlayback
#if canImport(UIKit)
import UIKit
#else
import AppKit
#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.
///
@@ -22,12 +17,7 @@ typealias PlatformView = UIView
typealias PlatformView = NSView
#endif
/// Die Bildfläche des laufendie 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`.
/// Die Bildfläche der laufenden Engine.
struct VideoSurface: View {
let engine: (any VideoPlayerEngine)?
/// Die Bildebene des Systemplayers, gehalten vom Modell.
@@ -36,8 +26,6 @@ struct VideoSurface: View {
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
/// VLC macht es seit jeher richtig und reicht seine `videoView` durch;
/// hier ist es jetzt derselbe Weg.
let surface: PlayerLayerView
/// Ob **diese** Ansicht die Bildfläche gerade haben soll.
@@ -61,21 +49,11 @@ struct VideoSurface: View {
var bottomInset: CGFloat = 0
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 {
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
} else {
Color.black
}
#endif
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Sucht über alle Mediatheken des Servers.
@@ -8,7 +8,7 @@ import MediaKit
/// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server
/// stellt. Siehe dort, warum das kein Beiwerk ist.
struct SearchView: View {
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@State private var text = ""
@State private var feed = SearchFeed()
@@ -23,9 +23,9 @@ struct SearchView: View {
results
}
}
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("search.title"))
.flixyInlineTitle()
.haloInlineTitle()
.searchable(text: $text)
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
@@ -35,12 +35,12 @@ struct SearchView: View {
// in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus
// dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah
// aus, als wäre nichts passiert.
.environment(\.flixyItemChanged, { await feed.search(text, from: session.backend) })
.environment(\.haloItemChanged, { await feed.search(text, from: session.backend) })
}
private var results: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
ForEach(feed.hubs) { hub in
// **Antippen führt auf die Seite, nicht in den Film.** Die
// Suche benutzt dieselbe Reihe wie die Startseite — und
@@ -51,9 +51,9 @@ struct SearchView: View {
MediaRow(row: hub, opensDetails: true)
}
}
.padding(.vertical, FlixySpacing.loose)
.padding(.vertical, HaloSpacing.loose)
}
.flixyRevealsTopChrome()
.haloRevealsTopChrome()
}
@ViewBuilder
@@ -72,7 +72,7 @@ struct SearchView: View {
text = entry
} label: {
Label(entry, systemImage: "clock.arrow.circlepath")
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
}
}
} header: {
@@ -81,12 +81,12 @@ struct SearchView: View {
Button("search.forgetRecent", role: .destructive) {
feed.forgetRecent()
}
.font(.flixyCaption)
.font(.haloCaption)
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
}
.scrollContentBackground(.hidden)
.flixyRevealsTopChrome()
.haloRevealsTopChrome()
}
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// Die Farbeinstellungen.
///
@@ -8,22 +8,22 @@ import FlixyDesign
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
/// stillschweigend zu verändern.
struct AppearanceView: View {
@Environment(FlixyTheme.self) private var theme
@Environment(HaloTheme.self) private var theme
@Environment(HomeLayoutSettings.self) private var layout
@State private var customHex: String = ""
@State private var showsCustomField = false
private let columns = [GridItem(.adaptive(minimum: 64), spacing: FlixySpacing.regular)]
private let columns = [GridItem(.adaptive(minimum: 64), spacing: HaloSpacing.regular)]
var body: some View {
Form {
Section {
LazyVGrid(columns: columns, spacing: FlixySpacing.regular) {
LazyVGrid(columns: columns, spacing: HaloSpacing.regular) {
ForEach(AccentOption.palette) { option in
swatch(option)
}
}
.padding(.vertical, FlixySpacing.snug)
.padding(.vertical, HaloSpacing.snug)
} header: {
Text("appearance.accent")
} footer: {
@@ -38,7 +38,7 @@ struct AppearanceView: View {
LabeledContent {
Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0))))
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
.monospacedDigit()
} label: {
Text("appearance.cardScale")
@@ -52,7 +52,7 @@ struct AppearanceView: View {
} footer: {
Text("appearance.cardScaleExplanation")
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
Section {
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
@@ -60,7 +60,7 @@ struct AppearanceView: View {
if showsCustomField {
HStack {
TextField("appearance.hexPlaceholder", text: $customHex)
.flixyPlainEntry()
.haloPlainEntry()
.onSubmit(applyCustomColor)
Button("action.apply", action: applyCustomColor)
@@ -69,8 +69,8 @@ struct AppearanceView: View {
if !customHex.isEmpty, RGB(string: customHex) == nil {
Text("appearance.invalidHex")
.font(.flixyCaption)
.foregroundStyle(Color.flixyDestructive)
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
}
}
@@ -80,11 +80,11 @@ struct AppearanceView: View {
// als im Farbwähler, und niemand wüsste warum.
Label {
Text("appearance.adjusted \(theme.accent.hexString)")
.font(.flixyCaption)
.font(.haloCaption)
} icon: {
Image(systemName: "info.circle")
}
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
}
}
@@ -92,11 +92,11 @@ struct AppearanceView: View {
PreviewRow()
}
}
.flixyForm()
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("appearance.title"))
.flixyInlineTitle()
.haloInlineTitle()
.onAppear {
if theme.selection.id == AccentOption.customIdentifier {
customHex = theme.selection.color.hexString
@@ -119,13 +119,13 @@ struct AppearanceView: View {
// Dunkel auf der Farbe, nicht weiß: die Palette
// enthält helle Töne wie Amber, auf denen Weiß
// verschwinden würde.
.foregroundStyle(Color.flixyCanvas)
.foregroundStyle(Color.haloCanvas)
}
}
.overlay {
Circle()
.strokeBorder(
theme.selection == option ? Color.flixyTextPrimary : .clear,
theme.selection == option ? Color.haloTextPrimary : .clear,
lineWidth: 2
)
.padding(-4)
@@ -144,27 +144,27 @@ struct AppearanceView: View {
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
private struct PreviewRow: View {
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
HStack(spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
Label("hero.play", systemImage: "play.fill")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.background(accent, in: .rect(cornerRadius: FlixyRadius.control))
.foregroundStyle(Color.flixyCanvas)
.font(.haloCardTitle)
.padding(.horizontal, HaloSpacing.loose)
.padding(.vertical, HaloSpacing.snug)
.background(accent, in: .rect(cornerRadius: HaloRadius.control))
.foregroundStyle(Color.haloCanvas)
Label("sidebar.downloads", systemImage: "arrow.down.circle")
.font(.flixyCardTitle)
.font(.haloCardTitle)
.foregroundStyle(accent)
}
WatchProgressBar(fraction: 0.45)
}
.padding(.vertical, FlixySpacing.snug)
.listRowBackground(Color.flixySurface)
.padding(.vertical, HaloSpacing.snug)
.listRowBackground(Color.haloSurface)
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
///
@@ -23,15 +23,15 @@ struct AudioLanguagePicker: View {
let title: LocalizedStringKey
@Environment(LanguagePreferences.self) private var languages
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
HStack(spacing: FlixySpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
Text(title)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
Spacer(minLength: FlixySpacing.regular)
Spacer(minLength: HaloSpacing.regular)
Menu {
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
@@ -53,11 +53,11 @@ struct AudioLanguagePicker: View {
}
}
} label: {
HStack(spacing: FlixySpacing.tight) {
HStack(spacing: HaloSpacing.tight) {
Text(currentLabel)
.font(.flixyBody)
.font(.haloBody)
Image(systemName: "chevron.up.chevron.down")
.font(.flixyCaption)
.font(.haloCaption)
}
.foregroundStyle(accent)
}
@@ -1,6 +1,5 @@
import SwiftUI
import FlixyDesign
import PlexKit
import HaloDesign
/// Was beim letzten Abspielversuch passiert ist.
///
@@ -8,29 +7,24 @@ import PlexKit
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
struct DiagnosticsView: View {
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(PlaybackDiagnostics.self) private var diagnostics
@State private var liveTVStructure: String?
@State private var isInspectingLiveTV = false
var body: some View {
Form {
Section("diagnostics.connection") {
if let server = session.server {
LabeledContent("settings.serverName", value: server.name)
LabeledContent("settings.address", value: server.connection.uri)
.font(.flixyCaption)
if server.connection.relay {
// Der häufigste Grund dafür, dass nichts anläuft. Ohne
// Hinweis sucht man den Fehler in der App.
Label("diagnostics.relayWarning", systemImage: "exclamationmark.triangle")
.font(.flixyCaption)
.foregroundStyle(Color.flixyDestructive)
if session.backend != nil {
LabeledContent("settings.serverName", value: session.serverName)
if let address = session.serverAddress {
LabeledContent("settings.address", value: address)
.font(.haloCaption)
}
if let beschreibung = session.connectionDescription {
LabeledContent("settings.connection", value: beschreibung)
.font(.haloCaption)
}
} else {
Text("diagnostics.noServer").foregroundStyle(Color.flixyTextMuted)
Text("diagnostics.noServer").foregroundStyle(Color.haloTextMuted)
}
}
@@ -42,11 +36,11 @@ struct DiagnosticsView: View {
VStack(alignment: .leading, spacing: 2) {
Text("diagnostics.source")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Text(attempt.sourceDescription)
.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
Text(step)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
}
}
Section {
Button {
FlixyClipboard.copy(report(for: attempt))
HaloClipboard.copy(report(for: attempt))
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
}
@@ -69,40 +63,10 @@ struct DiagnosticsView: View {
}
} else {
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") {
LabeledContent("settings.version", value: Branding.version)
LabeledContent("settings.build", value: Branding.buildStamp)
@@ -115,19 +79,11 @@ struct DiagnosticsView: View {
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent)
.flixyForm()
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("diagnostics.title"))
.flixyInlineTitle()
}
/// 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()
.haloInlineTitle()
}
/// Der Bericht als Text zum Weitergeben.
@@ -136,7 +92,7 @@ struct DiagnosticsView: View {
/// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches.
private func report(for attempt: PlaybackDiagnostics.Attempt) -> String {
"""
Flixy \(Branding.version) · Build \(Branding.buildStamp)
Halo \(Branding.version) · Build \(Branding.buildStamp)
Verbindung: \(attempt.connection)
Titel: \(attempt.title)
Weg: \(attempt.method)
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import HaloDesign
import HaloDownloads
import MediaKit
/// Was beim Herunterladen gilt.
@@ -11,7 +11,7 @@ import MediaKit
/// rutschen.
struct DownloadSettingsView: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
@Bindable var downloads = downloads
@@ -73,11 +73,11 @@ struct DownloadSettingsView: View {
}
}
.tint(accent)
.flixyForm()
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("sidebar.downloads"))
.flixyInlineTitle()
.haloInlineTitle()
}
private var storageLimitBinding: Binding<Int64> {
@@ -1,11 +1,11 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
/// Reihen der Startseite ein- und ausblenden und umsortieren.
struct HomeLayoutView: View {
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(HomeLayoutSettings.self) private var layout
@State private var rows: [HomeRow] = []
@@ -21,9 +21,9 @@ struct HomeLayoutView: View {
var body: some View {
list
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("homeLayout.title"))
.flixyInlineTitle()
.haloInlineTitle()
.task { await load() }
}
@@ -48,7 +48,7 @@ struct HomeLayoutView: View {
// gerade so weit hereinlugt, wie man es mag.
LabeledContent {
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
.monospacedDigit()
} label: {
Text("home.heroHeight")
@@ -64,7 +64,7 @@ struct HomeLayoutView: View {
} footer: {
Text("home.heroExplanation")
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
@@ -84,7 +84,7 @@ struct HomeLayoutView: View {
}
} label: {
Text(library.title)
.foregroundStyle(Color.flixyTextPrimary)
.foregroundStyle(Color.haloTextPrimary)
}
// Damit der ganze Streifen trifft und nicht nur
// die Schrift — eine Zeile, bei der nur ein Wort
@@ -99,7 +99,7 @@ struct HomeLayoutView: View {
} header: {
Text("home.heroLibraries")
} footer: {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
Text("home.heroLibrariesExplanation")
// Lieber eine Erklärung als ein Aufmacher, der
// heimlich etwas anderes zeigt: trifft die Auswahl
@@ -107,18 +107,18 @@ struct HomeLayoutView: View {
// dann muss dastehen, warum.
if greiftInsLeere {
Text("home.heroLibrariesNoMatch")
.foregroundStyle(Color.flixyDestructive)
.foregroundStyle(Color.haloDestructive)
}
}
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
}
Section {
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
if isLoading {
ProgressView()
@@ -126,8 +126,8 @@ struct HomeLayoutView: View {
.listRowBackground(Color.clear)
} else if rows.isEmpty {
Text("homeLayout.empty")
.foregroundStyle(Color.flixyTextSecondary)
.listRowBackground(Color.flixySurface)
.foregroundStyle(Color.haloTextSecondary)
.listRowBackground(Color.haloSurface)
}
Section {
@@ -140,9 +140,9 @@ struct HomeLayoutView: View {
}
)) {
Text(row.title)
.foregroundStyle(Color.flixyTextPrimary)
.foregroundStyle(Color.haloTextPrimary)
}
.listRowBackground(Color.flixySurface)
.listRowBackground(Color.haloSurface)
}
.onMove { indices, destination in
rows.move(fromOffsets: indices, toOffset: destination)
@@ -162,7 +162,7 @@ struct HomeLayoutView: View {
if !rows.isEmpty {
Spacer()
EditButton()
.font(.flixyCaption)
.font(.haloCaption)
.textCase(nil)
}
#endif
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// Welche Sprachen der Player von sich aus wählen soll.
///
@@ -9,7 +9,7 @@ import FlixyDesign
/// Vorliebe, die niemand äußern kann, ist keine.
struct LanguagesView: View {
@Environment(LanguagePreferences.self) private var settings
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
@State private var isAdding = false
@@ -61,11 +61,11 @@ struct LanguagesView: View {
}
}
.tint(accent)
.flixyForm()
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("languages.title"))
.flixyInlineTitle()
.haloInlineTitle()
// Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht
// das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form`
// genügt, sobald die Liste bearbeitbar ist.
@@ -86,19 +86,19 @@ struct LanguagesView: View {
Section {
if settings.fallbacks.isEmpty {
Text("languages.fallbackEmpty")
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
} else {
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
// ist — eine Liste ohne sichtbaren Rang liest sich
// wie eine Menge.
Text("\(position + 1)")
.font(.flixyCaption.monospacedDigit())
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption.monospacedDigit())
.foregroundStyle(Color.haloTextSecondary)
.frame(minWidth: 18, alignment: .trailing)
Text(name(of: code))
.foregroundStyle(Color.flixyTextPrimary)
.foregroundStyle(Color.haloTextPrimary)
}
}
.onMove { von, nach in
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import HaloDesign
import HaloPlayback
/// Alles, was die Wiedergabe betrifft.
///
@@ -9,7 +9,7 @@ import FlixyPlayback
/// einmal durchgeht und dann in Ruhe lässt.
struct PlaybackSettingsView: View {
@Environment(PlaybackSettings.self) private var settings
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
@Bindable var settings = settings
@@ -36,7 +36,7 @@ struct PlaybackSettingsView: View {
} header: {
Text("settings.quality")
} footer: {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
Text("settings.qualityExplanation")
// **Nur mit Beleg.** Eine Warnung, die immer dasteht,
@@ -55,8 +55,8 @@ struct PlaybackSettingsView: View {
} icon: {
Image(systemName: "exclamationmark.triangle")
}
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
}
@@ -108,11 +108,11 @@ struct PlaybackSettingsView: View {
}
}
.tint(accent)
.flixyForm()
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("settings.playback"))
.flixyInlineTitle()
.haloInlineTitle()
}
private func rateLabel(_ rate: Double) -> String {
@@ -1,27 +1,25 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import MediaKit
import PlexKit
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
struct ServerSettingsView: View {
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
var body: some View {
Form {
Section("settings.server") {
LabeledContent("settings.serverName", value: session.serverName)
// Welche Art von Server — bei zwei möglichen ist das keine
// Nebensache mehr, sondern die erste Frage bei jeder
// Rückmeldung.
LabeledContent("settings.serverKind", value: kindLabel)
if let connection = session.server?.connection {
LabeledContent("settings.connection", value: connectionLabel(connection))
// Nah oder fern: daran hängt, ob „Auto" gedeckelt anfängt,
// und damit die erste Frage bei jeder Rückmeldung über
// Ruckeln.
if session.backend != nil {
LabeledContent("settings.connection", value: connectionLabel)
}
if let address = session.serverAddress {
LabeledContent("settings.address", value: address)
.font(.flixyCaption)
.font(.haloCaption)
.textSelection(.enabled)
}
if let accountName = session.accountName {
@@ -46,27 +44,19 @@ struct ServerSettingsView: View {
}
}
.tint(accent)
.flixyForm()
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("settings.server"))
.flixyInlineTitle()
.haloInlineTitle()
}
private var kindLabel: String {
switch session.kind {
case .plex: "Plex"
case .jellyfin: "Jellyfin"
case nil: "—"
}
}
/// 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")
/// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
/// ist das die erste Frage.
private var connectionLabel: String {
session.isLocalConnection
? String(localized: "settings.connectionLocal")
: String(localized: "settings.connectionRemote")
}
}
@@ -74,20 +64,20 @@ struct ServerSettingsView: View {
struct LibraryRow: View {
let library: MediaLibrary
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@State private var phase: Phase = .idle
private enum Phase { case idle, running, done }
var body: some View {
LabeledContent {
HStack(spacing: FlixySpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
// „Filme" stand sonst als „Filme — Filme" da.
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
Text(library.kind.label)
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
}
if session.backend?.capabilities.contains(.libraryScan) == true {
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// Das farbige Symbol vor einer Einstellungszeile.
///
@@ -35,7 +35,7 @@ struct SettingsHeader: View {
let explanation: LocalizedStringKey
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Image(systemName: symbol)
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
@@ -43,16 +43,16 @@ struct SettingsHeader: View {
.background(colour.gradient, in: .rect(cornerRadius: 13))
Text(title)
.font(.flixyTitle)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloTitle)
.foregroundStyle(Color.haloTextPrimary)
Text(explanation)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, FlixySpacing.snug)
.listRowBackground(Color.flixySurface)
.padding(.vertical, HaloSpacing.snug)
.listRowBackground(Color.haloSurface)
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
/// 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.
struct SettingsView: View {
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@State private var showsSignOutConfirmation = false
var body: some View {
@@ -23,7 +23,7 @@ struct SettingsView: View {
} label: {
LabeledContent {
Text(session.serverName)
.foregroundStyle(Color.flixyTextSecondary)
.foregroundStyle(Color.haloTextSecondary)
} label: {
row(
"settings.server",
@@ -83,12 +83,12 @@ struct SettingsView: View {
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent)
.flixyForm()
.haloForm()
.scrollContentBackground(.hidden)
.flixyRevealsTopChrome()
.background(Color.flixyCanvas)
.haloRevealsTopChrome()
.background(Color.haloCanvas)
.navigationTitle(Text("sidebar.settings"))
.flixyInlineTitle()
.haloInlineTitle()
.confirmationDialog(
Text("settings.signOutConfirm"),
isPresented: $showsSignOutConfirmation,
@@ -115,7 +115,7 @@ struct SettingsView: 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)
Text(title)
}
@@ -1,11 +1,11 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import HaloDesign
import HaloPlayback
/// Wie Untertitel aussehen sollen.
struct SubtitleAppearanceView: View {
@Environment(SubtitleAppearance.self) private var appearance
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
var body: some View {
@Bindable var appearance = appearance
@@ -51,11 +51,11 @@ struct SubtitleAppearanceView: View {
}
}
.tint(accent)
.flixyForm()
.haloForm()
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("subtitleStyle.title"))
.flixyInlineTitle()
.haloInlineTitle()
}
/// Ein Beispielsatz über grauer Fläche.
@@ -84,10 +84,10 @@ struct SubtitleAppearanceView: View {
radius: 2,
y: 1
)
.padding(FlixySpacing.regular)
.padding(HaloSpacing.regular)
}
.frame(height: 96)
.clipShape(.rect(cornerRadius: FlixyRadius.card))
.clipShape(.rect(cornerRadius: HaloRadius.card))
.listRowInsets(EdgeInsets())
.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)
}
}
@@ -1,5 +1,5 @@
import SwiftUI
import FlixyDesign
import HaloDesign
import JellyfinKit
/// 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
/// durch diese App läuft.
struct JellyfinSignInView: View {
@Environment(FlixySession.self) private var session
@Environment(HaloSession.self) private var session
@Environment(\.dismiss) private var dismiss
@Environment(\.flixyAccent) private var accent
@Environment(\.haloAccent) private var accent
@State private var model: JellyfinSignInModel?
@FocusState private var focusedField: Field?
@@ -23,16 +23,16 @@ struct JellyfinSignInView: View {
ScrollView {
if let model {
content(for: model)
.padding(.horizontal, FlixySpacing.section)
.padding(.vertical, FlixySpacing.section)
.padding(.horizontal, HaloSpacing.section)
.padding(.vertical, HaloSpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity)
}
}
.scrollDismissesKeyboard(.interactively)
.background(Color.flixyCanvas)
.background(Color.haloCanvas)
.navigationTitle(Text("signIn.jellyfin.title"))
.flixyInlineTitle()
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") {
@@ -66,7 +66,7 @@ struct JellyfinSignInView: View {
private func content(for model: JellyfinSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlixySpacing.section) {
VStack(alignment: .leading, spacing: HaloSpacing.section) {
switch model.step {
case .address:
addressStep(model: model)
@@ -82,8 +82,8 @@ struct JellyfinSignInView: View {
if let message = model.errorMessage {
Label(message, systemImage: "exclamationmark.triangle")
.font(.flixyCaption)
.foregroundStyle(Color.flixyDestructive)
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
}
}
}
@@ -94,39 +94,39 @@ struct JellyfinSignInView: View {
private func addressStep(model: JellyfinSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
Text("signIn.jellyfin.addressPrompt")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
.textFieldStyle(.plain)
.textContentType(.URL)
.flixyPlainEntry(isAddress: true)
.haloPlainEntry(isAddress: true)
.submitLabel(.go)
.focused($focusedField, equals: .address)
.onSubmit { Task { await model.findServer() } }
// Hier statt beim Öffnen des Sheets: so bekommt das Feld den
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
.task { focusedField = .address }
.padding(FlixySpacing.regular)
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
.foregroundStyle(Color.flixyTextPrimary)
.padding(HaloSpacing.regular)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
.foregroundStyle(Color.haloTextPrimary)
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
// niemandem etwas, „192.168.1.50" jedem.
Text("signIn.jellyfin.addressHint")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Button {
focusedField = nil
Task { await model.findServer() }
} label: {
Text("signIn.jellyfin.connect")
.font(.flixyRowHeader)
.font(.haloRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.regular)
.padding(.vertical, HaloSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
@@ -138,40 +138,40 @@ struct JellyfinSignInView: View {
@ViewBuilder
private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
foundServerHeader(model: model)
Text("signIn.jellyfin.quickConnectHint")
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
Text(code)
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
.tracking(8)
.foregroundStyle(Color.flixyTextPrimary)
.foregroundStyle(Color.haloTextPrimary)
.textSelection(.enabled)
.padding(.vertical, FlixySpacing.regular)
.padding(.vertical, HaloSpacing.regular)
.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)
Text("signIn.jellyfin.waitingForApproval")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
Text("signIn.jellyfin.privacy")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Divider().overlay(Color.flixySeparator)
Divider().overlay(Color.haloSeparator)
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
.font(.flixyCaption)
.font(.haloCaption)
Button("signIn.jellyfin.otherAddress") { model.startOver() }
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
@@ -181,12 +181,12 @@ struct JellyfinSignInView: View {
private func credentialsStep(model: JellyfinSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
foundServerHeader(model: model)
TextField("signIn.jellyfin.userName", text: $model.userName)
.textContentType(.username)
.flixyPlainEntry()
.haloPlainEntry()
.submitLabel(.next)
.focused($focusedField, equals: .userName)
.onSubmit { focusedField = .password }
@@ -200,8 +200,8 @@ struct JellyfinSignInView: View {
focusedField = .password
return .handled
}
.padding(FlixySpacing.regular)
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
.padding(HaloSpacing.regular)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
SecureField("signIn.jellyfin.password", text: $model.password)
.textContentType(.password)
@@ -214,17 +214,17 @@ struct JellyfinSignInView: View {
focusedField = .userName
return .handled
}
.padding(FlixySpacing.regular)
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
.padding(HaloSpacing.regular)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
Button {
focusedField = nil
Task { await model.signInWithPassword() }
} label: {
Text("signIn.jellyfin.submit")
.font(.flixyRowHeader)
.font(.haloRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.regular)
.padding(.vertical, HaloSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
@@ -234,46 +234,46 @@ struct JellyfinSignInView: View {
// verworfen. Gespeichert wird nur der Token, und der im
// Schlüsselbund.
Text("signIn.jellyfin.passwordNote")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
Divider().overlay(Color.flixySeparator)
Divider().overlay(Color.haloSeparator)
if model.offersQuickConnect {
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
.font(.flixyCaption)
.font(.haloCaption)
}
Button("signIn.jellyfin.otherAddress") { model.startOver() }
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
.foregroundStyle(Color.flixyTextPrimary)
.foregroundStyle(Color.haloTextPrimary)
}
// MARK: - Bausteine
@ViewBuilder
private func foundServerHeader(model: JellyfinSignInModel) -> some View {
VStack(alignment: .leading, spacing: FlixySpacing.tight) {
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
Text("signIn.jellyfin.foundServer")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.textCase(.uppercase)
Text(model.serverName ?? model.address)
.font(.flixyTitle)
.foregroundStyle(Color.flixyTextPrimary)
.font(.haloTitle)
.foregroundStyle(Color.haloTextPrimary)
}
}
@ViewBuilder
private func waiting(text: LocalizedStringKey) -> some View {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: HaloSpacing.snug) {
ProgressView().controlSize(.small)
Text(text)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, FlixySpacing.section)
.padding(.vertical, HaloSpacing.section)
}
}
+67
View File
@@ -0,0 +1,67 @@
import SwiftUI
import HaloDesign
/// Der Anmeldebildschirm.
///
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
///
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
struct SignInView: View {
@Environment(\.haloAccent) private var accent
@State private var showsJellyfinSignIn = false
var body: some View {
VStack(spacing: HaloSpacing.section) {
Spacer()
VStack(spacing: HaloSpacing.regular) {
Image(systemName: "play.rectangle.on.rectangle")
.font(.system(size: 52))
.foregroundStyle(accent)
Text(Branding.appName)
.font(.haloHero)
.foregroundStyle(Color.haloTextPrimary)
Text("signIn.tagline")
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
.multilineTextAlignment(.center)
}
Spacer()
VStack(spacing: HaloSpacing.regular) {
Button {
showsJellyfinSignIn = true
} label: {
Text("signIn.withJellyfin")
.font(.haloRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
Text("signIn.privacy")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.multilineTextAlignment(.center)
}
Spacer().frame(height: HaloSpacing.section)
}
.padding(.horizontal, HaloSpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.haloCanvas)
.sheet(isPresented: $showsJellyfinSignIn) {
JellyfinSignInView()
}
}
}
@@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.scarriffleservices.flixy</string>
<string>group.com.scarriffleservices.halo</string>
</array>
</dict>
</plist>
@@ -1,13 +1,12 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import HaloDesign
import HaloPlayback
import MediaKit
import JellyfinKit
import PlexKit
/// 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
/// die Bedienart: kein Finger, keine Grössenklasse, keine Werkzeugleiste, kein
/// 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
/// 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
/// 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
/// fällt beides weg.
@main
struct FlixyTVApp: App {
@State private var session: FlixySession
@State private var theme = FlixyTheme()
struct HaloTVApp: App {
@State private var session: HaloSession
@State private var theme = HaloTheme()
@State private var playback = PlaybackCoordinator()
@State private var playbackSettings = PlaybackSettings()
@State private var languages = LanguagePreferences()
@@ -41,11 +40,7 @@ struct FlixyTVApp: App {
@State private var network = NetworkConditions()
init() {
_session = State(initialValue: FlixySession(
identity: PlexClientIdentity.current(
product: Branding.plexProduct,
version: Branding.version
),
_session = State(initialValue: HaloSession(
jellyfinIdentity: JellyfinIdentity.current(
client: Branding.appName,
version: Branding.version
@@ -67,7 +62,7 @@ struct FlixyTVApp: App {
.environment(homeLayout)
.environment(diagnostics)
.environment(network)
.flixyAccent(theme)
.haloAccent(theme)
.tint(theme.accentColor)
// Ein Fernseher ist immer dunkel. Der Schalter in der
// Info.plist gilt fürs System, diese Zeile für die Vorschauen.
+4 -4
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Flixy</string>
<string>Halo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -22,10 +22,10 @@
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.scarriffleservices.flixy.topshelf</string>
<string>com.scarriffleservices.halo.topshelf</string>
<key>CFBundleURLSchemes</key>
<array>
<string>flixy</string>
<string>halo</string>
</array>
</dict>
</array>
@@ -39,7 +39,7 @@
<true/>
</dict>
<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>
<string>Dark</string>
</dict>

Some files were not shown because too many files have changed in this diff Show More