Flixy heißt ab hier Halo

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

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

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

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

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

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

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

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
+50
View File
@@ -0,0 +1,50 @@
import AVKit
import SwiftUI
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
/// Der Knopf, der die Empfängerliste öffnet.
///
/// Kein eigener Knopf mit eigener Liste: `AVRoutePickerView` ist die Stelle,
/// die das System dafür vorsieht, und sie kennt Geräte, die eine App nie
/// aufzählen könnte — Apple TVs, AirPlay-Lautsprecher, HomePods im Verbund.
/// Sie nachzubauen hieße, eine unvollständige Liste zu zeigen.
#if canImport(UIKit)
struct AirPlayButton: UIViewRepresentable {
let tint: Color
func makeUIView(context: Context) -> AVRoutePickerView {
let view = AVRoutePickerView()
view.tintColor = .white
view.activeTintColor = UIColor(tint)
// Bild vor Ton: bei einem Film soll die Liste die Fernseher zuerst
// zeigen, nicht die Lautsprecher.
view.prioritizesVideoDevices = true
view.setContentHuggingPriority(.required, for: .horizontal)
return view
}
func updateUIView(_ view: AVRoutePickerView, context: Context) {
view.activeTintColor = UIColor(tint)
}
}
#else
struct AirPlayButton: NSViewRepresentable {
let tint: Color
func makeNSView(context: Context) -> AVRoutePickerView {
let view = AVRoutePickerView()
view.isRoutePickerButtonBordered = false
view.setRoutePickerButtonColor(.white, for: .normal)
view.setRoutePickerButtonColor(NSColor(tint), for: .active)
return view
}
func updateNSView(_ view: AVRoutePickerView, context: Context) {
view.setRoutePickerButtonColor(NSColor(tint), for: .active)
}
}
#endif
+38
View File
@@ -0,0 +1,38 @@
import UIKit
/// Das Fenster auf dem Mac ins Vollbild und zurück.
///
/// **Und warum das umständlich ist.** Für eine „für iPad angepasste" App gibt
/// es dafür keine öffentliche Schnittstelle. UIKit kennt `isFullScreen` nur
/// lesend und nur für Mac Catalyst; einen Umschalter gibt es nirgends. Das
/// Fenster gehört AppKit, und an AppKit kommt diese App nicht heran.
///
/// Was bleibt, ist die **Antwortkette**: `toggleFullScreen:` ist die Aktion,
/// die auch der grüne Knopf und ⌃⌘F auslösen, und UIKit reicht unbeantwortete
/// Aktionen an das tragende AppKit-Fenster weiter. Ob es das tut, ist
/// nirgends zugesagt.
///
/// **Deshalb wird vorher gefragt, nicht hinterher gehofft.**
/// `target(forAction:)` sagt, ob die Aktion jemand entgegennimmt — **ohne sie
/// auszulösen**. Erst wenn es einen Empfänger gibt, erscheint der Knopf. Ein
/// Knopf, der nichts tut, ist schlimmer als keiner: er lässt den Nutzer an
/// sich zweifeln statt an der Plattform.
@MainActor
enum MacFullScreen {
private static let action = NSSelectorFromString("toggleFullScreen:")
/// Ob es hier überhaupt jemanden gibt, der umschalten kann.
///
/// Bei jedem Zeichnen gefragt statt einmal gemerkt: die Antwortkette
/// hängt am gerade aktiven Fenster, und ein einmal ermittelter Wert wäre
/// falsch, sobald es keins gibt — etwa vor dem ersten Erscheinen.
static var isAvailable: Bool {
UIApplication.shared.target(forAction: action, withSender: nil) != nil
}
/// Schaltet um. Gibt zurück, ob jemand zugehört hat.
@discardableResult
static func toggle() -> Bool {
UIApplication.shared.sendAction(action, to: nil, from: nil, for: nil)
}
}
+248
View File
@@ -0,0 +1,248 @@
import SwiftUI
import HaloDesign
/// Die schwebende Videokachel.
///
/// Damit läuft weiter, was man gerade sieht, während man nach dem Nächsten
/// stöbert — der Grund, warum es sie gibt. Plex' eigene Handy-Apps reißen die
/// Wiedergabe beim Zurückgehen ab, und genau in dem Moment will man
/// weiterschauen und nebenher nachsehen, was sonst noch läuft.
///
/// Bewusst schwebend statt als Leiste am unteren Rand: eine Leiste nimmt jeder
/// Liste Platz weg und müsste in jedem Bildschirm berücksichtigt werden. Eine
/// Kachel schwebt darüber, und wem sie im Weg ist, der zieht sie weg.
struct MiniPlayer: View {
let model: PlayerModel
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.horizontalSizeClass) private var sizeClass
/// Seite und Höhe bleiben über Starts hinweg erhalten. Wer die Kachel
/// einmal nach links unten gezogen hat, will sie dort wiederfinden.
@AppStorage("miniPlayer.side") private var storedSide = MiniPlayerPlacement.Side.trailing.rawValue
/// Als Anteil, nicht in Punkten: beim Drehen bleibt sie so an derselben
/// Stelle, statt unten herauszufallen.
@AppStorage("miniPlayer.height") private var storedFraction = 0.85
/// Verschiebung während des Ziehens, noch nicht eingerastet.
@State private var drag: CGSize = .zero
private var side: MiniPlayerPlacement.Side {
MiniPlayerPlacement.Side(rawValue: storedSide) ?? .trailing
}
/// Die selbst eingestellte Breite. Null heißt „noch nie angefasst".
@AppStorage("miniPlayer.width") private var storedWidth: Double = 0
/// Vergrößerung während des Zusammen- oder Auseinanderziehens.
@State private var zoom: CGFloat = 1
/// Die zuletzt tatsächlich gezeigte Breite — schon begrenzt.
///
/// Ohne sie würde beim Loslassen der ungebremste Wert gespeichert, und
/// eine einmal zu weit gezogene Kachel bliebe für immer zu groß.
@State private var lastWidth: CGFloat = 0
/// Auf dem iPad größer voreingestellt — dort ist genug Fläche, und bei
/// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts.
private var defaultWidth: CGFloat { sizeClass == .regular ? 280 : 180 }
private func tileSize(in bounds: CGSize) -> CGSize {
let chosen = storedWidth > 0 ? CGFloat(storedWidth) : defaultWidth
// Nach oben durch den Bildschirm begrenzt: eine Kachel, die breiter
// ist als das Fenster, ließe sich nicht mehr wegziehen.
let upper = max(160, min(bounds.width * 0.75, 560))
let width = min(max(chosen * zoom, 140), upper)
return CGSize(width: width, height: (width / HaloAspectRatio.landscape).rounded())
}
var body: some View {
GeometryReader { geometry in
let tileSize = tileSize(in: geometry.size)
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
let resting = placement.center(side: side, verticalFraction: storedFraction)
let position = placement.clamped(
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
)
tile
.frame(width: tileSize.width, height: tileSize.height)
.onChange(of: tileSize.width, initial: true) { _, width in
lastWidth = width
}
.position(position)
.gesture(dragGesture(in: placement, from: resting))
// Gleichzeitig, damit das Zusammenziehen nicht erst nach dem
// Loslassen greift — beides passiert mit denselben Fingern.
.simultaneousGesture(zoomGesture(in: geometry.size))
// Nur während des Ziehens ohne Animation, sonst würde die
// Kachel dem Finger hinterherfedern statt an ihm zu kleben.
.animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position)
}
.transition(.scale(scale: 0.8).combined(with: .opacity))
}
private var tile: some View {
ZStack {
Color.black
VideoSurface(
engine: model.engine,
surface: model.surface,
isActive: playback.presentation == .mini
)
if model.isBuffering {
ProgressView().tint(.white)
}
corners
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
// es gibt kein Ende, auf das er zulaufen könnte.
if !model.isLive, let fraction = progress {
VStack {
Spacer()
WatchProgressBar(fraction: fraction)
}
}
}
.clipShape(.rect(cornerRadius: HaloRadius.card))
.overlay {
RoundedRectangle(cornerRadius: HaloRadius.card)
.strokeBorder(Color.haloSeparator, lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
.contentShape(.rect)
// Antippen vergrößert, sofort. Vorher brauchte es zwei Berührungen:
// die erste holte die Bedienelemente hervor, die zweite sollte
// öffnen — traf dabei aber den Abspielknopf, der genau in der Mitte
// lag. Vergrößern war damit überhaupt nicht möglich.
.onTapGesture { playback.expand() }
.accessibilityElement(children: .contain)
.accessibilityLabel(Text(model.title))
}
/// Die beiden Knöpfe in den oberen Ecken.
///
/// Dauerhaft sichtbar statt auf Antippen hin. Die Fläche in der Mitte
/// gehört dem Vergrößern — sie ist das, was man an einer solchen Kachel
/// am häufigsten tut, und sie darf nicht mit einem Knopf belegt sein.
/// Unten bleibt frei für den Fortschrittsbalken.
private var corners: some View {
VStack {
HStack {
tileButton(
model.isPlaying ? "pause.fill" : "play.fill",
label: model.isPlaying ? "player.pause" : "player.play"
) {
model.togglePlayPause()
}
Spacer()
tileButton("xmark", label: "miniPlayer.close") {
playback.close()
}
}
Spacer()
HStack {
Spacer()
// Ein eigener Knopf, obwohl auch das Antippen der Fläche
// vergrößert. Beim Ziehen und Aufziehen liegen auf derselben
// Fläche schon zwei Gesten, und was daneben geht, sieht aus
// wie „reagiert nicht". Ein Knopf geht nicht daneben.
tileButton(
"arrow.up.left.and.arrow.down.right",
label: "miniPlayer.expand"
) {
playback.expand()
}
}
}
.padding(HaloSpacing.tight)
// Über dem Fortschrittsbalken, nicht darauf.
.padding(.bottom, HaloSpacing.tight)
}
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
///
/// Die dunkle Scheibe darunter ist nicht Zierde: ein weißes Symbol auf
/// einer hellen Szene verschwindet, und die Kachel zeigt Filmbilder, deren
/// Helligkeit sich jede Sekunde ändert.
private func tileButton(
_ symbol: String,
label: LocalizedStringKey,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.frame(width: 26, height: 26)
.background(.black.opacity(0.55), in: .circle)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(label))
}
/// Größer und kleiner durch Zusammen- und Auseinanderziehen.
///
/// Kein Griff mehr: der saß zwangsläufig irgendwo auf dem Bild, war klein
/// und stritt sich mit dem Verschieben um dieselbe Geste.
private func zoomGesture(in bounds: CGSize) -> some Gesture {
MagnifyGesture()
.onChanged { value in
zoom = value.magnification
}
.onEnded { _ in
// Die **begrenzte** Breite merken, nicht die gezogene: eine
// einmal zu weit aufgezogene Kachel bliebe sonst für immer zu
// groß, auch auf einem kleineren Bildschirm.
storedWidth = Double(lastWidth)
zoom = 1
}
}
private var progress: Double? {
guard let duration = model.duration, duration.seconds > 0 else { return nil }
return model.currentTime.seconds / duration.seconds
}
private func dragGesture(
in placement: MiniPlayerPlacement,
from resting: CGPoint
) -> some Gesture {
DragGesture()
.onChanged { value in
drag = value.translation
}
.onEnded { value in
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
// dem Finger: sonst springt eine Kachel, die man am Rand
// angefasst hat, auf die falsche Seite.
let center = placement.clamped(center: CGPoint(
x: resting.x + value.predictedEndTranslation.width,
y: resting.y + value.predictedEndTranslation.height
))
// Seitlich einrasten, in der Höhe **nicht** — dort bleibt sie,
// wohin man sie gezogen hat.
storedSide = placement.nearestSide(toCenter: center).rawValue
storedFraction = placement.verticalFraction(forCenter: center)
drag = .zero
}
}
}
extension View {
/// Legt die schwebende Kachel über diesen Bildschirm.
///
/// Bewusst hier und nicht ganz oben in der Wurzel: auf dem iPhone soll die
/// Seitenleiste die Kachel verdecken, wenn sie aufgeht, und nicht
/// umgekehrt. Deshalb liegt sie **innerhalb** des Inhalts.
func miniPlayer(_ model: PlayerModel?, isVisible: Bool) -> some View {
overlay {
if let model, isVisible {
MiniPlayer(model: model)
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
import SwiftUI
import HaloDesign
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
///
/// **Warum nicht `Slider`.** Ein `Slider` hat genau eine Spur, und die gehört
/// dem Fortschritt. Was bereits geladen ist, lässt sich dort nicht
/// unterbringen — und das ist die Auskunft, um die es hier geht: wie weit
/// kann ich springen, ohne zu warten. Dahinter etwas zu zeichnen und auf
/// gleiche Ausrichtung zu hoffen ginge so lange gut, bis Apple die
/// Innenabstände ändert.
///
/// Drei Schichten übereinander, alle aus derselben Rechnung:
///
/// Grund ── die ganze Länge, gedämpft
/// Geladen ── bis wohin Daten da sind, heller
/// Gespielt ── bis zur Abspielstelle, in der Akzentfarbe
///
/// **Der Griff ist grösser als der Strich.** Vier Punkte hoch trifft niemand
/// mit dem Finger; der unsichtbare Bereich darüber ist deshalb so hoch wie
/// eine Zeile.
struct PlaybackScrubber: View {
/// Wo abgespielt wird.
let position: Duration
/// Wie lang das Ganze ist.
let duration: Duration
/// Bis wohin geladen ist — `nil`, wenn es niemand weiss.
let buffered: Duration?
/// Ob gerade gezogen wird; dann bleibt der Griff gross.
@Binding var scrubbing: Double?
/// Beim Loslassen, mit dem Ziel.
let onCommit: (Duration) -> Void
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
let onActivity: () -> Void
@Environment(\.haloAccent) private var accent
private static let trackHeight: CGFloat = 5
private static let thumbSize: CGFloat = 13
/// So hoch, dass ein Finger ihn trifft.
private static let hitHeight: CGFloat = 34
var body: some View {
GeometryReader { geometry in
let breite = geometry.size.width
let gespielt = anteil(of: angezeigt) * breite
let geladen = buffered.map { anteil(of: $0) * breite } ?? 0
ZStack(alignment: .leading) {
Capsule()
.fill(.white.opacity(0.22))
.frame(height: Self.trackHeight)
// Nur wenn er weiter reicht als das Gespielte. Sonst sähe man
// einen Balken, der hinter der Abspielstelle zurückbleibt —
// und das behauptete etwas Falsches.
if geladen > gespielt {
Capsule()
.fill(.white.opacity(0.45))
.frame(width: geladen, height: Self.trackHeight)
}
Capsule()
.fill(accent)
.frame(width: gespielt, height: Self.trackHeight)
Circle()
.fill(accent)
.frame(width: scrubbing == nil ? Self.thumbSize : Self.thumbSize * 1.5)
.offset(x: gespielt - Self.thumbSize / 2)
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
}
.frame(height: Self.hitHeight)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { wert in
scrubbing = sekunden(at: wert.location.x, width: breite)
onActivity()
}
.onEnded { wert in
let ziel = sekunden(at: wert.location.x, width: breite)
scrubbing = nil
onCommit(.seconds(ziel))
onActivity()
}
)
}
.frame(height: Self.hitHeight)
.accessibilityElement()
.accessibilityLabel(Text("player.position"))
.accessibilityValue(Text(angezeigt.timecode))
.accessibilityAdjustableAction { richtung in
let schritt = Duration.seconds(15)
switch richtung {
case .increment: onCommit(min(angezeigt + schritt, duration))
case .decrement: onCommit(max(.zero, angezeigt - schritt))
@unknown default: break
}
}
}
/// Was der Regler zeigt: beim Ziehen der Finger, sonst die Wiedergabe.
private var angezeigt: Duration {
scrubbing.map(Duration.seconds) ?? position
}
private func anteil(of zeit: Duration) -> CGFloat {
let gesamt = duration.seconds
guard gesamt > 0 else { return 0 }
return min(max(CGFloat(zeit.seconds / gesamt), 0), 1)
}
private func sekunden(at x: CGFloat, width: CGFloat) -> Double {
guard width > 0 else { return 0 }
return min(max(Double(x / width), 0), 1) * duration.seconds
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
import SwiftUI
import HaloDesign
import HaloPlayback
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
///
/// Ein Sheet statt eines Dialogs: die Leiter hat je nach Quelle bis zu
/// dreizehn Stufen, und `confirmationDialog` zeigt nur eine Liste von Knöpfen
/// ohne Ordnung. Hier stehen sie nach Auflösung gruppiert — und ganz oben,
/// was gerade über die Leitung kommt.
struct QualitySheet: View {
let model: PlayerModel
@Environment(\.haloAccent) private var accent
/// **Als Fragezeichen**, nicht als Behauptung.
///
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
/// an, wenn der Gegenstand nicht im Baum steht — und ein Sheet hängt an
/// einem eigenen Baum. Genau daran ist der Player eingefroren, und zu
/// sehen war nichts davon. Der Player reicht die Einstellungen jetzt
/// ausdrücklich herein; das Fragezeichen sorgt dafür, dass ein Vergessen
/// künftig nur eine fehlende Zeile kostet statt der ganzen App.
@Environment(PlaybackSettings.self) private var settings: PlaybackSettings?
@Environment(\.dismiss) private var dismiss
@State private var showsEveryRung = false
private var everyday: [PlayerQuality] {
PlayerQuality.everyday(forSourceHeight: model.sourceHeight)
}
private var options: [PlayerQuality] {
PlayerQuality.available(forSourceHeight: model.sourceHeight)
}
var body: some View {
NavigationStack {
List {
current
Section {
ForEach(everyday) { option in
row(for: option)
}
} footer: {
Text("quality.audioOnly.hint")
}
// Aufgeklappt nur, wer es wirklich will. Für den Alltag
// reicht „eine Stufe kleiner"; die volle Leiter braucht, wer
// eine bestimmte Bitrate erzwingen muss.
Section {
DisclosureGroup(isExpanded: $showsEveryRung) {
ForEach(groupedHeights, id: \.self) { height in
ForEach(rungs(height: height), id: \.self) { rung in
row(for: .limited(rung), detail: "\(height)p")
}
}
} label: {
Text("quality.everyRung")
}
}
}
.navigationTitle(Text("player.quality"))
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.done") { dismiss() }
}
}
}
.haloResizableSheet()
}
/// Was gerade läuft — gemessen, nicht gewünscht.
@ViewBuilder
private var current: some View {
if settings?.showsBitrate == true, let statistics = model.statistics {
Section {
if let observed = statistics.observedBitrate {
LabeledContent("quality.current.bitrate", value: bitrateText(observed))
}
if let resolution = statistics.resolution {
LabeledContent("quality.current.resolution", value: resolution)
}
if let dropped = statistics.droppedFrames, dropped > 0 {
LabeledContent("quality.current.dropped", value: "\(dropped)")
}
// **Ob der eigene Vorrat läuft, und wie weit.** Von aussen ist
// das nicht zu sehen: läuft er nicht, spielt es trotzdem, nur
// ohne Vorsprung — und genau so sieht auch ein Vorrat aus, der
// läuft und nichts bekommt.
if let vorrat = model.prefetchStatus {
LabeledContent("quality.current.prefetch", value: vorrat)
}
// **Die Zahl, die alles entscheidet.** Eine Playlist mit
// einer Stufe hat nichts zum Umschalten — dann greift jedes
// Nachregeln ins Leere, ganz gleich was bestellt wurde.
if let variants = statistics.variantCount {
LabeledContent("quality.current.variants", value: "\(variants)")
}
// Der Puffer ist der einzige echte Frühwarner: schrumpft er
// schneller, als die Zeit vergeht, kommt weniger herein als
// hinausgeht.
if let buffer = statistics.bufferedAhead {
LabeledContent("quality.current.buffer", value: secondsText(buffer))
}
if statistics.stallCount > 0 {
LabeledContent(
"quality.current.stalls",
value: String(
localized: "quality.stalls \(statistics.stallCount) \(secondsText(statistics.stalledTime))"
)
)
}
} header: {
Text("quality.current")
} footer: {
// Der Unterschied ist keine Haarspalterei: die Obergrenze sagt,
// was erlaubt ist, die gemessene Rate, was ankommt. Ruckelt es
// trotz hoher Stufe, steht die Antwort hier.
//
// Und wenn es nur eine Stufe gibt, gehört auch das gesagt:
// sonst sucht man den Fehler beim Player, während in
// Wahrheit nie eine Leiter bestellt wurde.
if statistics.variantCount == 1 {
Text("quality.current.noLadder")
} else {
Text("quality.current.hint")
}
}
}
}
private var groupedHeights: [Int] {
var seen = Set<Int>()
return options.compactMap { option -> Int? in
guard case .limited(let rung) = option else { return nil }
return seen.insert(rung.height).inserted ? rung.height : nil
}
}
private func rungs(height: Int) -> [PlayerQuality.Rung] {
options.compactMap { option in
guard case .limited(let rung) = option, rung.height == height else { return nil }
return rung
}
}
private func row(for option: PlayerQuality, detail: String? = nil) -> some View {
Button {
Task { await model.select(quality: option) }
dismiss()
} label: {
HStack {
Text(label(for: option))
.foregroundStyle(Color.haloTextPrimary)
if let detail {
Text(detail)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
Spacer()
if option == model.quality {
Image(systemName: "checkmark").foregroundStyle(accent)
}
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private func label(for option: PlayerQuality) -> String {
switch option {
case .automatic: String(localized: "quality.automatic")
case .original: String(localized: "quality.original")
case .audioOnly: String(localized: "quality.audioOnly")
// In der kurzen Liste zählt die Auflösung, nicht die Zahl dahinter:
// „720p" sagt mehr als „3 Mbps", wenn man nur kleiner will.
case .limited(let rung): "\(rung.height)p · \(bitrateText(rung.bitrate))"
}
}
/// Sekunden mit einer Nachkommastelle — für Puffer und Wartezeit.
private func secondsText(_ duration: Duration) -> String {
let seconds = Double(duration.components.seconds)
+ Double(duration.components.attoseconds) / 1e18
return String(localized: "quality.seconds \(String(format: "%.1f", seconds))")
}
/// Kilobit unterhalb eines Megabits, darüber Megabit mit einer Stelle.
///
/// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps"
/// schlechter als „64 kbps".
private func bitrateText(_ kilobits: Int) -> String {
guard kilobits >= 1_000 else {
return String(localized: "quality.kilobits \(kilobits)")
}
let megabits = Double(kilobits) / 1_000
let rounded = megabits.rounded() == megabits
? String(Int(megabits))
: String(format: "%.1f", megabits)
return String(localized: "quality.megabits \(rounded)")
}
}
+152
View File
@@ -0,0 +1,152 @@
import SwiftUI
import HaloDesign
import HaloPlayback
import MediaKit
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
///
/// Getrennt, weil es zwei verschiedene Fragen sind: die Tonspur wechselt man
/// einmal am Anfang, die Untertitel mitten im Film. Beide in einem Sheet
/// bedeutete jedes Mal Scrollen an der anderen Liste vorbei.
struct TrackPickerSheet: View {
enum Kind: Identifiable {
case audio
case subtitles
var id: Self { self }
var title: LocalizedStringKey {
switch self {
case .audio: "player.audioTracks"
case .subtitles: "player.subtitleTracks"
}
}
}
let model: PlayerModel
let kind: Kind
@Environment(\.dismiss) private var dismiss
@Environment(\.haloAccent) private var accent
var body: some View {
NavigationStack {
List {
switch kind {
case .audio where model.isOffline:
// **Offline zählt, was in der Datei liegt.** Die Liste des
// Servers gibt es hier nicht: `chooseAudioStream` läuft nur
// auf dem Online-Weg. Solange sie trotzdem gefragt wurde,
// blieb die Auswahl für heruntergeladene Titel leer — der
// Knopf war da, das Sheet zeigte nichts.
ForEach(model.audioTracks) { track in
row(title: track.displayName, isSelected: model.selectedAudioTrack == track) {
model.selectAudioTrack(track)
dismiss()
}
}
case .audio:
// **Die Spuren der Datei, nicht die des Players.** Rechnet
// der Server um, kennt der Player genau eine — die, für
// die er sich entschieden hat. Zur Auswahl steht, was in
// der Datei liegt.
ForEach(model.audioStreams) { stream in
row(
title: stream.name,
isSelected: model.selectedAudioStream == stream
) {
Task { await model.selectAudioStream(stream) }
dismiss()
}
}
if let gemerkt = model.rememberedTrackName {
Section {
Button(role: .destructive) {
model.forgetRememberedTrack()
} label: {
Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward")
}
.listRowBackground(Color.haloSurface)
} header: {
Text("player.rememberedTrack")
} footer: {
// Eine automatische Handlung, die man nicht sehen
// kann, ist von einem Fehler nicht zu
// unterscheiden.
Text("player.rememberedTrackExplanation \(gemerkt)")
}
}
case .subtitles where model.isOffline:
row(
title: String(localized: "player.subtitlesOff"),
isSelected: model.selectedSubtitleTrack == nil
) {
model.selectSubtitleTrack(nil)
dismiss()
}
ForEach(model.subtitleTracks) { track in
row(title: track.displayName, isSelected: model.selectedSubtitleTrack == track) {
model.selectSubtitleTrack(track)
dismiss()
}
}
case .subtitles:
// „Aus" gehört in dieselbe Liste wie die Spuren, nicht als
// eigener Schalter daneben: es ist eine Auswahl unter
// mehreren, keine getrennte Eigenschaft.
row(
title: String(localized: "player.subtitlesOff"),
isSelected: model.selectedSubtitleStream == nil
) {
Task { await model.selectSubtitleStream(nil) }
dismiss()
}
// Wie beim Ton die Spuren der Datei: ein umgerechneter
// Stream bringt genau einen mit, und der heisst „CC".
ForEach(model.subtitleStreams) { stream in
row(
title: stream.name,
isSelected: model.selectedSubtitleStream == stream
) {
Task { await model.selectSubtitleStream(stream) }
dismiss()
}
}
}
}
.scrollContentBackground(.hidden)
.background(Color.haloCanvas)
.navigationTitle(Text(kind.title))
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.haloMediumSheet()
}
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text(title)
.foregroundStyle(Color.haloTextPrimary)
Spacer()
if isSelected {
Image(systemName: "checkmark").foregroundStyle(accent)
}
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.listRowBackground(Color.haloSurface)
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
}
}
+217
View File
@@ -0,0 +1,217 @@
import AVKit
import SwiftUI
import HaloPlayback
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
/// Die Ansichtsklasse der jeweiligen Plattform.
///
/// UIKit und AppKit heißen verschieden und meinen dasselbe. Ein Name dafür
/// spart in dieser Datei ein Dutzend `#if`.
#if canImport(UIKit)
typealias PlatformView = UIView
#else
typealias PlatformView = NSView
#endif
/// Die Bildfläche der laufenden Engine.
struct VideoSurface: View {
let engine: (any VideoPlayerEngine)?
/// Die Bildebene des Systemplayers, gehalten vom Modell.
///
/// **Nicht** hier gebaut. Eine `AVPlayerLayer`, die in `makeUIView`
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
let surface: PlayerLayerView
/// Ob **diese** Ansicht die Bildfläche gerade haben soll.
///
/// **Der Kern des Schwarzbilds.** Kachel und Vollbild bestehen beim
/// Wechsel kurz gleichzeitig, und beide holen sich die Fläche in ihre
/// Hülle. Eine Ansicht hat aber nur einen Vorgesetzten — wer zuletzt
/// aktualisiert, gewinnt, und das war nicht verlässlich die richtige.
/// Blieb die Fläche bei der Kachel, die gleich darauf verschwand, lief
/// im Vollbild der Ton weiter und das Bild blieb schwarz.
///
/// Die Hülle allein genügte dagegen nicht: sie verhindert, dass das
/// Abräumen der einen der anderen etwas wegnimmt, entscheidet aber
/// nicht, wer sie bekommen **soll**. Das steht hier.
var isActive = true
/// Wie viel unten frei bleibt, damit Untertitel nicht abgeschnitten werden.
///
/// Hier steht nur die Zahl; wann sie ungleich null ist, entscheidet
/// `PlayerView.surface(for:)`.
var bottomInset: CGFloat = 0
var body: some View {
if engine is AVPlayerEngine {
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
} else {
Color.black
}
}
}
/// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
///
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet,
/// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das
/// UIKit.
#if canImport(UIKit)
final class PlayerLayerView: UIView {
override static var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
/// Die Fläche, auf der wirklich Bild steht — ohne die schwarzen Ränder.
///
/// Vom Player ausgerechnet, aus dem, was gerade ankommt. Vor dem ersten
/// Bild ist sie leer; wer sie benutzt, muss das prüfen.
var videoRect: CGRect { playerLayer.videoRect }
}
#else
/// Auf dem Mac gibt es kein `layerClass`: eine `NSView` bekommt ihre Ebene
/// zugewiesen, statt ihre Art vorzugeben.
final class PlayerLayerView: NSView {
let playerLayer = AVPlayerLayer()
/// Siehe die UIKit-Fassung oben.
var videoRect: CGRect { playerLayer.videoRect }
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layer = playerLayer
// Ohne das bleibt die Ebene beim Vergrößern des Fensters stehen, wo
// sie war, und das Bild sitzt in der Ecke.
layer?.needsDisplayOnBoundsChange = true
autoresizingMask = [.width, .height]
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("nicht aus einem Storyboard") }
}
#endif
/// Hängt eine bestehende `UIView` in den SwiftUI-Baum.
///
/// Umhängen statt neu bauen: dieselbe `UIView` wandert zwischen Kachel und
/// Vollbild hin und her, und die Wiedergabe merkt davon nichts.
///
/// Sie wird dabei **nicht** unmittelbar zurückgegeben, sondern in eine eigene
/// Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es beide
/// Ansichten kurz gleichzeitig gibt — beim Wechsel zwischen Kachel und
/// Vollbild: eine `UIView` hat nur einen Vorgesetzten, und wer sie zuletzt
/// bekommt, gewinnt. Gab SwiftUI sie unmittelbar heraus, nahm das Abräumen der
/// einen Ansicht der anderen die Bildfläche weg, und übrig blieb eine schwarze
/// Kachel — in einer fast schwarzen Oberfläche also scheinbar gar keine.
///
/// Mit eigener Hülle gehört jeder Ansicht ihre eigene, und `updateUIView` holt
/// die Fläche zurück, falls sie zwischenzeitlich woanders eingehängt wurde.
#if canImport(UIKit)
private struct SurfaceContainer: UIViewRepresentable {
let view: PlatformView
let isActive: Bool
let bottomInset: CGFloat
func makeUIView(context: Context) -> SurfaceHostView {
let host = SurfaceHostView()
host.backgroundColor = .black
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
if isActive { host.adopt(view) }
return host
}
func updateUIView(_ host: SurfaceHostView, context: Context) {
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
// Wer nicht dran ist, greift auch nicht zu. Ohne diese Zeile holen
// sich beide Ansichten dieselbe Fläche, und die Reihenfolge
// entscheidet — mal richtig, mal schwarz.
guard isActive, view.superview !== host else { return }
host.adopt(view)
}
}
#else
private struct SurfaceContainer: NSViewRepresentable {
let view: PlatformView
let isActive: Bool
let bottomInset: CGFloat
func makeNSView(context: Context) -> SurfaceHostView {
let host = SurfaceHostView()
host.wantsLayer = true
host.layer?.backgroundColor = NSColor.black.cgColor
host.bottomInset = bottomInset
if isActive { host.adopt(view) }
return host
}
func updateNSView(_ host: SurfaceHostView, context: Context) {
host.bottomInset = bottomInset
guard isActive, view.superview !== host else { return }
host.adopt(view)
}
}
#endif
/// Die Hülle, die der Bildfläche ihren Platz zuweist.
///
/// **Warum nicht mehr `autoresizingMask`.** Bisher bekam die Fläche die
/// Grösse der Hülle und eine Maske, die sie mitwachsen liess. Eine Maske hält
/// Ränder aber **verhältnismässig**: ein Einzug von zwanzig Punkten wüchse
/// beim Drehen mit und wäre im Querformat plötzlich vierzig. Ein fester Rand
/// verlangt echtes Layout — und das sind genau diese vier Zeilen.
#if canImport(UIKit)
final class SurfaceHostView: UIView {
/// Was ringsum frei bleibt. Heute nur unten, siehe `VideoSurface`.
var contentInset: UIEdgeInsets = .zero {
didSet {
guard contentInset != oldValue else { return }
setNeedsLayout()
}
}
/// Hängt die Fläche ein — ohne Maske, die Grösse kommt aus `layoutSubviews`.
func adopt(_ view: UIView) {
view.translatesAutoresizingMaskIntoConstraints = true
view.autoresizingMask = []
addSubview(view)
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
subviews.first?.frame = bounds.inset(by: contentInset)
}
}
#else
final class SurfaceHostView: NSView {
var bottomInset: CGFloat = 0 {
didSet {
guard bottomInset != oldValue else { return }
needsLayout = true
}
}
func adopt(_ view: NSView) {
view.translatesAutoresizingMaskIntoConstraints = true
view.autoresizingMask = []
addSubview(view)
needsLayout = true
}
override func layout() {
super.layout()
// Auf dem Mac liegt der Ursprung unten links: einzurücken heisst
// hier, den Ursprung anzuheben und die Höhe zu kürzen.
var rahmen = bounds
rahmen.origin.y += bottomInset
rahmen.size.height -= bottomInset
subviews.first?.frame = rahmen
}
}
#endif