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