Flixy heißt ab hier Halo

Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

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

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

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

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

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

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

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
+79
View File
@@ -0,0 +1,79 @@
import Foundation
import HaloDownloads
import MediaKit
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
///
/// Die Regel-Engine rechnet aus, *wie viele* Folgen je Serie fehlen; hier wird
/// entschieden, *welche*. Die Trennung ist Absicht: das Rechnen braucht keinen
/// Server und ist deshalb prüfbar, das Auswählen braucht ihn zwangsläufig.
@MainActor
struct DownloadRefiller {
let backend: (any MediaBackend)?
let coordinator: DownloadCoordinator
/// Lädt nach, was den Regeln nach fehlt.
///
/// Läuft nach dem Ansehen einer Folge und beim Start — nicht auf einem
/// Zeitgeber. Eine App, die im Hintergrund unaufgefordert Gigabytes lädt,
/// wäre eine unangenehme Überraschung auf der Mobilfunkrechnung.
@discardableResult
func refill() async -> Int {
guard let backend, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 }
let needed = coordinator.rules.seriesNeedingRefill(in: coordinator.items)
guard !needed.isEmpty else { return 0 }
var added = 0
let service = DownloadService(backend: backend, coordinator: coordinator)
for (seriesKey, count) in needed {
let candidates = await nextUnwatchedEpisodes(
inSeries: seriesKey,
count: count,
backend: backend
)
for episode in candidates {
// Die Kinderliste bringt keine Dateiangaben mit — ohne sie
// ließe sich weder das Speicherlimit prüfen noch ein
// Fortschritt anzeigen.
let detailed = (try? await backend.item(id: episode.id)) ?? episode
// Folgen ohne abrufbare Datei still überspringen: das Nachladen
// läuft im Hintergrund, da hilft keine Fehlermeldung.
guard let plan = try? service.plan(for: detailed) else { continue }
if (try? coordinator.enqueue(plan)) != nil { added += 1 }
}
}
return added
}
/// Die nächsten ungesehenen Folgen einer Serie, die noch nicht offline sind.
///
/// In Ausstrahlungsreihenfolge, nicht nach Beliebtheit oder Zufall: wer
/// drei Folgen vorhalten lässt, will die drei, die als Nächstes dran sind.
private func nextUnwatchedEpisodes(
inSeries seriesKey: String,
count: Int,
backend: any MediaBackend
) async -> [MediaItem] {
guard let seasons = try? await backend.children(of: seriesKey) else { return [] }
let offlineKeys = Set(coordinator.items.map(\.ratingKey))
var result: [MediaItem] = []
for season in seasons.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
// Specials führt Plex als Staffel 0. Sie gehören nicht in den
// Vorrat für „was kommt als Nächstes".
guard (season.index ?? 0) > 0 else { continue }
guard let episodes = try? await backend.children(of: season.id) else { continue }
for episode in episodes.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
guard !episode.isWatched, !offlineKeys.contains(episode.id) else { continue }
result.append(episode)
if result.count >= count { return result }
}
}
return result
}
}
+383
View File
@@ -0,0 +1,383 @@
import Foundation
import HaloDownloads
import HaloPlayback
import MediaKit
/// Übersetzt Titel vom Server in Download-Aufträge.
///
/// Die Trennung ist Absicht: `DownloadCoordinator` kennt nur Adressen, Bytes
/// und Zustände — nichts von Plex. Dadurch bleibt die Engine ohne Server
/// testbar, und dieser Typ ist die einzige Stelle, die beides kennt.
/// In welcher Qualität heruntergeladen wird.
enum DownloadQuality: String, CaseIterable, Identifiable {
/// Die Datei, wie sie auf dem Server liegt.
case original
case fullHD
case hd
case low
var id: String { rawValue }
var maximumBitrate: Int? {
switch self {
case .original: nil
case .fullHD: 8_000
case .hd: 4_000
case .low: 2_000
}
}
var resolution: String? {
switch self {
case .original: nil
case .fullHD: "1920x1080"
case .hd: "1280x720"
case .low: "854x480"
}
}
/// Die Bildhöhe, auf die diese Stufe herunterrechnet.
var pixelHeight: Int? {
switch self {
case .original: nil
case .fullHD: 1080
case .hd: 720
case .low: 480
}
}
/// Welche Stufen für eine Datei überhaupt sinnvoll sind.
///
/// Eine 720p-Datei auf „Full HD" hochzurechnen bringt nichts außer einer
/// größeren Datei — und im Auswahldialog standen dann drei Einträge mit
/// derselben geschätzten Größe untereinander, was aussah, als wäre die
/// Schätzung kaputt. Sie war es nicht; die Stufen waren es.
static func available(forSourceHeight height: Int?) -> [DownloadQuality] {
guard let height else { return allCases }
// Zehn Prozent Spielraum: 1080p-Dateien sind oft 1072 oder 1088 hoch.
let threshold = Double(height) * 0.9
return allCases.filter { quality in
guard let target = quality.pixelHeight else { return true }
return Double(target) < threshold
}
}
/// Grobe Schätzung der Dateigröße bei dieser Stufe.
///
/// Aus Bitrate mal Laufzeit. Nicht genau — der Transcoder arbeitet mit
/// variabler Bitrate —, aber genau genug für die Frage, ob es aufs Gerät
/// passt. Bei `original` steht die echte Größe zur Verfügung.
func estimatedBytes(durationMilliseconds: Int?, originalBytes: Int64) -> Int64 {
guard let maximumBitrate, let durationMilliseconds, durationMilliseconds > 0 else {
return originalBytes
}
let seconds = Double(durationMilliseconds) / 1000
let estimate = Int64(Double(maximumBitrate) * 1000 / 8 * seconds)
// Nie mehr schätzen als das Original groß ist.
return originalBytes > 0 ? min(estimate, originalBytes) : estimate
}
}
@MainActor
struct DownloadService {
let backend: (any MediaBackend)?
let coordinator: DownloadCoordinator
/// Welche Sprache gewünscht ist — nur fürs Umpacken.
///
/// **Weil beim Umpacken eine Tonspur übrig bleibt.** Online wählt man im
/// Player; offline gibt es nach dem Herunterladen nichts mehr zu wählen,
/// also fällt die Entscheidung hier. Dieselbe Abwägung wie beim Abspielen
/// (`AudioTrackChooser`) — eine zweite Regel liefe irgendwann auseinander.
///
/// Optional, damit Aufrufer ohne Spracheinstellungen weiter bauen: dann
/// gilt, was der Server als Standard meldet.
var languages: LanguagePreferences?
/// Baut den Auftrag für einen einzelnen Titel.
///
/// Wirft mit Begründung, statt still `nil` zurückzugeben. Der Unterschied
/// ist nicht akademisch: mit `nil` sah der Download-Knopf aus, als täte er
/// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server
/// noch nicht analysiert hat.
func plan(
for item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original,
audioStream: MediaStream? = nil
) throws(DownloadRefusal) -> DownloadPlan {
guard let backend else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
}
let media = item.versions.indices.contains(mediaIndex)
? item.versions[mediaIndex]
: item.preferredVersion
guard let media else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia"))
}
guard let part = media.files.first else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
}
// **Was offline liegt, muss der Systemplayer öffnen können.** Online
// springt für eine Matroska das Umpacken ein; auf dem Gerät gibt es
// hinterher niemanden mehr, der das nachholen könnte — und seit es
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
// schwarz. Also wird schon beim Herunterladen umgepackt.
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
let url: URL?
switch quality {
case .original where umpacken:
// **Gewählt schlägt geraten.** Hier stand nur die Abwägung aus
// `AudioTrackChooser` — und die hat bei einer Aufnahme mit
// Audiodeskription die falsche Spur genommen, ohne zu fragen.
// Nach dem Download gibt es nichts mehr zu korrigieren; drei
// Gigabyte sind dann weg. Jetzt entscheidet, wer gefragt wurde,
// und die Abwägung ist nur noch der Vorschlag.
let ton = audioStream ?? preferredAudioStream(in: part, of: item)
let untertitel = part.subtitleStreams.first { $0.isSelected }
?? part.subtitleStreams.first
url = backend.remuxDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
audioStreamIndex: ton?.index,
subtitleStreamIndex: untertitel?.index
) ?? backend.downloadURL(for: part)
case .original:
url = backend.downloadURL(for: part)
case .fullHD, .hd, .low:
// Über den Universal-Transcoder. NOCH NICHT AM SERVER GEPRÜFT:
// offen ist, ob Plex eine Content-Length mitschickt — ohne sie
// gibt es keinen Fortschrittsbalken und kein Fortsetzen.
url = backend.compressedDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
maximumBitrate: quality.maximumBitrate ?? 8_000,
resolution: quality.resolution
)
}
guard let url else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noURL"))
}
return DownloadPlan(
ratingKey: item.id,
title: item.rowTitle,
subtitle: item.downloadSubtitle,
sourceURL: url,
seriesKey: item.seriesID,
seriesTitle: item.seriesTitle,
seasonKey: item.parentID,
seasonIndex: item.parentIndex,
episodeIndex: item.index,
durationMilliseconds: item.duration,
expectedBytes: quality.estimatedBytes(
durationMilliseconds: item.duration,
originalBytes: part.size ?? 0
),
// **Nur beim Original ist die Größe eine Tatsache.** Sie steht in
// den Metadaten des Servers. Die umgerechneten Stufen sind aus
// Bitrate und Dauer hochgerechnet — und was hochgerechnet ist,
// darf abweichen, ohne dass die Datei kaputt wäre.
// Umgepackt stimmt die Größe des Servers nicht mehr: dieselben
// Ströme in einer anderen Hülle wiegen anders. Dann ist sie wieder
// eine Schätzung — und die weiche Prüfung die richtige.
expectedIsExact: quality == .original && !umpacken && part.size != nil,
offlineMedia: offlineMedia(for: item, media: media, part: part, remuxed: umpacken)
)
}
/// Lädt einen einzelnen Titel.
@discardableResult
func download(
_ item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original,
audioStream: MediaStream? = nil
) throws(DownloadRefusal) -> DownloadItem {
try coordinator.enqueue(
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
)
}
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
/// werden muss.
///
/// **Die Frage gehört vor den Download, nicht danach.** Wird umgepackt,
/// legt der Server genau eine Tonspur in die neue Hülle; was nicht
/// mitkommt, ist offline nicht mehr zu holen. Automatisch zu wählen hiess
/// hier: dem Nutzer die Entscheidung abnehmen und ihn mit dem Ergebnis
/// leben lassen — bei einer Aufnahme mit Audiodeskription genau der
/// falsche Weg.
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
guard quality == .original else { return false }
guard let media = mediaIndex < item.versions.count
? item.versions[mediaIndex] : item.preferredVersion,
let part = media.files.first
else { return false }
return !systemPlayerOpens(media: media, part: part) && part.audioStreams.count > 1
}
/// Was ein Stapel ergeben hat.
typealias BatchResult = (
accepted: Int,
refused: [(MediaItem, DownloadRefusal)],
unavailable: Int
)
/// Lädt alle Folgen einer Staffel oder Serie.
func downloadAll(
in container: MediaItem,
quality: DownloadQuality = .original
) async -> BatchResult {
guard let backend else { return (0, [], 0) }
guard let episodes = try? await allEpisodes(in: container, backend: backend) else {
return (0, [], 0)
}
return await download(episodes, quality: quality)
}
/// Lädt genau die übergebenen Folgen.
///
/// **Holt die Metadaten jeder Folge einzeln nach.** Die Kinderliste einer
/// Staffel enthält keine Dateiangaben; ohne Dateigröße ließe sich weder
/// das Speicherlimit prüfen noch ein Fortschrittsbalken zeigen — und eine
/// erwartete Größe von null überspringt die Prüfung auf freien Platz
/// stillschweigend.
func download(
_ episodes: [MediaItem],
quality: DownloadQuality = .original,
audioStreamIndex: Int? = nil
) async -> BatchResult {
guard let backend else { return (0, [], 0) }
var accepted = 0
var refused: [(MediaItem, DownloadRefusal)] = []
var unavailable = 0
for episode in episodes {
// Folgen, die schon offline sind, werden übersprungen statt
// abgelehnt — bei einer Serie, von der die Hälfte geladen ist,
// wäre eine Liste aus zwanzig Fehlern keine Hilfe.
if coordinator.items.contains(where: { $0.ratingKey == episode.id }) {
continue
}
let detailed = (try? await backend.item(id: episode.id)) ?? episode
do {
// Die Nummer, nicht die Spur: innerhalb einer Staffel sind
// die Spuren gleich angeordnet, und die Folge selbst kennt
// ihre eigene Liste.
let ton = audioStreamIndex.flatMap { nummer in
detailed.preferredVersion?.files.first?.audioStreams
.first { $0.index == nummer }
}
_ = try coordinator.enqueue(
try plan(for: detailed, quality: quality, audioStream: ton)
)
accepted += 1
} catch DownloadRefusal.noDownloadableFile {
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
// werden am Ende nur gezählt.
unavailable += 1
} catch {
refused.append((episode, error))
// Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen
// noch einzeln durchzuprobieren — sie scheitern alle gleich.
if case .storageLimitReached = error { break }
if case .insufficientDeviceSpace = error { break }
}
}
return (accepted, refused, unavailable)
}
private func allEpisodes(in container: MediaItem, backend: any MediaBackend) async throws -> [MediaItem] {
switch container.kind {
case .season:
return try await backend.children(of: container.id)
case .show:
var episodes: [MediaItem] = []
for season in try await backend.children(of: container.id) {
episodes.append(contentsOf: try await backend.children(of: season.id))
}
return episodes
default:
return [container]
}
}
/// Der Abzug der Serverangaben, den der Player offline braucht.
/// Welche Tonspur vorgeschlagen wird.
///
/// **Die gemerkte der Serie zuerst.** Wer eine Folge schon einmal auf
/// Deutsch gesehen hat, will sie nicht auf Englisch herunterladen — und
/// die App merkt sich diese Entscheidung ohnehin schon fürs Abspielen
/// (`LanguagePreferences.rememberedTrack`). Dieselbe Abwägung also, über
/// denselben Weg: `wish` trägt das Gemerkte, die Serieneinstellung und
/// die allgemeine Sprache in dieser Reihenfolge.
///
/// Die Mediathek bleibt aussen vor: sie nachzuschlagen kostet eine
/// Anfrage je Folge, und die Ebenen darüber tragen den Wunsch ohnehin.
func preferredAudioStream(in part: MediaFile, of item: MediaItem) -> MediaStream? {
guard let languages else { return part.selectedAudioStream }
return AudioTrackChooser.choose(
from: part.streams,
wish: languages.wish(seriesID: item.seriesID, libraryID: nil)
) ?? part.selectedAudioStream
}
/// Ob AVFoundation die Datei so, wie sie auf dem Server liegt, öffnet.
private func systemPlayerOpens(media: MediaVersion, part: MediaFile) -> Bool {
DeviceCapabilities.avFoundation.opens(
container: part.container ?? media.container,
videoCodec: media.videoCodec,
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec
)
}
private func offlineMedia(
for item: MediaItem,
media: MediaVersion,
part: MediaFile,
remuxed: Bool
) -> OfflineMedia {
OfflineMedia(
// Umgepackt liegt eine MP4 auf dem Gerät, keine Matroska. Stünde
// hier weiter die alte Schachtel, entschiede der Player anhand
// einer Datei, die es so nicht mehr gibt.
container: remuxed ? "mp4" : (part.container ?? media.container),
videoCodec: media.videoCodec,
// **Beim Umpacken wissen wir es nicht.** Der Server rechnet nur
// um, was nicht durchpasst — ob aus DTS dann AAC oder AC3 wird,
// sagt er vorher nicht. Eine Behauptung wäre schlechter als die
// Lücke: der Player liest die Spuren ohnehin aus der Datei.
audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec),
videoResolution: media.videoResolution,
bitrate: media.bitrate,
// **Beim Umpacken bleibt je eine übrig.** Alle zu notieren wäre
// ein Versprechen, das die Datei nicht hält — und offline ist es
// nicht nachprüfbar, bevor der Player sie öffnet.
audioLanguages: remuxed ? [] : part.audioStreams.compactMap(\.language),
subtitleLanguages: remuxed ? [] : part.subtitleStreams.compactMap(\.language),
markers: item.markers.map {
OfflineMarker(
type: $0.kind.rawValue,
startMilliseconds: $0.startMilliseconds,
endMilliseconds: $0.endMilliseconds
)
}
)
}
}
extension MediaItem {
/// Die Zeile unter dem Titel in der Downloads-Ansicht.
var downloadSubtitle: String? {
if kind == .episode, let season = parentIndex, let episode = index {
return "\(String(localized: "episode.short \(season) \(episode)")) · \(title)"
}
return year.map(String.init)
}
}
+403
View File
@@ -0,0 +1,403 @@
import Foundation
import JellyfinKit
import MediaKit
import Observation
/// Der Zustand der Verbindung zum Server.
///
/// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß,
/// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort,
/// an dem sich entscheidet, was auf dem Bildschirm erscheint.
///
/// Seit es zwei Serverarten gibt, hält diese Klasse **einen** aktiven Server,
/// nicht zwei. Zwei gleichzeitig hießen: zwei Startseiten, zwei Mediatheken,
/// zwei Suchergebnisse — und bei jedem Titel die Frage, woher er stammt.
/// Umschalten ja, nebeneinander nein.
@MainActor
@Observable
final class HaloSession {
enum Phase: Equatable {
/// Beim Start: liegt ein Token im Schlüsselbund?
case launching
case signedOut
/// Ein Weg zum Server wird gesucht.
case connecting(serverName: String)
case ready
case failed(message: String)
// Kein eigener ==-Operator mehr: er war nur nötig, weil `PlexResource`
// nicht `Equatable` war und über die Kennung verglichen werden musste.
// Alle verbliebenen Nutzlasten sind `String`, also leitet der Compiler
// die Gleichheit selbst ab — und ein künftiger Fall wird nicht
// stillschweigend als „ungleich" behandelt.
}
private enum Key {
static let jellyfinAddress = "halo.jellyfin.address"
static let jellyfinUserID = "halo.jellyfin.userID"
static let jellyfinUserName = "halo.jellyfin.userName"
static let jellyfinServerName = "halo.jellyfin.serverName"
static let jellyfinIsAdministrator = "halo.jellyfin.isAdministrator"
}
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
///
/// Nicht in den Voreinstellungen: es gibt vollen Zugriff auf die
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
/// später daneben passt, ohne diesen zu verdrängen.
private static let jellyfinTokenAccount = "jellyfin.server"
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
///
/// **Diese Namen stehen sonst nirgends mehr.** Mit dem Plex-Code ging auch
/// das Einzige, was die beiden Token je wieder entfernt hätte — wer sich
/// nie abgemeldet hat, trüge ein gültiges plex.tv-Token bis in alle
/// Ewigkeit im Schlüsselbund, ohne dass irgendein Weg es noch löschen
/// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
/// verschwinden.
private enum Altlast {
static let merker = "halo.migration.plexPurged"
static let tokenKonten = ["plex.account", "plex.server"]
static let schlüssel = [
"halo.backend.kind",
"halo.server.clientIdentifier",
"halo.server.connectionURI",
"halo.server.name",
"halo.server.connectionIsLocal",
]
}
/// Ob der Server in **demselben** Netz steht wie wir.
///
/// Entscheidet, ob „Auto" gedeckelt anfängt: zuhause trägt die Leitung
/// das Original, unterwegs nicht. Der Server wird danach gefragt, weil
/// ein Blick auf die Adresse nicht genügt — wer über einen Reverse Proxy geht, erreicht seinen Server
/// auch von der Couch aus unter einem öffentlichen Namen.
///
/// Im Zweifel `false`: lieber einmal zu vorsichtig gedeckelt als eine
/// Wiedergabe, die über eine Fernverbindung gar nicht erst anläuft.
private(set) var isLocalConnection = false
private(set) var phase: Phase = .launching
/// Der aktive Server hinter dem neutralen Vertrag.
///
/// Die Ansichten sprechen ausschließlich hierüber und sehen nicht, wer
/// dahintersteckt. Dass dieser Vertrag einmal zwei Server gleichzeitig
/// getragen hat, ist kein Zufall, sondern der Grund, warum für einen
/// weiteren keine einzige Ansicht neu geschrieben werden muss.
private(set) var backend: (any MediaBackend)?
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
var accountName: String? { jellyfinAccount?.userName }
/// Die Mediatheken, die Halo darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
private(set) var libraries: [MediaLibrary] = []
/// Wie der Server heißt, so wie er sich selbst nennt.
var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird.
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
/// Wie die Verbindung zum Server zu beschreiben ist.
///
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
/// die Sitzung. Nah oder fern ist dabei die Angabe, die etwas erklärt —
/// an ihr hängt, ob „Auto" gedeckelt anfängt, und damit die Hälfte aller
/// Fragen nach Rucklern. Die blosse Adresse steht schon eine Zeile höher.
var connectionDescription: String? {
guard let serverAddress else { return nil }
return isLocalConnection
? "Lokal · \(serverAddress)"
: "Von außen · \(serverAddress)"
}
/// Ob der aktive Server Live-TV anbietet.
///
/// Die Fähigkeit selbst, nicht der Umweg über die Serverart. Einen
/// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine
/// Zusage, die niemand einlöst — so gibt sie nur, wer sie auch einlöst.
var supportsLiveTV: Bool { backend?.capabilities.contains(.liveTV) == true }
/// Ob er darüber hinaus aufnehmen kann.
///
/// Getrennt gefragt: ein Server kann empfangen, ohne aufnehmen zu können.
var supportsRecordings: Bool { backend?.capabilities.contains(.liveTVRecordings) == true }
/// Der Server als Live-TV-Server — sofern er einer ist.
var liveTV: (any LiveTVBackend)? { backend as? any LiveTVBackend }
/// Der Server als Aufnahme-Server — sofern er einer ist.
var recorder: (any LiveTVRecordingBackend)? { backend as? any LiveTVRecordingBackend }
/// Warum gerade nicht abgespielt werden kann.
///
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
///
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
var playbackUnavailableMessage: String {
backend != nil
? String(localized: "player.jellyfinPending")
: String(localized: "error.serverUnreachable")
}
private let jellyfinIdentity: JellyfinIdentity
private let tokenStore: any TokenStoring
private let defaults: UserDefaults
init(
jellyfinIdentity: JellyfinIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.jellyfinIdentity = jellyfinIdentity
self.tokenStore = tokenStore
self.defaults = defaults
}
// MARK: - Start
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
func restore() async {
purgePlexLeftovers()
#if DEBUG
if await adoptStubServerIfRequested() { return }
#endif
if await restoreJellyfin() { return }
phase = .signedOut
}
/// Räumt einmal weg, was die Plex-Zeit hinterlassen hat.
///
/// **Der einzige Weg, auf dem das je wieder passiert.** Vorher entfernte
/// `signOut()` die Plex-Token über den Authenticator; den gibt es nicht
/// mehr, und `plex.account` stünde sonst in keiner Zeile dieses Projekts.
/// Wer bis heute angemeldet war und nie auf „Abmelden" gedrückt hat,
/// behielte ein gültiges Token am fremden Konto — unsichtbar und
/// unlöschbar.
///
/// Der Merker wird **vor** der Prüfung gesetzt: ein Aufräumen, das
/// scheitert, soll nicht bei jedem Start wiederkehren.
private func purgePlexLeftovers() {
guard !defaults.bool(forKey: Altlast.merker) else { return }
defaults.set(true, forKey: Altlast.merker)
// Nur anfassen, wo auch etwas liegt. Sonst verlöre jeder
// Jellyfin-Nutzer beim ersten Start seinen Mediatheken-Speicher für
// nichts.
let hattePlex = Altlast.tokenKonten.contains { tokenStore.token(for: $0) != nil }
|| Altlast.schlüssel.contains { defaults.object(forKey: $0) != nil }
guard hattePlex else { return }
for konto in Altlast.tokenKonten { tokenStore.remove(for: konto) }
for schlüssel in Altlast.schlüssel { defaults.removeObject(forKey: schlüssel) }
// Titel und Sehfortschritt gehörten zu einem Konto, das es hier nicht
// mehr gibt. Gelesen würden sie ohnehin nie wieder.
Task { await LibraryCache.shared.clearAll() }
}
/// Meldet ab und räumt dabei alles ab, was zur Anmeldung gehörte.
///
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
func signOut() {
forgetJellyfin()
clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
// verloren.
Task { await LibraryCache.shared.clearAll() }
phase = .signedOut
}
// MARK: - Verbinden mit Jellyfin
/// Übernimmt eine fertige Jellyfin-Anmeldung.
///
/// Die Anmeldung selbst führt `JellyfinSignInModel` — sie besteht aus
/// mehreren Schritten mit eigener Bedienung (Adresse, Code, Rückfall auf
/// Passwort) und gehört deshalb in ein eigenes Modell. Hier kommt nur das
/// Ergebnis an: ein Konto und ein Weg dorthin.
#if DEBUG
/// Meldet sich an der Attrappe aus `Tools/JellyfinStub` an.
///
/// Nur in der Entwicklung und nur auf ausdrückliches Kommando:
/// `-HaloStubServer http://127.0.0.1:8096` als Startargument. Der Weg
/// führt bewusst **am Schlüsselbund vorbei** und schreibt nichts fort —
/// so lässt sich die Oberfläche ansehen, prüfen und fotografieren, ohne
/// dass je ein echter Server, ein echtes Konto oder ein echter Token im
/// Spiel wäre. Genau daran hat es bisher gefehlt: Aussagen über das
/// Aussehen waren Vermutungen, weil niemand hinsehen konnte.
private func adoptStubServerIfRequested() async -> Bool {
guard let raw = defaults.string(forKey: "HaloStubServer"),
let baseURL = URL(string: raw)
else { return false }
let account = JellyfinAccount(
userID: "user-1",
userName: "Attrappe",
token: "stub-token",
isAdministrator: true
)
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: account.token)
let backend = JellyfinBackend(client: client, account: account, serverName: "Attrappe")
jellyfinAccount = account
self.backend = backend
do {
libraries = try await loadLibraries(from: backend)
phase = .ready
} catch {
phase = .failed(message: ErrorText.describe(error))
}
return true
}
#endif
func adoptJellyfin(
client: JellyfinClient,
account: JellyfinAccount,
serverName: String
) async {
phase = .connecting(serverName: serverName)
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
self.backend = backend
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
} catch {
self.backend = nil
jellyfinAccount = nil
phase = .failed(message: ErrorText.describe(error))
return
}
rememberJellyfin(account: account, serverName: serverName, baseURL: client.baseURL)
phase = .ready
}
/// Nimmt eine gespeicherte Jellyfin-Anmeldung wieder auf.
///
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Token nicht
/// mehr gilt — dann steht die Anmeldung wieder am Anfang. Ist der Server
/// bloß gerade nicht erreichbar, bleibt die Anmeldung dagegen bestehen und
/// es gibt einen Fehler mit „Erneut versuchen": auf dem Weg zur Arbeit ist
/// jeder Server unerreichbar, und das ist kein Grund, jemanden abzumelden.
private func restoreJellyfin() async -> Bool {
guard let address = defaults.string(forKey: Key.jellyfinAddress),
let baseURL = URL(string: address),
let userID = defaults.string(forKey: Key.jellyfinUserID),
let token = tokenStore.token(for: Self.jellyfinTokenAccount)
else { return false }
let serverName = defaults.string(forKey: Key.jellyfinServerName) ?? ""
phase = .connecting(serverName: serverName)
let account = JellyfinAccount(
userID: userID,
userName: defaults.string(forKey: Key.jellyfinUserName) ?? "",
token: token,
isAdministrator: defaults.bool(forKey: Key.jellyfinIsAdministrator)
)
let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: token)
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
phase = .ready
return true
} catch MediaBackendError.unauthorized {
// Der Token wurde am Server zurückgezogen. Ihn zu behalten würde
// bei jedem Start denselben Fehler erzeugen.
forgetJellyfin()
clearActiveServer()
return false
} catch {
phase = .failed(message: ErrorText.describe(error))
return true
}
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName)
defaults.set(serverName, forKey: Key.jellyfinServerName)
defaults.set(account.isAdministrator, forKey: Key.jellyfinIsAdministrator)
try? tokenStore.store(account.token, for: Self.jellyfinTokenAccount)
}
private func forgetJellyfin() {
for key in [Key.jellyfinAddress, Key.jellyfinUserID, Key.jellyfinUserName,
Key.jellyfinServerName, Key.jellyfinIsAdministrator] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.jellyfinTokenAccount)
}
// MARK: - Der aktive Server
/// Holt die Mediathekenliste und merkt sie sich.
///
/// **Der Speicher ist hier nur ein Rettungsanker**, kein Beschleuniger:
/// die Liste ist eine einzige kleine Anfrage, und sie sofort zu zeigen
/// hiesse, eine Sitzung für fertig zu erklären, bevor feststeht, dass der
/// Token noch gilt. Antwortet der Server dagegen **nicht**, ist eine
/// Seitenleiste von gestern besser als eine leere — im Zug ist das der
/// Unterschied zwischen „das Heruntergeladene ansehen" und „gar nichts".
private func loadLibraries(from backend: any MediaBackend) async throws -> [MediaLibrary] {
let kennung = backend.identity
do {
let geladen = try await backend.libraries().filter(\.isSupported)
await LibraryCache.shared.storeLibraries(geladen, server: kennung)
return geladen
} catch {
guard let gemerkt = await LibraryCache.shared.libraries(server: kennung),
!gemerkt.isEmpty
else { throw error }
return gemerkt
}
}
private func clearActiveServer() {
backend = nil
jellyfinAccount = nil
libraries = []
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
if await restoreJellyfin() { return }
phase = .signedOut
}
/// Die Kennung für Jellyfin-Anmeldungen.
///
/// Der Anmeldebildschirm baut sich seinen eigenen Client, bevor es eine
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
}
+120
View File
@@ -0,0 +1,120 @@
import Foundation
import HaloDesign
import MediaKit
/// Woher der Aufmacher seine Bilder nimmt.
///
/// **Liegt bei den Modellen, obwohl der Name nach Ansicht klingt.** Hier steht
/// keine Zeile SwiftUI, nur die Frage, welche Adresse in welcher Grösse
/// angefordert wird — und die beantwortet der Fernseher genauso wie das
/// Telefon.
///
/// An **einer** Stelle, weil zwei Stellen es schon zweimal falsch gemacht
/// haben:
///
/// Erstens wurde das Hintergrundbild in 900×1200 angefordert — hochkant — und
/// dann in einem 16:9-Rahmen gezeigt. Zu sehen war davon ein schmaler Streifen
/// aus der Mitte; vom Bild fehlte oben und unten je ein Drittel. Die Maße
/// richten sich jetzt nach dem Rahmen, in dem das Bild landet.
///
/// Zweitens holt das Karussell das nächste Bild vor, solange das aktuelle noch
/// steht. Weicht dessen Adresse auch nur in einer Zahl ab, ist es eine andere
/// Adresse, der Zwischenspeicher trifft daneben und der Aufmacher blendet auf
/// eine graue Fläche über.
enum HeroArtwork {
/// Wofür das Bild gebraucht wird.
///
/// **Auf einem breiten Bildschirm macht das keinen Unterschied** — dort
/// ist beides ein 16:9-Hintergrundbild über die ganze Breite. Schmal
/// gehen die beiden auseinander, und zwar aus einem inhaltlichen Grund:
///
/// Im **Karussell** sucht man noch. Dort soll eine Szene stehen, kein
/// Plakat: das Plakat trägt den Titel selbst, und darüber liegt noch der
/// freigestellte Schriftzug — derselbe Name zweimal.
///
/// Auf einer **Detailseite** hat man sich entschieden. Dort ist das
/// Plakat das Bild dieses Films, und der Kopf der Seite darf die Höhe
/// nehmen, die ein Plakat braucht.
enum Placement {
/// Der Aufmacher auf der Startseite.
case carousel
/// Der Kopf einer Detailseite.
case detailHeader
}
/// Ob der Rahmen hochkant ist.
///
/// Die eine Stelle, an der die Entscheidung fällt — Maße, Verhältnis und
/// Reihenfolge der Bildpfade hängen alle daran, und sie müssen
/// zusammenpassen.
private static func isPortrait(_ placement: Placement, compact: Bool) -> Bool {
compact && placement == .detailHeader
}
/// Die Maße, in denen das Bild angefordert wird.
///
/// Im Seitenverhältnis des Rahmens, in dem es landet — sonst schneidet der
/// Server zu, und man sieht einen Streifen aus der Mitte.
static func size(_ placement: Placement = .carousel, compact: Bool) -> (width: Int, height: Int) {
if isPortrait(placement, compact: compact) { return (600, 900) }
// Schmal landet das Querbild in einem 4:3-Rahmen von höchstens 440
// Punkten Breite — bei dreifacher Auflösung rund 1320 Bildpunkte.
// Zwölfhundert ist genug und spart gegenüber 1920 ein Drittel.
return compact ? (1200, 900) : (1920, 1080)
}
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
if isPortrait(placement, compact: compact) { return HaloAspectRatio.poster }
return compact ? HaloAspectRatio.heroCompact : HaloAspectRatio.hero
}
static func backdropURL(
for item: MediaItem,
backend: any MediaBackend,
placement: Placement = .carousel,
compact: Bool
) -> URL? {
backdropURLs(for: item, backend: backend, placement: placement, compact: compact).first
}
/// Alle Kandidaten, vom besten zum schlechtesten.
///
/// **Warum mehrere und nicht einer.** Eine Folge hat selten ein eigenes
/// Querformatbild, die Staffel manchmal, die Serie fast immer. Bisher
/// wurde die erste Adresse genommen, die sich **zusammensetzen** liess —
/// und ob es das Bild dahinter gibt, stellt sich erst beim Abruf heraus.
/// Gab es keines, blieb die Fläche grau, obwohl eine Ebene höher eines
/// gelegen hätte. Genau so sahen Folgen im Aufmacher aus.
///
/// Die Reihenfolge steht hier, ausprobiert wird sie beim Laden.
static func backdropURLs(
for item: MediaItem,
backend: any MediaBackend,
placement: Placement = .carousel,
compact: Bool
) -> [URL] {
let size = size(placement, compact: compact)
let pfade: [String?] = isPortrait(placement, compact: compact)
// Hochkant: das Plakat der Folge, dann das der Staffel, dann das
// der Serie. Ein Hintergrundbild kommt hier zuletzt — es ist quer
// und verliert hochkant die Hälfte.
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
// das der Serie — und erst danach das Vorschaubild der Folge.
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
var gesehen = Set<String>()
return pfade
.compactMap(\.self)
.filter { gesehen.insert($0).inserted }
.compactMap { backend.artworkURL(path: $0, width: size.width, height: size.height) }
}
/// Der freigestellte Schriftzug — ohne Bild-Transcoder.
///
/// Er ist ein PNG mit durchsichtigem Grund, und der Transcoder liefert
/// JPEG. Aus dem Schriftzug würde sonst ein schwarzer Kasten.
static func logoURL(for item: MediaItem, backend: any MediaBackend) -> URL? {
backend.rawImageURL(path: item.clearLogoPath)
}
}
+165
View File
@@ -0,0 +1,165 @@
import MediaKit
import Observation
/// Die Startseite als Modell: laden, ordnen, auswählen.
///
/// **Warum das nicht mehr in `HomeView` steht.** Der Fernseher zeigt dieselben
/// Reihen wie das Telefon, nur ganz anders — und ohne diesen Schnitt hiesse
/// „auch auf tvOS" schlicht: hundert Zeilen kopieren. Kopiert wäre dabei
/// ausgerechnet der Teil, an dem schon mehrere Fehler steckten: die Reihenfolge
/// des Aufmachers, das Zusammenlegen der Weiterschauen-Reihen, das Ausblenden
/// von Gesehenem. Einer davon — das Los des Aufmachers hing an der Position in
/// der Liste statt am Titel — wäre in einer Kopie ein zweites Mal zu finden
/// gewesen.
///
/// **Die Naht verläuft zwischen Laden und Zeigen.** Hier steht, *was* auf der
/// Startseite steht; wie es aussieht, entscheidet jede Plattform für sich.
@MainActor
@Observable
final class HomeFeed {
private(set) var state: LoadState<[MediaRowGroup]> = .loading
/// Holt die Reihen vom Server.
///
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
/// Muster wie bei `QuickPlay.start`: so bleibt der Typ ohne Abhängigkeiten
/// baubar, und ein Serverwechsel veraltet hier nichts.
func load(from backend: (any MediaBackend)?) async {
guard let backend else { return }
state = await LoadState.load(keepingPrevious: state) {
try await backend.homeRows()
}
#if os(tvOS)
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
if case .loaded(let reihen) = state {
TopShelfWriter.update(rows: reihen, backend: backend)
}
#endif
}
/// Die Reihen in der eingestellten Form.
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
guard case .loaded(let alle) = state else { return [] }
return layout.apply(to: prepared(alle, layout: layout))
}
// MARK: - Ordnen
/// Bringt die Reihen vom Server in die Form, die eingestellt ist.
///
/// Hier und nicht im Backend: „Weiterschauen und Als Nächstes zusammen"
/// und „Gesehenes ausblenden" sind Fragen der Darstellung, keine des
/// Servers. Beides hier zu erledigen hält den Vertrag schmal und gilt für
/// Plex und Jellyfin gleichermaßen.
private func prepared(_ rows: [MediaRowGroup], layout: HomeLayoutSettings) -> [MediaRowGroup] {
var rows = rows
if layout.hidesWatchedInLatest {
rows = rows.map { row in
guard row.id.hasPrefix("latest") else { return row }
return MediaRowGroup(
id: row.id,
title: row.title,
moreKey: row.moreKey,
// **Die Mediathek muss mit.** Sie fehlte hier, und das
// war teuer: dieser Zweig baut genau die Reihen neu, die
// als einzige eine tragen — und der Aufmacher wirft seit
// Neuestem jede Reihe ohne Mediathek weg, sobald eine
// ausgewählt ist. „Gesehenes ausblenden" und „Mediathek
// für den Aufmacher" zusammen ergaben deshalb gar keinen
// Aufmacher mehr. Beide Schalter stehen im selben
// Bildschirm untereinander.
libraryID: row.libraryID,
items: row.items.filter { !$0.isWatched }
)
}
}
if layout.combinesContinueRows {
rows = combiningContinueRows(rows)
}
return rows.filter { !$0.items.isEmpty }
}
/// Legt „Weiterschauen" und „Als Nächstes" in eine Reihe.
///
/// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre
/// sie für die gespeicherte Reihenfolge eine neue und stünde plötzlich
/// wieder ganz unten.
private func combiningContinueRows(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
let continuing = rows.filter(\.isContinueWatching)
guard continuing.count > 1, let first = continuing.first else { return rows }
var seen = Set<String>()
let merged = continuing.flatMap(\.items).filter { seen.insert($0.id).inserted }
var result: [MediaRowGroup] = []
for row in rows {
guard row.isContinueWatching else {
result.append(row)
continue
}
guard row.id == first.id else { continue }
// `moreKey` und `libraryID` wie oben: was die Reihe mitbrachte,
// soll sie behalten. Ohne `moreKey` fiel bei zusammengelegten
// Reihen „Alle anzeigen" weg — ein eigener, älterer Fehler.
result.append(MediaRowGroup(
id: row.id,
title: row.title,
moreKey: row.moreKey,
libraryID: row.libraryID,
items: merged
))
}
return result
}
// MARK: - Der Aufmacher
/// Was oben groß gezeigt wird.
///
/// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen.
/// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen
/// Folge gäbe es sonst nichts zu blättern. Wer den Aufmacher lieber als
/// Schaufenster versteht, stellt auf Neuzugänge um.
func heroItems(for layout: HomeLayoutSettings) -> [MediaItem] {
// **Nur hier, nicht an den Reihen selbst.** Eine abgewählte Mediathek
// verschwindet nicht von der Startseite — sie soll nur nicht mehr
// oben gross stehen. Wer sie ganz loswerden will, blendet die Reihe
// aus; das ist eine andere Frage und hat ihren eigenen Schalter.
let hubs = HeroSelection.rows(rows(for: layout), limitedTo: layout.heroLibraries)
let candidates: [MediaItem]
switch layout.heroSource {
case .continueFirst:
let continuing = hubs.first(where: \.isContinueWatching)?.items ?? []
let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items)
candidates = continuing + rest
case .latestOnly:
candidates = hubs.filter { $0.id.hasPrefix("latest") }.flatMap(\.items)
}
var seen = Set<String>()
let unique = candidates.filter { seen.insert($0.id).inserted }
// **Gemischt, aber nicht bei jedem Blick.**
//
// Wie gemischt wird, steht in `HeroSelection` — einem reinen Typ mit
// Tests. Der Grund dafür ist ein Fehler, den man hier nie gefunden
// hätte: das Los hing an der **Position** in der Liste, nicht am
// Titel. Kam einer dazu oder änderte sich ein Fortschritt, bekamen
// alle dahinter ein anderes Los — und der Aufmacher war nach jedem
// Start ein anderer, obwohl das Zeitfenster dasselbe war.
switch layout.heroSource {
case .continueFirst:
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
// Angefangenes bleibt vorn, wechselt aber untereinander.
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
return Array(ordered.prefix(layout.heroCount))
case .latestOnly:
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
}
}
}
+162
View File
@@ -0,0 +1,162 @@
import Foundation
import Observation
import MediaKit
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
///
/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht
/// `Arrangement` in MediaKit — dort ist es reine Rechnung und ohne Simulator
/// prüfbar, und genau dort liegen die kniffligen Fälle: neue Reihen vom
/// Server, verschwundene aus der gespeicherten Liste.
@MainActor
@Observable
final class HomeLayoutSettings {
private enum Key {
static let hidden = "halo.home.hiddenHubs"
static let order = "halo.home.hubOrder"
static let heroCount = "halo.home.heroCount"
static let heroSource = "halo.home.heroSource"
static let heroLibraries = "halo.home.heroLibraries"
static let combinesContinueRows = "halo.home.combinesContinueRows"
static let hidesWatchedInLatest = "halo.home.hidesWatchedInLatest"
static let cardScale = "halo.home.cardScale"
static let heroHeight = "halo.home.heroHeight"
}
/// Woraus sich der Aufmacher speist.
enum HeroSource: String, CaseIterable, Identifiable, Sendable {
/// Angefangenes zuerst, danach aufgefüllt.
case continueFirst
/// Nur Neuzugänge — für den, der den Aufmacher als Schaufenster
/// versteht und nicht als Fortsetzung.
case latestOnly
var id: String { rawValue }
}
/// Wie viele Titel der Aufmacher durchwechselt.
static let heroCountChoices = [1, 3, 5, 7, 10]
/// Die Grenzen der Kachelgröße.
///
/// Nach unten, wo ein Plakat noch erkennbar ist; nach oben, wo auf einem
/// iPad noch mehr als zwei nebeneinander passen — sonst ist es keine Reihe
/// mehr, sondern eine Abfolge.
static let cardScaleRange: ClosedRange<Double> = 0.7...1.8
/// Wie viel vom Bildschirm der Aufmacher einnehmen darf.
///
/// Nach unten bleibt er ein breites Vorschaubild, nach oben verdeckt er
/// die erste Reihe ganz — und dann weiß niemand, dass es weitergeht.
static let heroHeightRange: ClosedRange<Double> = 0.4...0.95
private let defaults: UserDefaults
private(set) var arrangement: Arrangement
var heroCount: Int { didSet { defaults.set(heroCount, forKey: Key.heroCount) } }
var heroSource: HeroSource { didSet { defaults.set(heroSource.rawValue, forKey: Key.heroSource) } }
/// Aus welchen Mediatheken der Aufmacher sich speist.
///
/// **Leer heisst alle** — siehe `HeroSelection.rows(_:limitedTo:)`. Eine
/// Auswahl, bei der nichts angekreuzt ist, darf nicht in einen leeren
/// Aufmacher führen.
///
/// Als `Set` und nicht als eine einzelne Kennung: wer Filme und Serien
/// will, aber keine Musikvideos, hat zwei Antworten und nicht eine.
var heroLibraries: Set<String> {
didSet { defaults.set(Array(heroLibraries), forKey: Key.heroLibraries) }
}
/// Ob „Weiterschauen" und „Als Nächstes" eine Reihe sind.
///
/// Getrennt ist genauer, zusammen ist kürzer. Beides ist vertretbar, und
/// welches richtig ist, hängt davon ab, wie viele Serien jemand parallel
/// schaut.
var combinesContinueRows: Bool { didSet { defaults.set(combinesContinueRows, forKey: Key.combinesContinueRows) } }
/// Ob Gesehenes aus „Zuletzt hinzugefügt" verschwindet.
var hidesWatchedInLatest: Bool { didSet { defaults.set(hidesWatchedInLatest, forKey: Key.hidesWatchedInLatest) } }
/// Wie groß Plakate und Vorschaubilder erscheinen, als Faktor auf die
/// gestalterisch festgelegten Breiten.
///
/// Ein Faktor und keine Punktzahl: die Breiten von Plakat und Querformat
/// stehen in einem Verhältnis zueinander, das stimmen soll. Zwei getrennte
/// Zahlen ließen sich gegeneinander verstellen, bis eine Reihe zur anderen
/// nicht mehr passt.
///
/// **Pro Gerät**, nicht über den Server: am Fernseher will man größere
/// Kacheln als am Laptop, und das eine soll das andere nicht umstellen.
var cardScale: Double { didSet { defaults.set(cardScale, forKey: Key.cardScale) } }
/// Welchen Anteil der sichtbaren Höhe der Aufmacher einnimmt.
var heroHeight: Double { didSet { defaults.set(heroHeight, forKey: Key.heroHeight) } }
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.arrangement = Arrangement(
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
order: defaults.stringArray(forKey: Key.order) ?? []
)
self.heroCount = defaults.object(forKey: Key.heroCount) as? Int ?? 5
self.heroSource = defaults.string(forKey: Key.heroSource)
.flatMap(HeroSource.init(rawValue:)) ?? .continueFirst
self.heroLibraries = Set(defaults.stringArray(forKey: Key.heroLibraries) ?? [])
self.combinesContinueRows = defaults.object(forKey: Key.combinesContinueRows) as? Bool ?? false
self.hidesWatchedInLatest = defaults.object(forKey: Key.hidesWatchedInLatest) as? Bool ?? false
// Die Voreinstellungen sind genau das bisherige feste Verhalten: wer
// nichts anfasst, merkt von der neuen Einstellung nichts.
self.cardScale = (defaults.object(forKey: Key.cardScale) as? Double)
.map { $0.clamped(to: Self.cardScaleRange) } ?? 1.0
self.heroHeight = (defaults.object(forKey: Key.heroHeight) as? Double)
.map { $0.clamped(to: Self.heroHeightRange) } ?? 0.82
}
func isHidden(_ identifier: String) -> Bool {
arrangement.hidden.contains(identifier)
}
func setHidden(_ hidden: Bool, for identifier: String) {
if hidden {
arrangement.hidden.insert(identifier)
} else {
arrangement.hidden.remove(identifier)
}
defaults.set(Array(arrangement.hidden), forKey: Key.hidden)
}
func setOrder(_ identifiers: [String]) {
arrangement.order = identifiers
defaults.set(identifiers, forKey: Key.order)
}
func apply(to rows: [MediaRowGroup]) -> [MediaRowGroup] {
arrangement.apply(to: rows)
}
/// Die Zeilen für den Einstellungsbildschirm.
func rows(from rows: [MediaRowGroup]) -> [HomeRow] {
arrangement.arrangedIdentifiers(from: rows).compactMap { identifier in
guard let row = rows.first(where: { $0.id == identifier }) else { return nil }
return HomeRow(id: identifier, title: row.title, isHidden: isHidden(identifier))
}
}
}
/// Eine Reihe im Einstellungsbildschirm.
struct HomeRow: Identifiable, Hashable {
let id: String
let title: String
var isHidden: Bool
}
extension Comparable {
/// Auf einen Bereich begrenzt.
///
/// Gespeicherte Werte werden beim Lesen geprüft, nicht blind übernommen:
/// eine ältere Fassung mit anderen Grenzen — oder ein von Hand
/// verändertes Voreinstellungs-Sheet — soll nicht zu einer Kachel führen,
/// die den halben Bildschirm einnimmt.
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
+248
View File
@@ -0,0 +1,248 @@
import Foundation
import JellyfinKit
import MediaKit
import Observation
/// Führt durch die Anmeldung an einem Jellyfin-Server.
///
/// Eigenes Modell statt eines weiteren Falls in `HaloSession.Phase`: die
/// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse
/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort
/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am
/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin.
///
/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits
/// angemeldeten Client bestätigt; das Passwort erreicht Halo dabei nie —
/// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt.
/// Benutzername und Passwort sind der Rückfall für Server, auf denen Quick
/// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer
/// einer Anfrage und wirft es danach weg.
@MainActor
@Observable
final class JellyfinSignInModel {
enum Step: Equatable {
/// Die Adresse wird eingetippt.
case address
/// Die Adresse wird durchprobiert.
case checking
/// Der Code steht, jetzt wird auf die Bestätigung gewartet.
case quickConnect(code: String)
/// Der Rückfall: Benutzername und Passwort.
case credentials
case signingIn
}
/// Welcher Weg nach dem Fund zuerst angeboten wird.
///
/// **Am Telefon und am Fernseher verschieden herum.** Dort füllt ein
/// Passwortspeicher beide Felder in einer Sekunde; hier tippt man sie mit
/// einem Steuerkreuz, Buchstabe für Buchstabe, und der Code ist der
/// bequemere Weg. Welcher Fall gilt, weiß die Oberfläche — dieses Modell
/// nicht. Deshalb ein Wert im Initialisierer statt einer
/// Plattformbedingung mitten in `findServer()`.
enum PreferredMethod { case credentials, quickConnect }
var address = ""
var userName = ""
var password = ""
private(set) var step: Step = .address
private(set) var errorMessage: String?
/// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse
/// stimmt, noch bevor jemand Anmeldedaten eintippt.
private(set) var serverName: String?
/// Ob der Server die Anmeldung per Code überhaupt anbietet.
private(set) var offersQuickConnect = false
/// Wird gerufen, sobald ein Konto feststeht.
///
/// Der Client kommt mit: er trägt den Token und die Gerätekennung bereits
/// und wird von der Sitzung unverändert weiterverwendet.
var onAuthenticated: ((JellyfinClient, JellyfinAccount, String) -> Void)?
private let identity: JellyfinIdentity
private let preferred: PreferredMethod
private var client: JellyfinClient?
private var authenticator: JellyfinAuthenticator?
private var pollTask: Task<Void, Never>?
/// Der Vorgabewert hält iOS unverändert.
init(identity: JellyfinIdentity, preferring: PreferredMethod = .credentials) {
self.identity = identity
self.preferred = preferring
}
// MARK: - Schritt 1: den Server finden
/// Probiert die plausiblen Ergänzungen der eingetippten Adresse durch.
///
/// Nicht eine raten, sondern der Reihe nach fragen: „192.168.1.50" kann
/// alles Mögliche heißen, und wer die Adresse vollständig eintippen muss,
/// tippt sie dreimal falsch.
func findServer() async {
pollTask?.cancel()
errorMessage = nil
let candidates = JellyfinAddress.candidates(for: address)
guard !candidates.isEmpty else {
errorMessage = String(localized: "signIn.jellyfin.addressMissing")
return
}
step = .checking
for candidate in candidates {
let client = JellyfinClient(baseURL: candidate, identity: identity)
let authenticator = JellyfinAuthenticator(client: client)
guard let info = try? await authenticator.serverInfo() else { continue }
self.client = client
self.authenticator = authenticator
serverName = info.serverName
address = candidate.absoluteString
offersQuickConnect = await authenticator.supportsQuickConnect()
// **Am Telefon Name und Passwort zuerst, am Fernseher der Code.**
//
// Hier sprang der Weg früher immer von selbst in den Code-Fluss,
// sobald der Server ihn anbietet. Das war gut gemeint — dabei
// läuft kein Passwort durch diese App —, geht am Telefon aber am
// Alltag vorbei: der Code muss in einem zweiten Gerät bestätigt
// werden, und wer gerade im Zug sitzt, hat das nicht offen. Ein
// Passwortspeicher füllt dagegen beide Felder in einer Sekunde.
//
// Vor einem Fernseher stimmt jedes Wort davon nicht mehr: dort
// gibt es keinen Passwortspeicher, dort tippt man mit dem
// Steuerkreuz, und das zweite Gerät liegt sowieso auf dem Sofa.
//
// Beide Wege bleiben erreichbar: `offersQuickConnect` steuert den
// Knopf darunter, und `useCredentials()` führt zurück.
if preferred == .quickConnect, offersQuickConnect {
await beginQuickConnect()
return
}
step = .credentials
return
}
step = .address
errorMessage = String(localized: "signIn.jellyfin.notFound")
}
// MARK: - Schritt 2a: Code bestätigen lassen
func beginQuickConnect() async {
guard let authenticator else { return }
errorMessage = nil
step = .checking
do {
let request = try await authenticator.beginQuickConnect()
step = .quickConnect(code: request.code)
startPolling(secret: request.secret, authenticator: authenticator)
} catch {
// Quick Connect abgeschaltet oder gestört: dann eben der andere
// Weg, statt hier stehen zu bleiben.
offersQuickConnect = false
step = .credentials
errorMessage = ErrorText.describe(error)
}
}
/// Fragt im Zweisekundentakt, ob der Code bestätigt wurde.
///
/// Fünf Minuten lang — so lange gilt ein Code bei Jellyfin in der
/// Voreinstellung. Danach wird nicht weiter gefragt, sondern gesagt, dass
/// er abgelaufen ist; eine Schleife, die ewig läuft, sieht aus wie eine
/// App, die hängt.
private func startPolling(secret: String, authenticator: JellyfinAuthenticator) {
pollTask?.cancel()
pollTask = Task { [weak self] in
for _ in 0..<150 {
try? await Task.sleep(for: .seconds(2))
if Task.isCancelled { return }
do {
if let account = try await authenticator.completeQuickConnect(secret: secret) {
self?.finish(with: account)
return
}
} catch MediaBackendError.notFound {
// Jellyfin kennt den Code nicht mehr: abgelaufen oder
// abgelehnt.
self?.expireQuickConnect()
return
} catch {
self?.errorMessage = ErrorText.describe(error)
return
}
}
self?.expireQuickConnect()
}
}
private func expireQuickConnect() {
errorMessage = String(localized: "signIn.jellyfin.codeExpired")
step = .credentials
}
// MARK: - Schritt 2b: Benutzername und Passwort
func signInWithPassword() async {
guard let authenticator else { return }
pollTask?.cancel()
errorMessage = nil
step = .signingIn
let secret = password
// Sofort aus dem Feld nehmen. Es wird nur noch für diese eine Anfrage
// gebraucht, und was nicht mehr dasteht, kann auch nicht in einem
// Bildschirmfoto landen.
password = ""
do {
let account = try await authenticator.signIn(userName: userName, password: secret)
finish(with: account)
} catch {
errorMessage = ErrorText.describe(error)
step = .credentials
}
}
// MARK: - Ende
private func finish(with account: JellyfinAccount) {
pollTask?.cancel()
guard let client else { return }
password = ""
onAuthenticated?(client, account, serverName ?? address)
}
/// Von der Code-Anzeige auf Benutzername und Passwort wechseln.
///
/// Der Weg ohne Passwort steht vorn, aber er ist kein Zwang: wer keinen
/// zweiten angemeldeten Client zur Hand hat, käme sonst nicht weiter.
func useCredentials() {
pollTask?.cancel()
errorMessage = nil
step = .credentials
}
/// Zurück zum Adressfeld — etwa nach einem Vertipper.
func startOver() {
pollTask?.cancel()
client = nil
authenticator = nil
serverName = nil
offersQuickConnect = false
password = ""
errorMessage = nil
step = .address
}
func cancel() {
pollTask?.cancel()
password = ""
}
}
+181
View File
@@ -0,0 +1,181 @@
import Foundation
import Observation
import MediaKit
import HaloPlayback
/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand
/// gesagt hat.
///
/// **Drei Ebenen, eine Frage.**
///
/// Serie → Mediathek → allgemein
///
/// Gefragt wird von unten nach oben; die erste Antwort gilt. Bei Filmen gibt
/// es die Serienebene nicht, dort endet es bei der Mediathek.
///
/// **Warum ein eigener Typ und nicht in `PlaybackSettings`.** Weil das hier
/// keine Einstellung ist, sondern ein kleines Regelwerk: drei Ebenen, eine
/// Rangliste darunter, und ein Gedächtnis, das sich ohne Nachfrage füllt. Das
/// neben Sprungweiten und Abspielgeschwindigkeit zu legen hiesse, beim Lesen
/// jedes Mal beides gleichzeitig im Kopf zu haben.
///
/// **Die Schlüssel der allgemeinen Sprachen bleiben die alten.** Wer sie
/// eingestellt hat, soll sie nach dem Umzug wiederfinden — ein Umzug, den
/// niemand bemerkt, ist ein guter Umzug.
@MainActor
@Observable
final class LanguagePreferences {
private enum Key {
/// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der
/// Besitzer gewechselt, nicht der Wert.
static let audio = "halo.playback.audioLanguage"
static let subtitles = "halo.playback.subtitleLanguage"
static let fallbacks = "halo.languages.fallbacks"
static let avoidsAudioDescription = "halo.languages.avoidsAudioDescription"
static let perLibrary = "halo.languages.library"
static let perSeries = "halo.languages.series"
static let rememberedTracks = "halo.languages.seriesTrack"
}
/// Die Sprachen, die zur Auswahl stehen.
///
/// Keine vollständige Liste aller Sprachen der Welt: ein Auswahlrad mit
/// zweihundert Einträgen, um „Deutsch" zu finden, ist keine Hilfe.
/// Schweizerdeutsch steht dabei, weil es in dieser Sammlung vorkommt und
/// **keine** Schreibweise von Deutsch ist — wer es will, muss es nennen
/// können.
static let offeredCodes = [
"de", "gsw", "en", "fr", "it", "es", "nl", "pt", "pl", "ru", "sv",
"da", "no", "fi", "tr", "ja", "ko", "zh", "cs", "hu",
]
private let defaults: UserDefaults
// MARK: - Allgemein
var audioLanguage: String {
didSet { defaults.set(audioLanguage, forKey: Key.audio) }
}
var subtitleLanguage: String {
didSet { defaults.set(subtitleLanguage, forKey: Key.subtitles) }
}
/// Die Rangliste für den Fall, dass die Wunschsprache fehlt.
///
/// **Nur einmal, für alles.** Sie ist keine dritte Einstellung je
/// Mediathek oder Serie: dort steht der *Wunsch*, die Rangliste ist der
/// Boden darunter und gilt überall gleich. Eine Reihenfolge je Mediathek
/// zu pflegen wäre Arbeit für einen Fall, den es nicht gibt.
var fallbacks: [String] {
didSet { defaults.set(fallbacks, forKey: Key.fallbacks) }
}
/// Ob Spuren mit Audiodeskription gemieden werden.
///
/// An, und das ist die richtige Vorgabe: wer Audiodeskription braucht,
/// weiss das und findet den Schalter. Wer sie nicht braucht, ahnt nicht
/// einmal, dass es sie gibt — und bekäme sie sonst untergeschoben, weil
/// sie in manchen Dateien als die deutsche Spur ausgezeichnet ist.
var avoidsAudioDescription: Bool {
didSet { defaults.set(avoidsAudioDescription, forKey: Key.avoidsAudioDescription) }
}
// MARK: - Je Mediathek und je Serie
private var perLibrary: [String: String] {
didSet { defaults.set(perLibrary, forKey: Key.perLibrary) }
}
private var perSeries: [String: String] {
didSet { defaults.set(perSeries, forKey: Key.perSeries) }
}
/// Was zuletzt **von Hand** für eine Serie gewählt wurde.
///
/// Keine Sprache, sondern eine Beschreibung der Spur. Das ist die
/// Kernentscheidung des ganzen Entwurfs: bei manchen Titeln ist die als
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
/// abzulegen hiesse dort, den Fehler festzuschreiben.
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
didSet {
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
defaults.set(daten, forKey: Key.rememberedTracks)
}
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.audioLanguage = defaults.string(forKey: Key.audio) ?? ""
self.subtitleLanguage = defaults.string(forKey: Key.subtitles) ?? ""
self.fallbacks = defaults.stringArray(forKey: Key.fallbacks) ?? []
// `object(forKey:)` statt `bool(forKey:)`: das gäbe für „nie gesetzt"
// ein `false` zurück, und die Vorgabe wäre damit still das Gegenteil
// der beabsichtigten.
self.avoidsAudioDescription =
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
} ?? [:]
}
// MARK: - Lesen und Setzen je Ebene
func audioLanguage(forLibrary id: String) -> String? { perLibrary[id] }
func setAudioLanguage(_ code: String?, forLibrary id: String) {
perLibrary[id] = code
}
func audioLanguage(forSeries id: String) -> String? { perSeries[id] }
func setAudioLanguage(_ code: String?, forSeries id: String) {
perSeries[id] = code
}
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
rememberedTracks[id]
}
/// Merkt sich, was jemand von Hand gewählt hat — ohne zu fragen.
///
/// **Ohne Nachfrage**, weil „für diese Serie merken?" bei jeder Umstellung
/// genau die Art Dialog ist, die man nach dem dritten Mal wegklickt.
/// Sichtbar wird es trotzdem: das Spurenblatt zeigt es an und lässt es
/// löschen. Eine automatische Handlung, die man nicht sehen kann, ist von
/// einem Fehler nicht zu unterscheiden.
func remember(_ stream: MediaStream, forSeries id: String) {
rememberedTracks[id] = AudioTrackChooser.TrackSignature(stream)
}
func forgetTrack(forSeries id: String) {
rememberedTracks[id] = nil
}
// MARK: - Was dabei herauskommt
/// Der Wunsch für einen bestimmten Titel.
///
/// Die Werte kommen von hier, die Rangfolge steht in
/// `AudioTrackChooser.resolve` — dort lässt sie sich prüfen.
func wish(seriesID: String?, libraryID: String?) -> AudioTrackChooser.Wish {
AudioTrackChooser.resolve(
series: seriesID.flatMap { perSeries[$0] },
library: libraryID.flatMap { perLibrary[$0] },
general: audioLanguage,
fallbacks: fallbacks,
remembered: seriesID.flatMap { rememberedTracks[$0] },
avoidsAudioDescription: avoidsAudioDescription
)
}
/// Ob überhaupt eine Mediathek ausschlaggebend sein kann.
///
/// Wer nie eine Sprache je Mediathek gesetzt hat — der Normalfall —, für
/// den braucht auch niemand herauszufinden, in welcher Mediathek ein Titel
/// liegt. Das spart bei jeder Wiedergabe eine Serveranfrage.
var usesLibraryLevel: Bool { !perLibrary.isEmpty }
}
+131
View File
@@ -0,0 +1,131 @@
import MediaKit
import Observation
/// Eine Mediathek als Modell: laden, nachladen, entdoppeln.
///
/// **Der teuerste Brocken, den man hätte kopieren können.** Hier stecken der
/// Abzug aus dem Zwischenspeicher, der Seitenversatz, die Entdoppelung gegen
/// Plex-Serverscans und die Sperre nach einem Fehlschlag — und an mindestens
/// zwei davon hat schon je ein Fehler geklebt, der sich als „es sind nicht
/// alle Filme drin" äusserte und sonst nichts. Ein zweites Mal wären sie in
/// einer Kopie zu finden gewesen.
///
/// **Was das Nachladen auslöst, steht hier nicht.** Am Telefon ist es das
/// Erscheinen einer Kachel, am Fernseher der Fokus. Die Regel selbst —
/// `PagingWindow` — ist ein reiner Typ mit Tests.
@MainActor
@Observable
final class LibraryFeed {
let library: MediaLibrary
private(set) var items: [MediaItem] = []
private(set) var state: LoadState<Void> = .loading
private(set) var page: MediaPage?
/// Ob der letzte Nachladeversuch misslungen ist.
///
/// Dann passiert von selbst nichts mehr, und es braucht einen Knopf: die
/// Kacheln, die das Nachladen auslösen, sind längst erschienen und
/// erscheinen kein zweites Mal.
private(set) var loadMoreFailed = false
private(set) var isLoadingMore = false
var query = MediaQuery()
init(library: MediaLibrary) {
self.library = library
}
/// Wie viele Platzhalter noch unter dem Geladenen stehen.
var pendingCount: Int {
guard let page else { return 0 }
return max(0, page.totalCount - items.count)
}
func reload(from backend: (any MediaBackend)?) async {
guard let server = backend else { return }
let schlüssel = query.cacheKey
let kennung = server.identity
// Nur wenn nichts dasteht. Beim Aktualisieren und beim Filterwechsel
// ist das Gezeigte aktueller als jeder Abzug.
if items.isEmpty,
let stand = await LibraryCache.shared.snapshot(
server: kennung, library: library.id, queryKey: schlüssel
) {
items = stand.items
page = MediaPage(items: stand.items, totalCount: stand.totalCount)
state = .loaded(())
} else if items.isEmpty {
state = .loading
}
do {
let erste = try await server.items(
inLibrary: library, query: query, offset: 0, limit: 60
)
items = erste.items
page = erste
loadMoreFailed = false
state = .loaded(())
await LibraryCache.shared.store(
LibraryCache.Snapshot(
queryKey: schlüssel, items: erste.items, totalCount: erste.totalCount
),
server: kennung,
library: library.id
)
} catch {
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
// sich als Übertragungsfehler, nicht als Abbruch.
if Task.isCancelled { return }
// Steht schon etwas da — aus dem Speicher oder von vorhin —, dann
// bleibt es. Den Fehler über eine brauchbare Liste zu legen nähme
// dem Nutzer etwas weg, statt ihm etwas zu sagen.
guard items.isEmpty else { return }
state = .failed(message: ErrorText.describe(error))
}
}
func loadMore(from backend: (any MediaBackend)?) async {
guard let server = backend, let page, !isLoadingMore,
items.count < page.totalCount
else { return }
isLoadingMore = true
loadMoreFailed = false
defer { isLoadingMore = false }
do {
let nächste = try await server.items(
inLibrary: library, query: query, offset: page.nextOffset, limit: 60
)
// **Anhängen, nie ersetzen.** Plex kann bei gleichzeitigem
// Serverscan denselben Titel auf zwei Seiten liefern; ohne diese
// Prüfung stünde er doppelt im Raster, und SwiftUI beschwert sich
// über doppelte Kennungen. Und auf dem Fernseher hinge daran noch
// mehr: würde `items` als Ganzes ersetzt, verlöre der Fokus mitten
// im Scrollen seinen Halt und spränge an den Anfang.
let bekannt = Set(items.map(\.id))
items.append(contentsOf: nächste.items.filter { !bekannt.contains($0.id) })
self.page = nächste
} catch {
// **Nicht `page = nil`.** Das stand hier einmal, und es beendete
// das Nachladen **endgültig**: eine einzige misslungene Anfrage
// auf einer wackeligen Leitung, und die Mediathek blieb für immer
// bei sechzig Titeln stehen — lautlos. Genau so sieht „es sind
// nicht alle Filme drin" aus.
loadMoreFailed = true
}
}
/// Ob der Eintrag an dieser Stelle nah genug am Ende liegt.
///
/// Die Regel steht in `PagingWindow`; hier steht nur, woher die Zahlen
/// kommen. **Was das auslöst**, entscheidet die Ansicht.
func shouldLoadMore(at index: Int, columns: Int) -> Bool {
guard !loadMoreFailed, !isLoadingMore, let page else { return false }
return PagingWindow(columns: columns)
.shouldLoad(focusedIndex: index, loaded: items.count, total: page.totalCount)
}
}
+99
View File
@@ -0,0 +1,99 @@
import MediaKit
/// Welcher Titel und welches Bild auf eine Kachel gehören.
///
/// **Hier und nicht bei einer Ansicht.** Das stand in `MediaRow.swift` — und
/// damit in einer Datei, die nur das Telefon übersetzt. Der Fernseher zeigt
/// dieselben Kacheln mit denselben Rückfallketten; sie dort noch einmal
/// hinzuschreiben hiesse, zwei Stellen gleich zu halten, an denen die Fehler
/// erst nach Wochen auffallen: ein Plakat, das bei einer Staffel grau bleibt,
/// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild.
///
/// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den
/// übrigen Modellen und nicht in HaloDesign, das `MediaItem` gar nicht kennt.
extension MediaItem {
/// Der Titel auf einer Kachel in einer Reihe.
///
/// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei
/// einer Staffel der Name der Serie. Die Einordnung — Serie, Staffel —
/// gehört darunter. „Staffel 10" allein als Überschrift sagt nichts
/// darüber, welche Serie gemeint ist.
var rowTitle: String {
switch kind {
case .episode: title
// Die Serie steht je nach Antwort mal unter `parentTitle`, mal unter
// `seriesTitle`. Fehlt beides, bleibt „Staffel 2" — besser als
// gar keine Beschriftung.
case .season: parentTitle ?? seriesTitle ?? title
default: title
}
}
/// Welches Bild auf die Querformat-Kachel gehört.
///
/// Bei Folgen ist `thumb` das Szenenbild der Folge — genau richtig. Bei
/// Filmen ist `thumb` das Hochkant-Plakat, das im Querformat beschnitten
/// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl.
var rowArtworkPath: String? {
switch kind {
case .episode: thumb ?? seriesThumb
// Eine Staffel hat bei Jellyfin oft gar kein eigenes Bild — sie leiht
// sich das der Serie. Ohne diesen Rückfall blieb „Nachtstreife,
// Staffel 7" eine graue Fläche, obwohl die Serie ein Plakat hat.
case .season: art ?? thumb ?? seriesArt ?? seriesThumb
default: art ?? thumb
}
}
/// Welches Bild auf die Hochkant-Kachel gehört.
///
/// Bei Folgen das Plakat der Serie statt des Szenenbilds: ein 16:9-Bild
/// hochkant beschnitten zeigt einen zufälligen Ausschnitt und ist zum
/// Wiedererkennen wertlos.
var posterArtworkPath: String? {
switch kind {
case .episode: seriesThumb ?? parentThumb ?? thumb
// Das eigene Staffelplakat zuerst: es zeigt die Staffel. Gibt es
// keines, ist das der Serie allemal besser als nichts.
case .season: thumb ?? seriesThumb
default: thumb
}
}
}
/// Laufzeiten als Text.
///
/// Aus derselben Ansichtsdatei hierher geholt wie die Kachel-Ableitungen
/// darüber: am Fernseher steht dieselbe Zeile unter demselben Aufmacher.
extension Duration {
/// Laufzeit als „2 Std 46 Min" bzw. „46 Min".
var runtimeLabel: String {
let totalMinutes = Int(components.seconds / 60)
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
if hours > 0 {
return String(localized: "runtime.hoursMinutes \(hours) \(minutes)")
}
return String(localized: "runtime.minutes \(minutes)")
}
}
extension MediaLibrary.Kind {
/// Das Symbol, an dem man eine Mediathek erkennt.
///
/// **Eine Zuordnung, nicht drei.** Dieselbe Entscheidung stand bisher an
/// drei Stellen — in der Seitenleiste, im Zwischenbildschirm und am
/// Navigationsziel —, und die dritte war falsch: sie gab jeder Mediathek
/// „film", auch einer mit Serien. Drei Kopien einer Zuordnung halten sich
/// nicht von allein gleich.
public var symbol: String {
switch self {
case .movie: "film"
case .show: "tv"
case .music: "music.note"
case .photo: "photo"
case .liveTV: "dot.radiowaves.left.and.right"
case .other: "rectangle.stack"
}
}
}
@@ -0,0 +1,64 @@
import Foundation
import Observation
import MediaKit
/// Welche Einträge die Navigation zeigt und in welcher Reihenfolge.
///
/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht
/// `Arrangement` in MediaKit — dieselbe Rechnung, die schon die Reihen der
/// Startseite ordnet, samt ihrer Tests. Dort liegen die kniffligen Fälle:
/// eine Mediathek, die neu auf dem Server auftaucht, und eine, die aus der
/// gespeicherten Reihenfolge verschwindet.
@MainActor
@Observable
final class NavigationLayoutSettings {
private enum Key {
static let hidden = "halo.navigation.hidden"
static let order = "halo.navigation.order"
}
private let defaults: UserDefaults
private(set) var arrangement: Arrangement
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
arrangement = Arrangement(
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
order: defaults.stringArray(forKey: Key.order) ?? []
)
}
func isHidden(_ id: String) -> Bool {
arrangement.hidden.contains(id)
}
func setHidden(_ hidden: Bool, for id: String) {
if hidden {
arrangement.hidden.insert(id)
} else {
arrangement.hidden.remove(id)
}
defaults.set(Array(arrangement.hidden), forKey: Key.hidden)
}
/// Legt die Reihenfolge neu fest.
///
/// Gespeichert wird die **vollständige** Liste, auch die ausgeblendeten
/// Einträge. Sonst rutschte ein Eintrag, den man aus- und wieder
/// einblendet, ans Ende — er stünde in der Reihenfolge nicht mehr drin.
func setOrder(_ ids: [String]) {
arrangement.order = ids
defaults.set(ids, forKey: Key.order)
}
/// Wendet Auswahl und Reihenfolge an.
func apply<Element: Arrangeable>(to elements: [Element]) -> [Element] {
arrangement.apply(to: elements)
}
/// Nur die Reihenfolge, ohne auszublenden — für den Bearbeiten-Modus, in
/// dem auch die ausgeblendeten Einträge zu sehen sein müssen.
func ordered<Element: Arrangeable>(_ elements: [Element]) -> [Element] {
Arrangement(hidden: [], order: arrangement.order).apply(to: elements)
}
}
+154
View File
@@ -0,0 +1,154 @@
import Foundation
import MediaPlayer
/// Meldet die laufende Wiedergabe beim System an — und nimmt von dort Befehle
/// entgegen.
///
/// Ohne diese Anmeldung sind die Medientasten der Tastatur wirkungslos, und
/// zwar lautlos: macOS schickt sie an das Programm, das sich als „läuft
/// gerade" gemeldet hat. Meldet sich keines, gewinnt irgendein anderes —
/// Musik, ein Browser-Tab, was zuletzt etwas abgespielt hat. Man drückt auf
/// Pause und pausiert etwas, das man gar nicht sieht.
///
/// Dieselbe Anmeldung trägt die Sperrbildschirm-Bedienung, das
/// Kontrollzentrum, die Knöpfe an Kopfhörern und die Auskunft in der
/// Menüleiste. Es ist **eine** Sache, nicht vier.
///
/// Eine Stelle für die ganze App, kein Wert je Player: das System kennt
/// ohnehin nur einen laufenden Titel. Läge die Anmeldung beim Modell, würde
/// ein zweiter Player dem ersten die Tasten wegnehmen und beim Beenden die
/// des anderen abräumen.
@MainActor
final class NowPlaying {
static let shared = NowPlaying()
/// Wofür wir uns angemeldet haben — zum Wiederabmelden.
///
/// `addTarget` gibt eine Marke zurück, und nur mit ihr lässt sich der
/// eigene Eintrag wieder lösen. Ohne diese Liste sammelten sich bei jedem
/// neuen Film weitere Empfänger an, und ein Tastendruck löste die
/// Wiedergabe fünfmal um.
private var registered: [(command: MPRemoteCommand, target: Any)] = []
fileprivate(set) weak var model: PlayerModel?
/// Wann zuletzt gemeldet wurde und mit welchem Tempo.
///
/// Die Stelle kommt viermal je Sekunde herein; sie jedes Mal zu melden
/// wäre umsonst, weil das System zwischen zwei Meldungen selbst
/// weiterzählt — es kennt ja das Tempo. Gemeldet wird deshalb nur, wenn
/// sich das Tempo ändert oder seit der letzten Meldung Zeit vergangen ist.
private var lastReport: (at: Date, rate: Double)?
private init() {}
// MARK: - An- und abmelden
func takeOver(_ model: PlayerModel) {
release()
self.model = model
let center = MPRemoteCommandCenter.shared()
add(center.togglePlayPauseCommand) { $0.togglePlayPause() }
add(center.playCommand) { if !$0.isPlaying { $0.togglePlayPause() } }
add(center.pauseCommand) { if $0.isPlaying || $0.isBuffering { $0.togglePlayPause() } }
// Die Spulweiten aus den Einstellungen, damit die Knöpfe im
// Kontrollzentrum dasselbe tun wie die im Player. Zwei Zahlen, die
// auseinanderlaufen, sind schlimmer als eine, die nicht gefällt.
center.skipBackwardCommand.preferredIntervals = [NSNumber(value: model.rewindSeconds)]
center.skipForwardCommand.preferredIntervals = [NSNumber(value: model.forwardSeconds)]
add(center.skipBackwardCommand) { $0.skip(by: .seconds(-$0.rewindSeconds)) }
add(center.skipForwardCommand) { $0.skip(by: .seconds($0.forwardSeconds)) }
// Das Ziehen am Balken im Kontrollzentrum. Die Stelle wird vor dem
// Sprung auf den Hauptakteur ausgelesen: das Ereignis selbst darf
// nicht mitreisen.
let position = center.changePlaybackPositionCommand
let target = position.addTarget { event in
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
let seconds = event.positionTime
Task { @MainActor in
guard let model = NowPlaying.shared.model else { return }
model.seek(to: .seconds(seconds))
NowPlaying.shared.update(from: model)
}
return .success
}
registered.append((position, target))
update(from: model)
}
/// Meldet ab — aber nur, wenn dieser Player noch der angemeldete ist.
///
/// Ohne die Prüfung räumte ein Player, der spät zu Ende geht, dem längst
/// gestarteten nächsten die Anmeldung weg: die Tasten wären tot, obwohl
/// etwas läuft.
func release(for model: PlayerModel? = nil) {
if let model, self.model !== model { return }
for (command, target) in registered { command.removeTarget(target) }
registered = []
self.model = nil
lastReport = nil
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
MPNowPlayingInfoCenter.default().playbackState = .stopped
}
// MARK: - Melden
func update(from model: PlayerModel) {
guard self.model === model else { return }
let rate = model.isPlaying ? 1.0 : 0.0
if let last = lastReport, last.rate == rate, Date.now.timeIntervalSince(last.at) < 2 {
return
}
lastReport = (Date.now, rate)
var info: [String: Any] = [
MPMediaItemPropertyTitle: model.title,
MPNowPlayingInfoPropertyPlaybackRate: rate,
MPNowPlayingInfoPropertyElapsedPlaybackTime: model.currentTime.seconds,
MPNowPlayingInfoPropertyIsLiveStream: model.isLive,
]
if let subtitle = model.subtitle {
info[MPMediaItemPropertyArtist] = subtitle
}
// Bei einem laufenden Sender keine Länge: es gibt kein Ende, und ein
// Balken, der auf eines zuläuft, wäre eine Behauptung.
if let duration = model.duration, !model.isLive {
info[MPMediaItemPropertyPlaybackDuration] = duration.seconds
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
MPNowPlayingInfoCenter.default().playbackState = model.isPlaying ? .playing : .paused
}
// MARK: - Kleinkram
/// Meldet sich für einen Befehl an und merkt sich die Marke dazu.
///
/// Die Handlung läuft über einen Sprung auf den Hauptakteur statt über
/// `assumeIsolated`: dass diese Rückrufe dort ankommen, ist nirgends
/// zugesagt, und dieselbe Annahme hat die App schon einmal
/// zum Absturz gebracht. Der Umweg kostet einen Durchlauf — unbemerkbar
/// neben dem Tastendruck, der ihn ausgelöst hat.
private func add(
_ command: MPRemoteCommand,
handle: @escaping @Sendable @MainActor (PlayerModel) -> Void
) {
command.isEnabled = true
let target = command.addTarget { [weak self] _ in
Task { @MainActor [weak self] in
guard let model = self?.model else { return }
handle(model)
self?.update(from: model)
}
return .success
}
registered.append((command, target))
}
}
+110
View File
@@ -0,0 +1,110 @@
#if os(iOS)
import SwiftUI
import UIKit
/// Bestimmt, in welche Richtungen sich die App drehen darf.
///
/// In reinem SwiftUI gibt es dafür keinen Weg: `requestGeometryUpdate` dreht
/// zwar, aber das System dreht sofort zurück, wenn der Bildschirm es nicht
/// auch erlaubt. Erlaubt wird es über
/// `application(_:supportedInterfaceOrientationsFor:)` — und das gibt es nur
/// am klassischen App-Delegaten. Diese Klasse ist die Brücke dazwischen.
@MainActor
final class OrientationLock {
static let shared = OrientationLock()
private(set) var mask: UIInterfaceOrientationMask = .portrait
private init() {
// Auf dem iPad bleibt alles erlaubt: dort ist das Querformat beim
// Blättern der Normalfall, nicht die Ausnahme.
if UIDevice.current.userInterfaceIdiom == .pad {
mask = .all
}
}
/// Dreht zum Start der Wiedergabe ins Querformat — und gibt danach frei.
///
/// **Gedreht wird einmal, gesperrt wird nicht.** Vorher blieb die Maske
/// auf `.landscape` stehen, und damit liess sich im Player nicht mehr ins
/// Hochformat drehen. Ein Film beginnt quer, weil er quer ist; wer danach
/// im Bett auf der Seite liegt, soll drehen dürfen.
func lockLandscape() {
guard UIDevice.current.userInterfaceIdiom == .phone else { return }
apply(.allButUpsideDown, rotatingTo: .landscape)
}
/// Gibt die Ausrichtung wieder frei und dreht zurück ins Hochformat.
func releaseLandscape() {
guard UIDevice.current.userInterfaceIdiom == .phone else { return }
apply(.portrait, rotatingTo: .portrait)
}
/// Dreht auf Knopfdruck, ohne die Freiheit danach zu nehmen.
func rotate(to target: UIInterfaceOrientationMask) {
guard UIDevice.current.userInterfaceIdiom == .phone else { return }
apply(.allButUpsideDown, rotatingTo: target)
}
private func apply(
_ newMask: UIInterfaceOrientationMask,
rotatingTo target: UIInterfaceOrientationMask
) {
mask = newMask
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive })
else { return }
// Erst die neue Erlaubnis bekanntgeben, dann drehen. In der anderen
// Reihenfolge lehnt das System die Drehung ab, weil die alte Maske
// noch gilt — und das sah aus wie „dreht sich nicht richtig".
scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
// **Mit Fehlerbehandler.** Bei eingeschalteter Ausrichtungssperre
// lehnt das System die Drehung ab. Ohne diesen Block wirft UIKit die
// Ablehnung in die Konsole und es sieht aus, als hinge die App —
// dabei ist es eine Einstellung des Nutzers und in Ordnung. Der Film
// läuft dann eben hochkant weiter.
scene.requestGeometryUpdate(.iOS(interfaceOrientations: target)) { fehler in
#if DEBUG
print("Drehung abgelehnt (vermutlich Ausrichtungssperre): \(fehler)")
#endif
}
}
}
/// Dreht beim Erscheinen ins Querformat und beim Verschwinden zurück.
struct LandscapeWhileVisible: ViewModifier {
/// Ob überhaupt gesperrt wird.
///
/// Für die allermeisten Filme ist das Querformat richtig — und für den,
/// der im Bett auf der Seite liegt, das Gegenteil davon.
let isEnabled: Bool
func body(content: Content) -> some View {
content
.onAppear { if isEnabled { OrientationLock.shared.lockLandscape() } }
.onDisappear { OrientationLock.shared.releaseLandscape() }
}
}
extension View {
/// Für den Vollbildplayer: Film ist Querformat.
func landscapeWhileVisible(_ isEnabled: Bool = true) -> some View {
modifier(LandscapeWhileVisible(isEnabled: isEnabled))
}
}
#else
import SwiftUI
/// Auf dem Mac dreht sich nichts.
///
/// Ein Fenster hat keine Ausrichtung, die sich sperren ließe — der Nutzer
/// bestimmt seine Größe. Die Stelle bleibt trotzdem bestehen, damit der
/// Player auf beiden Plattformen dieselbe Zeile schreiben kann.
extension View {
func landscapeWhileVisible(_ isEnabled: Bool = true) -> some View { self }
}
#endif
+165
View File
@@ -0,0 +1,165 @@
import Foundation
import Observation
import MediaKit
/// Was abgespielt werden soll.
struct PlaybackRequest: Identifiable, Hashable {
let id = UUID()
let ratingKey: String
/// Für die Titelzeile, solange die Metadaten noch geladen werden.
let title: String
/// Die zweite Zeile, wenn sie schon feststeht.
///
/// Bei Live-TV die laufende Sendung: sie steht nirgends nachzuschlagen —
/// ein Sender hat keine Metadaten in der Mediathek —, und ohne sie sieht
/// man im Player nur den Sendernamen und weiß nicht, was läuft.
var subtitle: String?
/// Wann die laufende Sendung endet.
///
/// Damit im Player nicht den ganzen Abend derselbe Name steht: ein Sender
/// läuft weiter, die Sendung wechselt. Ohne diese Zeit wüsste niemand,
/// wann die Angabe falsch wird.
var subtitleEndsAt: Date?
/// Welche Fassung, wenn ein Titel in mehreren vorliegt.
let mediaIndex: Int
/// Ab wo — aus dem gemeldeten Fortschritt.
let startTime: Duration
/// Eine bereits feststehende Adresse.
///
/// Für Live-TV. Ein Sender liegt nicht in der Mediathek, und der übliche
/// Weg — Kennung nachschlagen, Datei suchen — findet für ihn nichts.
/// Daran ist das Einschalten gescheitert: die Adresse war längst
/// berechnet und wurde weggeworfen.
var streamURL: URL?
/// Womit der Tuner nach dem Zusehen wieder freigegeben wird.
///
/// Bei vier Tunern ist der fünfte Sender sonst nicht mehr einzuschalten.
var liveSubscriptionKey: String?
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
/// Nimmt Abspielwünsche von überall entgegen.
///
/// Ohne diese Stelle bräuchte jeder Bildschirm, von dem aus sich etwas starten
/// lässt — Aufmacher, Reihe, Detailseite, Suche — seinen eigenen
/// Vollbild-Übergang. Vier Kopien desselben Ablaufs, die auseinanderdriften.
@MainActor
@Observable
final class PlaybackCoordinator {
/// Wie der Player gerade zu sehen ist.
///
/// Der Unterschied zwischen „klein" und „gar nicht" ist der Kern der
/// Sache: verkleinern darf die Wiedergabe **nicht** anhalten. Vorher gab
/// es diese Unterscheidung nicht — der Player war da oder weg, und mit
/// ihm die Engine, der Transcoder und beim Live-TV der belegte Tuner.
enum Presentation: Equatable {
case hidden
case mini
case full
}
private(set) var request: PlaybackRequest?
private(set) var presentation: Presentation = .hidden
func play(_ item: MediaItem, mediaIndex: Int = 0, resuming: Bool = true) {
play(PlaybackRequest(
ratingKey: item.id,
title: item.playbackTitle,
mediaIndex: mediaIndex,
startTime: resuming ? item.resumeTime : .zero
))
}
/// Schaltet einen laufenden Sender ein.
///
/// Eigener Weg, weil ein Sender nicht in der Mediathek liegt: Adresse und
/// Tuner-Belegung stehen schon fest und dürfen nicht neu ermittelt werden.
func playLive(
channelID: String,
title: String,
programme: String?,
programmeEndsAt: Date?,
stream: URL,
subscriptionKey: String?
) {
play(PlaybackRequest(
ratingKey: channelID,
title: title,
subtitle: programme,
subtitleEndsAt: programmeEndsAt,
mediaIndex: 0,
startTime: .zero,
streamURL: stream,
liveSubscriptionKey: subscriptionKey
))
}
/// Übernimmt einen neuen Titel.
///
/// Läuft bereits die Kachel, bleibt es dabei: wer nebenher stöbert, will
/// beim Antippen nicht ins Vollbild gerissen werden. Sonst Vollbild, wie
/// man es von einem Antippen erwartet.
func play(_ next: PlaybackRequest) {
presentation = presentation == .mini ? .mini : .full
request = next
}
/// Verkleinert den Player zur schwebenden Kachel. Die Wiedergabe läuft.
func minimize() {
guard request != nil else { return }
presentation = .mini
}
func expand() {
guard request != nil else { return }
presentation = .full
}
/// Beendet die Wiedergabe wirklich.
///
/// Die einzige Stelle, an der das passiert. Alles andere — verkleinern,
/// vergrößern, Bild-in-Bild — lässt die Engine am Leben.
func close() {
request = nil
presentation = .hidden
}
}
extension MediaItem {
/// Wie der Titel im Player heißt.
///
/// Bei Folgen ist der eigene Titel allein nutzlos — „Die erste Folge"
/// sagt nichts darüber, welche Serie läuft.
var playbackTitle: String {
if kind == .episode, let series = seriesTitle {
return "\(series) · \(title)"
}
return title
}
/// Die Beschriftung unter einer Kachel.
///
/// Sie ordnet ein, was darüber steht: zu einer Folge gehört die Serie, zu
/// einer Staffel ihre Nummer. Die Reihenfolge ist Absicht — wird der
/// Platz eng, bleibt der Serienname stehen und die Folgennummer fällt weg.
var cardSubtitle: String? {
switch kind {
case .episode:
var code: String?
if let season = parentIndex, let episode = index {
code = String(localized: "episode.short \(season) \(episode)")
}
guard let series = seriesTitle else { return code }
return code.map { "\(series) · \($0)" } ?? series
case .season:
// Ohne die Serie darüber wäre „Staffel 10" beliebig — deshalb
// steht die Nummer hier und der Serienname als Titel.
if let index { return String(localized: "season.short \(index)") }
return parentTitle == nil ? nil : title
default:
return year.map(String.init)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import Foundation
import Observation
import HaloPlayback
/// Hält fest, was beim letzten Abspielversuch passiert ist.
///
/// Der Player verschwindet, wenn man ihn schließt — und mit ihm jeder Hinweis
/// darauf, woran es lag. Dieser Mitschnitt überlebt das und steht in den
/// Einstellungen. Aus „es läuft nicht" wird damit „umgepackt, lokale
/// Verbindung, hängt bei Öffnen" — ein Unterschied, der Stunden spart.
@MainActor
@Observable
final class PlaybackDiagnostics {
struct Attempt: Identifiable {
let id = UUID()
let startedAt: Date
var title: String
var method: String
var connection: String
/// Die Adresse ohne Abfrageteil.
///
/// Der Token steckt in der Abfrage. Er hat in einem Protokoll nichts
/// zu suchen — es landet in Screenshots, und wer ihn hat, kommt an die
/// ganze Mediathek.
var sourceDescription: String
var steps: [String] = []
var outcome: String?
}
private(set) var lastAttempt: Attempt?
func begin(title: String, connection: String?) {
lastAttempt = Attempt(
startedAt: Date(),
title: title,
method: "—",
connection: connection ?? "—",
sourceDescription: "—"
)
note("Player geöffnet")
}
func record(decision: PlaybackDecision, source: URL) {
lastAttempt?.method = Self.describe(decision)
lastAttempt?.sourceDescription = Self.describe(source)
note("Quelle gewählt")
}
/// Ein Zwischenschritt, mit Zeitabstand zum Beginn.
///
/// Der Abstand ist die eigentliche Auskunft: steht zwischen „Quelle
/// gewählt" und „spielt" eine Minute, liegt es an der Leitung; kommt
/// „spielt" gar nicht, am Engine.
func note(_ step: String) {
guard var attempt = lastAttempt else { return }
let elapsed = Date().timeIntervalSince(attempt.startedAt)
attempt.steps.append(String(format: "%.1fs %@", elapsed, step))
lastAttempt = attempt
}
func finish(outcome: String) {
lastAttempt?.outcome = outcome
note(outcome)
}
private static func describe(_ decision: PlaybackDecision) -> String {
switch decision.method {
case .directPlay:
"Direkt"
case .remux:
decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } }
? "Umgepackt · Bild unverändert, Ton umgerechnet"
: "Umgepackt · MP4, ohne Neukodierung"
case .transcode(let limit):
limit.map { "Transcode · max. \($0 / 1000) Mbit" } ?? "Transcode"
}
}
private static func describe(_ url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return "—"
}
components.query = nil
return components.string ?? "—"
}
}
+144
View File
@@ -0,0 +1,144 @@
import Foundation
import Observation
import HaloPlayback
/// Was der Nutzer über die Wiedergabe eingestellt hat.
///
/// Getrennt nach WLAN und Mobilfunk, weil sich die Anforderungen dort
/// grundlegend unterscheiden: im Heimnetz ist die Originaldatei das Beste,
/// unterwegs zählt das Datenvolumen.
@MainActor
@Observable
final class PlaybackSettings {
/// Die Stufen, die zur Auswahl stehen.
///
/// Bewusst wenige und in Schritten, die man spürt. Eine freie Eingabe in
/// Kilobit hilft niemandem bei der Frage „reicht das für heute Abend".
enum Quality: Int, CaseIterable, Identifiable, Sendable {
case original = 0
case high = 12_000
case medium = 8_000
case low = 4_000
case minimal = 2_000
var id: Int { rawValue }
/// `nil` heißt: keine Grenze, also die Originaldatei.
var bitrateLimit: Int? { self == .original ? nil : rawValue }
}
private enum Key {
static let wifiQuality = "halo.playback.quality.wifi"
static let cellularQuality = "halo.playback.quality.cellular"
static let autoSkipIntro = "halo.playback.autoSkipIntro"
static let autoSkipCredits = "halo.playback.autoSkipCredits"
static let autoPlayNext = "halo.playback.autoPlayNext"
static let locksLandscape = "halo.playback.locksLandscape"
static let rewindSeconds = "halo.playback.rewindSeconds"
static let forwardSeconds = "halo.playback.forwardSeconds"
static let defaultRate = "halo.playback.defaultRate"
static let remembersQuality = "halo.playback.remembersQuality"
static let rememberedQuality = "halo.playback.rememberedQuality"
static let showsBitrate = "halo.playback.showsBitrate"
static let lastPlaybackStruggled = "halo.playback.struggled"
}
/// Die Sprünge, die es zur Auswahl gibt.
///
/// Nicht frei einstellbar: ein Schieberegler für Sekunden lädt dazu ein,
/// 23 einzustellen, und dafür gibt es kein Symbol. Diese fünf haben eins.
static let skipChoices = [5, 10, 15, 30, 45]
/// Die Geschwindigkeiten, mit denen eine Wiedergabe beginnen kann.
static let rateChoices: [Double] = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
private let defaults: UserDefaults
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
/// Ob der Player beim Öffnen ins Querformat dreht und dort bleibt.
///
/// Für die allermeisten Filme richtig — und für den, der im Bett auf der
/// Seite liegt, das Gegenteil davon. Deshalb abschaltbar.
var locksLandscape: Bool { didSet { defaults.set(locksLandscape, forKey: Key.locksLandscape) } }
/// Wie weit der Rücksprung geht, in Sekunden.
var rewindSeconds: Int { didSet { defaults.set(rewindSeconds, forKey: Key.rewindSeconds) } }
/// Wie weit der Vorsprung geht. Getrennt vom Rücksprung, weil die beiden
/// verschiedene Zwecke haben: zurück, weil man etwas nicht verstanden hat;
/// vor, weil man etwas überspringen will.
var forwardSeconds: Int { didSet { defaults.set(forwardSeconds, forKey: Key.forwardSeconds) } }
/// Mit welcher Geschwindigkeit eine Wiedergabe beginnt.
var defaultRate: Double { didSet { defaults.set(defaultRate, forKey: Key.defaultRate) } }
/// Ob die im Player gewählte Stufe für den nächsten Titel gilt.
var remembersQuality: Bool { didSet { defaults.set(remembersQuality, forKey: Key.remembersQuality) } }
/// Ob im Qualitätsmenü steht, was gerade tatsächlich ankommt.
var showsBitrate: Bool { didSet { defaults.set(showsBitrate, forKey: Key.showsBitrate) } }
/// Die zuletzt gewählte Stufe, als Zahl.
///
/// Hier und nicht im Player: der lebt nur so lange wie ein Film. Was über
/// mehrere Filme hinweg gelten soll, gehört in die Einstellungen.
var rememberedQuality: Int? {
get {
guard remembersQuality else { return nil }
let value = defaults.integer(forKey: Key.rememberedQuality)
return value == 0 ? nil : value
}
set { defaults.set(newValue ?? 0, forKey: Key.rememberedQuality) }
}
/// Ob die letzte Wiedergabe von der Leitung nicht getragen wurde.
///
/// **Ein Beleg, keine Vermutung.** Der Hinweis bei „ohne Grenze" soll nur
/// erscheinen, wenn es tatsächlich gehakt hat — eine Warnung, die immer
/// dasteht, liest nach dem zweiten Mal niemand mehr.
///
/// Gesetzt vom Player, wenn im gleitenden Fenster ein Viertel der Zeit
/// fürs Warten draufging oder ein einzelner Rückler über fünfzehn
/// Sekunden dauerte.
var lastPlaybackStruggled: Bool {
get { defaults.bool(forKey: Key.lastPlaybackStruggled) }
set { defaults.set(newValue, forKey: Key.lastPlaybackStruggled) }
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// Voreinstellung: im WLAN das Original, unterwegs 4 Mbit. Das ist die
// Aufteilung, die im Alltag am seltensten stört — zuhause die beste
// Qualität, unterwegs ein Wert, der auch bei mäßigem Empfang trägt.
self.wifiQuality = Quality(rawValue: defaults.object(forKey: Key.wifiQuality) as? Int ?? 0) ?? .original
self.cellularQuality = Quality(rawValue: defaults.object(forKey: Key.cellularQuality) as? Int ?? 4_000) ?? .low
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
self.rewindSeconds = defaults.object(forKey: Key.rewindSeconds) as? Int ?? 10
self.forwardSeconds = defaults.object(forKey: Key.forwardSeconds) as? Int ?? 10
self.defaultRate = defaults.object(forKey: Key.defaultRate) as? Double ?? 1
self.remembersQuality = defaults.object(forKey: Key.remembersQuality) as? Bool ?? false
self.showsBitrate = defaults.object(forKey: Key.showsBitrate) as? Bool ?? true
}
/// Die Richtlinie für die aktuelle Lage.
///
/// Das ist die Stelle, an der Phase 2 greift: `allowsTranscoding` steht
/// jetzt auf `true`. Am Decider selbst musste dafür nichts geändert werden.
func policy(for conditions: NetworkConditions) -> PlaybackPolicy {
let quality = conditions.isExpensive || conditions.isConstrained
? cellularQuality
: wifiQuality
return PlaybackPolicy(
allowsTranscoding: true,
maximumBitrate: quality.bitrateLimit
)
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
import Observation
import MediaKit
/// „Spiel das jetzt" — von einer Kachel aus, ohne Umweg über die Detailseite.
///
/// **Warum ein eigener Typ.** Bei einem Film ist nichts nachzuschlagen: er
/// läuft sofort. Bei einer Serie schon — es muss erst feststehen, welche
/// Folge dran ist, und das ist eine Serveranfrage. Diese Anfrage gehört
/// weder in `PlaybackCoordinator`, der keinen Serverzugang hat und dessen
/// Aufgabe die Darstellung ist, noch in die Kacheln, wo sie dreimal stünde.
///
/// **Die Regel selbst steht nicht hier**, sondern in `EpisodeSelection` — als
/// reiner Typ mit Tests. Hier steht nur, woher die Liste kommt.
@MainActor
@Observable
final class QuickPlay {
/// Welche Kachel gerade nachschlägt.
///
/// Eine Kennung, kein Wahrheitswert: der Kreisel soll an **dieser** einen
/// Kachel stehen und nicht an allen zwanzig.
private(set) var resolvingID: String?
/// Warum es nicht ging — einmal für die ganze App.
///
/// Ein Hinweis je Kachel wäre bei einem Serverausfall ein Stapel aus
/// zwanzig identischen Meldungen.
var failure: String?
private var task: Task<Void, Never>?
/// Startet, was hinter dieser Kachel steckt.
///
/// `backend` und `coordinator` kommen als Argumente statt als gespeicherte
/// Werte: so bleibt der Typ ohne Abhängigkeiten baubar, und die Sitzung
/// kann den Server wechseln, ohne dass hier etwas veraltet. Dasselbe
/// Muster wie bei `DownloadRefiller`.
func start(
_ item: MediaItem,
backend: (any MediaBackend)?,
coordinator: PlaybackCoordinator
) {
// Der zweite Klick gewinnt. Sonst liefen zwei Abfragen, und die
// langsamere überschriebe am Ende die gewollte.
task?.cancel()
switch item.kind {
case .show, .season:
resolve(item, backend: backend, coordinator: coordinator)
default:
// **Sofort, ohne Aufgabe und ohne Kreisel.** Der häufige Fall
// soll nicht aufblitzen, nur weil der seltene eine Anfrage
// braucht.
resolvingID = nil
coordinator.play(item)
}
}
func cancel() {
task?.cancel()
task = nil
resolvingID = nil
}
private func resolve(
_ item: MediaItem,
backend: (any MediaBackend)?,
coordinator: PlaybackCoordinator
) {
guard let backend else {
failure = String(localized: "quickPlay.noServer")
return
}
resolvingID = item.id
task = Task { [weak self] in
let folgen: [MediaItem]
let bereich: EpisodeSelection.Scope
do {
if item.kind == .show {
folgen = try await backend.episodes(inSeries: item.id)
bereich = .series
} else {
// Eine Staffel ist schon eine Anfrage — `children(of:)`
// genügt, und der Server muss nichts Besonderes können.
folgen = try await backend.children(of: item.id)
bereich = .season
}
} catch {
guard !Task.isCancelled, let self else { return }
resolvingID = nil
failure = ErrorText.describe(error)
return
}
guard !Task.isCancelled, let self else { return }
resolvingID = nil
guard let wahl = EpisodeSelection.nextUp(in: folgen, scope: bereich) else {
failure = String(localized: "quickPlay.nothingToPlay")
return
}
coordinator.play(wahl.episode, resuming: wahl.resumes)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import SwiftUI
import UIKit
/// Wo der Bildschirm aufhört — für die Ansichten, die absichtlich darüber
/// hinauslaufen.
///
/// **Warum nicht aus SwiftUI.** Der Sicherheitsrand ist in SwiftUI kein Wert,
/// den man erfragt, sondern ein Rand, den man verbraucht: wer ihn respektiert,
/// sieht ihn als null, und wer ihn mit `ignoresSafeArea` überschreitet, sieht
/// ihn danach ebenfalls als null. Ein `GeometryReader` dazwischenzuschieben
/// hilft nicht, er nimmt der Bildfläche das `ignoresSafeArea` gleich mit — und
/// dann ist das Bild seitlich um die Notch-Breite kleiner.
///
/// Der Versuch, ihn eine Ebene höher zu messen und hineinzureichen, hat es
/// auch nicht behoben: gemessen in einer Ansicht, angewendet in einer anderen,
/// über eine Drehung hinweg. Auf dem Gerät kamen dabei die Werte der falschen
/// Ausrichtung an.
///
/// **Das Fenster weiß es immer.** Es kennt seine Ausrichtung, es kennt die
/// Notch, und es antwortet in dem Moment, in dem gefragt wird. Das ist die
/// einzige Stelle in dieser App, die so fragt — und genau die eine, an der es
/// keinen SwiftUI-Weg gibt.
///
/// Gebraucht wird es für die Untertitel im Füllmodus: die Bildebene reicht
/// dort über die untere Kante hinaus, und AVFoundation zeichnet die Schrift an
/// ihren unteren Rand.
@MainActor
enum ScreenSafeArea {
/// Was der Bildschirm gerade verdeckt — Notch, Dynamic Island,
/// Home-Indikator, gerundete Ecken.
///
/// **Jedes Mal frisch gefragt, nie gemerkt.** Beim Drehen ändert sich
/// jeder dieser Werte, und ein gemerkter wäre genau dann falsch, wenn er
/// gebraucht wird.
static var current: EdgeInsets {
let i = windowInsets
// `left`/`right` und nicht `leading`/`trailing`: UIKit denkt in
// Bildschirmseiten, SwiftUI in Leserichtung. Bei einer Sprache, die
// von rechts nach links läuft, wären sie vertauscht — deshalb hier
// ausdrücklich in die Leserichtung übersetzt.
let vonLinks = fenster?.effectiveUserInterfaceLayoutDirection != .rightToLeft
return EdgeInsets(
top: i.top,
leading: vonLinks ? i.left : i.right,
bottom: i.bottom,
trailing: vonLinks ? i.right : i.left
)
}
private static var windowInsets: UIEdgeInsets { fenster?.safeAreaInsets ?? .zero }
/// Das Fenster, das gerade zählt.
///
/// **Mit Rückfall, und das ist der Punkt.** Zuerst stand hier nur
/// „die Szene, die `foregroundActive` ist" — und genau die gibt es in
/// zwei Momenten nicht: während einer Drehung und kurz nach dem
/// Zurückholen der App. Wer dann fragt, bekommt `nil`, daraus werden
/// Ränder von null, und die bleiben stehen, bis sich die Fläche wieder
/// ändert. Die Knöpfe sitzen dann im Home-Indikator, und zwar dauerhaft,
/// obwohl die Rechnung stimmt.
///
/// Ein Fenster, das die richtigen Ränder kennt, ist in dem Moment
/// trotzdem da — es ist nur nicht „aktiv". Also der Reihe nach: aktiv,
/// sonst sichtbar, sonst irgendeines.
private static var fenster: UIWindow? {
let szenen = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let gewählt = szenen.first { $0.activationState == .foregroundActive }
?? szenen.first { $0.activationState == .foregroundInactive }
?? szenen.first
return gewählt?.keyWindow ?? gewählt?.windows.first { $0.isKeyWindow } ?? gewählt?.windows.first
}
}
+80
View File
@@ -0,0 +1,80 @@
import Foundation
import MediaKit
import Observation
/// Die Suche als Modell: entprellen, fragen, merken.
///
/// **Herausgelöst wie `HomeFeed` und `LibraryFeed`**, und aus demselben
/// Grund: der Fernseher stellt dieselben Fragen an denselben Server, nur ganz
/// anders dargestellt. Kopiert wären dabei die drei Stellen, an denen es
/// nicht offensichtlich ist — die Pause vor der Anfrage, die Mindestlänge und
/// vor allem die Prüfung auf `Task.isCancelled`, ohne die jeder Tastendruck
/// die Ergebnisse des vorigen löscht.
@MainActor
@Observable
final class SearchFeed {
/// Wie viele zuletzt gesuchte Begriffe gemerkt werden.
private static let recentLimit = 8
private static let recentKey = "halo.search.recent"
private(set) var hubs: [MediaRowGroup] = []
private(set) var isSearching = false
private(set) var recent: [String]
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.recent = defaults.stringArray(forKey: Self.recentKey) ?? []
}
/// Sucht — nach einer kurzen Pause.
///
/// Die Pause ist der Kern: aufgerufen wird das bei **jedem** Tastendruck,
/// und ohne sie liefe für „Nachtzug" eine Anfrage je Buchstabe. Ein
/// neuer Tastendruck bricht die laufende Aufgabe ab, und der Abbruch
/// landet im `catch` der Pause — deshalb steht dort kein Fehler, sondern
/// ein `return`.
func search(_ text: String, from backend: (any MediaBackend)?) async {
let bereinigt = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let backend, bereinigt.count >= 2 else {
hubs = []
return
}
do {
try await Task.sleep(for: .milliseconds(300))
} catch {
return
}
isSearching = true
defer { isSearching = false }
do {
hubs = try await backend.search(bereinigt, limit: 30)
if !hubs.isEmpty { remember(bereinigt) }
} catch {
// **`Task.isCancelled` statt `catch is CancellationError`.** Eine
// abgebrochene Netzanfrage kommt als Übertragungsfehler zurück,
// nicht als Abbruch; die naheliegende Prüfung lief deshalb ins
// Leere, und jeder Tastendruck leerte kurz die Liste.
if Task.isCancelled { return }
hubs = []
}
}
// MARK: - Zuletzt gesucht
private func remember(_ eintrag: String) {
recent.removeAll { $0.caseInsensitiveCompare(eintrag) == .orderedSame }
recent.insert(eintrag, at: 0)
recent = Array(recent.prefix(Self.recentLimit))
defaults.set(recent, forKey: Self.recentKey)
}
func forgetRecent() {
recent = []
defaults.removeObject(forKey: Self.recentKey)
}
}
+44
View File
@@ -0,0 +1,44 @@
import Foundation
import Observation
import HaloPlayback
/// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg.
///
/// Eigener Typ statt zweier Felder an `PlaybackSettings`, aus demselben Grund
/// wie bei `LanguagePreferences`: dort geht es um Qualität und Leitung, hier
/// um Darstellung. Zwei Fragen, zwei Orte.
///
/// **Die Werte selbst liegen in `SubtitleStyle`**, also im prüfbaren Teil.
/// Hier steht nur, wie sie einen Neustart überleben.
@MainActor
@Observable
final class SubtitleAppearance {
private enum Key {
static let size = "halo.subtitles.size"
static let palette = "halo.subtitles.palette"
}
private let defaults: UserDefaults
var size: SubtitleStyle.Size {
didSet { defaults.set(size.rawValue, forKey: Key.size) }
}
var palette: SubtitleStyle.Palette {
didSet { defaults.set(palette.rawValue, forKey: Key.palette) }
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// Über `object(forKey:)`, nicht über `integer(forKey:)`: das gäbe für
// „nie gesetzt" eine Null zurück, und die ist keine gültige Stufe.
self.size = (defaults.object(forKey: Key.size) as? Int)
.flatMap(SubtitleStyle.Size.init(rawValue:)) ?? .normal
self.palette = defaults.string(forKey: Key.palette)
.flatMap(SubtitleStyle.Palette.init(rawValue:)) ?? .whiteOnClear
}
var style: SubtitleStyle {
SubtitleStyle(size: size, palette: palette)
}
}
+37
View File
@@ -0,0 +1,37 @@
import HaloPlayback
import Foundation
/// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe
/// Muster wie bei `PlaybackSettings.Quality`: `SubtitleStyle` liegt im
/// prüfbaren Teil und soll keinen Textkatalog kennen.
///
/// **Hier und nicht bei der Einstellungsansicht.** Dort standen sie, und
/// damit übersetzte nur das Telefon sie: am Fernseher steht derselbe Stil im
/// Untertitelmenü des Players, und zwei Listen gleich zu halten ist genau die
/// Art Fehler, die erst auffällt, wenn jemand „Gelb auf Schwarz" wählt und
/// „Weiss auf Schwarz" bekommt.
///
/// Als `String` und nicht als `LocalizedStringKey`: ein `UIMenu` nimmt keinen
/// Schlüssel entgegen, und `Text` kommt mit beidem zurecht.
extension SubtitleStyle.Size {
var label: String {
switch self {
case .small: String(localized: "subtitleStyle.size.small")
case .normal: String(localized: "subtitleStyle.size.normal")
case .large: String(localized: "subtitleStyle.size.large")
case .huge: String(localized: "subtitleStyle.size.huge")
}
}
}
extension SubtitleStyle.Palette {
var label: String {
switch self {
case .whiteOnClear: String(localized: "subtitleStyle.palette.whiteOnClear")
case .whiteOnBlack: String(localized: "subtitleStyle.palette.whiteOnBlack")
case .blackOnWhite: String(localized: "subtitleStyle.palette.blackOnWhite")
case .yellowOnClear: String(localized: "subtitleStyle.palette.yellowOnClear")
case .yellowOnBlack: String(localized: "subtitleStyle.palette.yellowOnBlack")
}
}
}
+112
View File
@@ -0,0 +1,112 @@
// **Die ganze Datei nur für den Fernseher.**
//
// Sie liegt im geteilten Ordner, weil `Halo/Session/` von beiden Zielen
// übersetzt wird — und `TVServices` gibt es auf iOS nicht. Ein `#if` um den
// einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft
// ihn trotzdem und bricht mit „Unable to resolve module dependency:
// TVServices" ab, ohne den Fernseher zu erwähnen. Dieselbe Falle wie bei
// siehe `PlaybackSettings`.
#if os(tvOS)
import Foundation
import MediaKit
import TVServices
/// Legt für das Regal über der App hin, was es zeigen soll.
///
/// **Beim Laden der Startseite, nicht auf einem Zeitgeber.** Das Regal wird
/// gezeichnet, wenn jemand im Menü über der App steht — und dann ist die App
/// nicht gestartet. Was dort steht, ist also immer das, was beim letzten
/// Öffnen zu sehen war. Ein Zeitgeber änderte daran nichts und hielte nur die
/// App wach.
@MainActor
enum TopShelfWriter {
/// Die App-Gruppe, die App und Erweiterung teilen.
static let group = "group.com.scarriffleservices.halo"
/// Die Kante, in der das Regal Plakate zeigt.
///
/// **Apples Mass für die doppelte Auflösung**, und für beide Stufen
/// dasselbe Bild. Ein Apple TV zeichnet das Regal ohnehin doppelt
/// aufgelöst; zwei Adressen abzulegen hiesse, dem Server zwei Grössen
/// desselben Plakats abzuverlangen, damit eine davon nie gebraucht wird.
private static let posterWidth = 666
private static let posterHeight = 1000
static func update(
rows: [MediaRowGroup],
backend: (any MediaBackend)?
) {
guard let backend else { return }
let abschnitte = rows
.prefix(TopShelfSnapshot.sectionLimit)
.map { reihe in
TopShelfSnapshot.Section(
id: reihe.id,
title: reihe.title,
entries: reihe.items
.prefix(TopShelfSnapshot.entryLimit)
.map { item in
TopShelfSnapshot.Entry(
id: item.id,
title: item.rowTitle,
subtitle: item.cardSubtitle,
// **Das Hochkant-Plakat, nicht `thumb`.** Bei
// einer Folge ist `thumb` das Szenenbild; im
// Regal stünde davon ein zufälliger
// Ausschnitt. Dieselbe Rückfallkette wie auf
// den Kacheln.
imageURL: backend.artworkURL(
path: item.posterArtworkPath,
width: posterWidth,
height: posterHeight
),
progress: item.watchProgress
)
}
)
}
.filter { !$0.entries.isEmpty }
TopShelfStore.write(
TopShelfSnapshot(sections: Array(abschnitte)),
group: group
)
// **Und dem System sagen, dass es neu fragen soll.**
//
// Ohne diese Zeile bleibt die Erweiterung ungefragt, und im Header
// steht, was dann geschieht: „If the completion handler is not called
// in a timely manner or is called with empty content the system will
// display the static image instead." Genau das war zu sehen — das
// Plakat aus dem Asset-Katalog, gross und unverändert, während die
// Datei daneben längst drei Reihen enthielt.
//
// Die Erweiterung selbst kann das nicht auslösen: sie läuft nur,
// wenn sie gefragt wird.
TVTopShelfContentProvider.topShelfContentDidChange()
}
/// Was zuletzt abgelegt wurde — in einem Satz, zum Hinsehen.
///
/// **Weil man dem Regal sonst nichts ansieht.** Bleibt es leer, gibt es
/// genau drei Möglichkeiten: die App-Gruppe fehlt, die Datei ist nicht da,
/// oder sie ist da und das System fragt trotzdem nicht. Von aussen sehen
/// alle drei gleich aus — nämlich nach dem grossen Plakat aus dem
/// Asset-Katalog. Diese Zeile unterscheidet sie.
static var status: String {
guard let ort = TopShelfStore.location(group: group) else {
return String(localized: "topShelf.noGroup")
}
guard let abzug = TopShelfStore.read(group: group) else {
let daIst = FileManager.default.fileExists(atPath: ort.path)
return daIst
? String(localized: "topShelf.unreadable")
: String(localized: "topShelf.noFile")
}
let einträge = abzug.sections.reduce(0) { $0 + $1.entries.count }
let zeit = abzug.savedAt.formatted(date: .omitted, time: .shortened)
return String(localized: "topShelf.written \(abzug.sections.count) \(einträge) \(zeit)")
}
}
#endif