macOS: die App läuft jetzt auf dem Mac

Und dein Argument dafür war das beste: auf dem Mac kann ich selbst hinsehen.
Kein Simulator-Tunnel, der mitten im Prüfen abstürzt — `screencapture` auf das
Fenster der App, und zwar nur auf ihres, nicht auf den Bildschirm.

**Ein eigenes Ziel, nicht ein gemeinsames.** Die Info.plist der iOS-App ist
voller iOS-Angaben: Startbildschirm, erlaubte Ausrichtungen, Live Activities.
Die alle bedingt zu machen hieße, dass jede spätere Änderung an der iOS-Seite
über eine Stelle geht, die auch den Mac betrifft. Zwei Ziele sind ein paar
Zeilen mehr und eine Sorge weniger.

**Was den Unterschied ausmacht, steht an einer Stelle.** Ein neuer
`PlatformShims` in FlixyDesign übersetzt, was auf beiden Seiten verschieden
heißt: schmale Titelzeile, halbhohes Blatt, Eingabefelder ohne
Großschreibung, die Plätze in der Werkzeugleiste, die Zwischenablage. Zwanzig
Ansichten benutzen ihn, statt zwanzigmal `#if os(iOS)` zu tragen — sonst
wandert die Fallunterscheidung in jede neue Datei und wird beim übernächsten
Bildschirm vergessen.

Wirklich verschieden sind nur fünf Stellen: die Bildfläche (`UIView` gegen
`NSView`), der AirPlay-Knopf, die Ausrichtungssperre (auf dem Mac gibt es
nichts zu sperren), der App-Delegat für Hintergrund-Downloads und das
Anmeldefenster, das ein anderes Fenster als Anker braucht.

**Das Karussell blättert jetzt mit einem einrastenden Streifen** statt mit
`TabView(.page)` — den Seitenstil gibt es auf dem Mac nicht. Das ist nebenbei
die bessere Lösung: das verschachtelte TabView hatte die Eigenschaft, beim
Wechsel kurz zwei Seiten gleichzeitig zu zeigen.

**Was auf dem Mac fehlt und warum:** VLC. Das Binärpaket ist auf iOS
festgelegt; sein xcframework enthält zwar Mac-Teile, aber das ist eine eigene
Runde. Solange fällt die Wiedergabe dort auf den Systemplayer zurück — MKV und
DTS gehen also noch nicht. Live Activities gibt es auf dem Mac nicht, Bild-in-
Bild startet von Hand statt beim Verlassen der App.

Gesehen: die App startet, der Anmeldebildschirm steht, beide Wege sind da.
Angemeldet habe ich mich auf dem Mac **nicht** — das ginge nur, indem ich
einen Token in deinen Schlüsselbund schreibe, und dann fragt macOS dich beim
ersten Zugriff nach dem Passwort. Das machst du mit einem Klick selbst.

iOS baut unverändert, 263 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 15:45:13 +02:00
co-authored by Claude Opus 5
parent 21c5a8b46b
commit d54954b9c2
33 changed files with 480 additions and 111 deletions
+23
View File
@@ -1,5 +1,10 @@
import AVKit
import SwiftUI
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
/// Der Knopf, der die Empfängerliste öffnet.
///
@@ -7,6 +12,7 @@ import SwiftUI
/// 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
@@ -25,3 +31,20 @@ struct AirPlayButton: UIViewRepresentable {
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
+13 -1
View File
@@ -4,7 +4,9 @@ import MediaKit
import Observation
import FlixyDownloads
import FlixyPlayback
#if canImport(FlixyVLC)
import FlixyVLC
#endif
import PlexKit
/// Führt einen Abspielwunsch aus.
@@ -712,9 +714,12 @@ final class PlayerModel {
self?.restoreFromPictureInPicture?()
}
let controller = AVPictureInPictureController(playerLayer: surface.playerLayer)
#if os(iOS)
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
// Wiedergabe anzuhalten.
// Wiedergabe anzuhalten. Auf dem Mac verschwindet eine App nicht
// in den Hintergrund — dort startet Bild-in-Bild von Hand.
controller?.canStartPictureInPictureAutomaticallyFromInline = true
#endif
controller?.delegate = bridge
pictureInPictureDelegate = bridge
pictureInPicture = controller
@@ -730,12 +735,19 @@ final class PlayerModel {
}
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
#if canImport(FlixyVLC)
switch decision.engine {
case .vlc: VLCPlayerEngine()
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
// genau darin wechselt AVPlayer die Bitrate von selbst.
case .avPlayer, .none: AVPlayerEngine()
}
#else
// Auf dem Mac gibt es VLC (noch) nicht: das Binärpaket ist auf iOS
// festgelegt. Was der Systemplayer nicht öffnen kann, scheitert dort
// mit einer Meldung, statt still schwarz zu bleiben.
AVPlayerEngine()
#endif
}
private func observe(_ engine: any VideoPlayerEngine) {
+4
View File
@@ -101,7 +101,11 @@ struct PlayerView: View {
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
// und im Querformat erst recht nicht.
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
#if os(iOS)
// Die Statusleiste gehört nicht über einen Film. Auf dem Mac gibt es
// keine.
.statusBarHidden(true)
#endif
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
.landscapeWhileVisible(settings.locksLandscape)
.contentShape(.rect)
+2 -2
View File
@@ -54,14 +54,14 @@ struct QualitySheet: View {
}
}
.navigationTitle(Text("player.quality"))
.navigationBarTitleDisplayMode(.inline)
.flixyInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.done") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
.flixyResizableSheet()
}
/// Was gerade läuft — gemessen, nicht gewünscht.
+2 -2
View File
@@ -69,14 +69,14 @@ struct TrackPickerSheet: View {
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.navigationTitle(Text(kind.title))
.navigationBarTitleDisplayMode(.inline)
.flixyInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.presentationDetents([.medium])
.flixyMediumSheet()
}
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
+96 -19
View File
@@ -1,7 +1,24 @@
import AVKit
import SwiftUI
import FlixyPlayback
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
#if canImport(FlixyVLC)
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.
///
@@ -22,14 +39,21 @@ struct VideoSurface: View {
let surface: PlayerLayerView
var body: some View {
switch engine {
case is AVPlayerEngine:
UIViewContainer(view: surface)
case let vlcEngine as VLCPlayerEngine:
UIViewContainer(view: vlcEngine.videoView)
default:
#if canImport(FlixyVLC)
if let vlcEngine = engine as? VLCPlayerEngine {
SurfaceContainer(view: vlcEngine.videoView)
} else if engine is AVPlayerEngine {
SurfaceContainer(view: surface)
} else {
Color.black
}
#else
if engine is AVPlayerEngine {
SurfaceContainer(view: surface)
} else {
Color.black
}
#endif
}
}
@@ -38,10 +62,31 @@ struct VideoSurface: View {
/// 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 }
}
#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()
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.
///
@@ -58,25 +103,57 @@ final class PlayerLayerView: UIView {
///
/// 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.
private struct UIViewContainer: UIViewRepresentable {
let view: UIView
#if canImport(UIKit)
private struct SurfaceContainer: UIViewRepresentable {
let view: PlatformView
func makeUIView(context: Context) -> UIView {
func makeUIView(context: Context) -> PlatformView {
let host = UIView()
host.backgroundColor = .black
adopt(into: host)
adopt(view, into: host)
return host
}
func updateUIView(_ host: UIView, context: Context) {
func updateUIView(_ host: PlatformView, context: Context) {
guard view.superview !== host else { return }
adopt(into: host)
}
private func adopt(into host: UIView) {
view.translatesAutoresizingMaskIntoConstraints = true
view.frame = host.bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
host.addSubview(view)
adopt(view, into: host)
}
}
#else
private struct SurfaceContainer: NSViewRepresentable {
let view: PlatformView
func makeNSView(context: Context) -> PlatformView {
let host = NSView()
host.wantsLayer = true
host.layer?.backgroundColor = NSColor.black.cgColor
adopt(view, into: host)
return host
}
func updateNSView(_ host: PlatformView, context: Context) {
guard view.superview !== host else { return }
adopt(view, into: host)
}
}
#endif
/// Hängt die Fläche in ihre Hülle.
///
/// Sie wird **nicht** unmittelbar an SwiftUI zurückgegeben, sondern in eine
/// eigene Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es
/// Kachel und Vollbild kurz gleichzeitig gibt: eine Ansicht hat nur einen
/// Vorgesetzten, und wer sie zuletzt bekommt, gewinnt. Gab SwiftUI sie
/// unmittelbar heraus, nahm das Abräumen der einen der anderen das Bild weg.
private func adopt(_ view: PlatformView, into host: PlatformView) {
#if canImport(UIKit)
view.translatesAutoresizingMaskIntoConstraints = true
view.frame = host.bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
#else
view.translatesAutoresizingMaskIntoConstraints = true
view.frame = host.bounds
view.autoresizingMask = [.width, .height]
#endif
host.addSubview(view)
}