Schritt sieben. Wiedergabe, Spurwahl, Titelzeile, „Intro überspringen". **Die schwierige Stelle war die Spurwahl.** Unsere Ton- und Untertitelwahl geht als `audioStreamIndex` an Jellyfin und verlangt bei einem Transcode einen **neuen Stream**. Apples eingebauter Spurwähler zeigt dagegen die Auswahlgruppen des **laufenden** Streams — beim Transcode genau eine. Er ist die richtige Oberfläche für die falsche Liste. Unsere Menüs sitzen deshalb über `transportBarCustomMenuItems` daneben; `customInfoViewControllers` wäre falsch, denn die Tonspur ist ein Bedienelement der Wiedergabe und kein Begleitmaterial. **Der Neuaufbau wird angesagt, nicht erklärt.** Jeder Eintrag, der Sekunden mit schwarzem Bild kostet, trägt das als Untertitel — vorher, nicht hinterher. Wer drückt, sieht den Haken sofort wandern, weil das Menü sich schliesst und darin kein Platz für einen Kreisel ist. Das schwarze Bild deckt eine Überlagerung in `contentOverlayView` zu, die hinter den Bedienelementen liegt und keinen Fokus nimmt — `customOverlayViewController` täte beides nicht und machte die Fernbedienung währenddessen tot. Und solange umgebaut wird, sind alle Einträge gesperrt: sonst räumt `restart()` eine Engine ab, die eine andere gerade baut. **Die Regel dahinter ist jetzt prüfbar.** „Wann kostet ein Spurwechsel einen Neuaufbau" lag bisher mitten im Vollzug von `selectAudioStream` und liess sich nur an einem echten Server mit mehreren Tonspuren auslösen. Als `TrackMenu` steht sie neben den übrigen Entscheidungsregeln, mit sieben Tests — darunter der Fall, auf den der Kommentar dort seit jeher hinweist: stimmt die Spurzahl nicht überein, ist die Zuordnung über die Reihenfolge falsch, und es muss neu aufgebaut werden. **Zwei stille Fehler nebenbei.** Die Tonsitzung stand hinter `#if os(iOS)` und wäre auf tvOS nie gesetzt worden — verfügbar ist sie dort seit 9.0, man hätte es nur daran gemerkt, dass sich die Wiedergabe gegen anderen Ton nicht durchsetzt. Und `PlayerModel.downloads` ist jetzt optional statt eines leeren Koordinators: ein leerer bräuchte einen `ModelContainer`, und der ist das Einzige, wofür diese App SwiftData benutzt. **Belegt statt vermutet.** Im Simulator lief die Wiedergabe gegen die Attrappe, und die Titelzeile zeigte „Unbekannt". Statt an AVKit zu drehen, hat ein Protokolleintrag die Frage beantwortet: `externalMetadata` wird gesetzt, der Wert stimmt, und „Unbekannt" ist wörtlich das, was die Attrappe für unbekannte Kennungen liefert. Geblieben ist davon nur die Erkenntnis, dass die Beschriftung auf das Item warten muss — beim Austausch des Players gibt es noch keines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
8.8 KiB
Swift
240 lines
8.8 KiB
Swift
import AVKit
|
|
import SwiftUI
|
|
import FlixyPlayback
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#else
|
|
import AppKit
|
|
#endif
|
|
// Siehe `PlaybackSettings`: `!os(tvOS)` muss dazu, sonst bricht der
|
|
// Abhängigkeits-Sucher an VLCKit ab, das für dieses Ziel gar nicht gebaut wird.
|
|
#if canImport(FlixyVLC) && !os(tvOS)
|
|
import FlixyVLC
|
|
#endif
|
|
|
|
/// Die Ansichtsklasse der jeweiligen Plattform.
|
|
///
|
|
/// 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 des laufendie Engine.
|
|
///
|
|
/// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden
|
|
/// Engines sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
|
|
/// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche
|
|
/// `UIView`.
|
|
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.
|
|
/// VLC macht es seit jeher richtig und reicht seine `videoView` durch;
|
|
/// hier ist es jetzt derselbe Weg.
|
|
let surface: PlayerLayerView
|
|
|
|
/// Ob **diese** Ansicht die Bildfläche gerade haben soll.
|
|
///
|
|
/// **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.
|
|
///
|
|
/// Siehe `PlayerModel.subtitleBottomInset`. Hier steht nur die Zahl; was
|
|
/// sie bedeutet, entscheidet das Modell.
|
|
var bottomInset: CGFloat = 0
|
|
|
|
var body: some View {
|
|
#if canImport(FlixyVLC) && !os(tvOS)
|
|
if let vlcEngine = engine as? VLCPlayerEngine {
|
|
SurfaceContainer(view: vlcEngine.videoView, isActive: isActive, bottomInset: bottomInset)
|
|
} else if engine is AVPlayerEngine {
|
|
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
|
|
} else {
|
|
Color.black
|
|
}
|
|
#else
|
|
if engine is AVPlayerEngine {
|
|
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
|
|
} else {
|
|
Color.black
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// 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
|