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,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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user