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
+12 -1
View File
@@ -1,5 +1,8 @@
import SwiftData import SwiftData
import SwiftUI import SwiftUI
#if os(iOS)
import UIKit
#endif
import FlixyDesign import FlixyDesign
import FlixyDownloads import FlixyDownloads
import FlixyPlayback import FlixyPlayback
@@ -9,10 +12,14 @@ import PlexKit
@main @main
struct FlixyApp: App { struct FlixyApp: App {
#if os(iOS)
/// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von /// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von
/// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen /// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen
/// eigenen Weg — nur den klassischen App-Delegaten. /// eigenen Weg — nur den klassischen App-Delegaten. Auf dem Mac wird eine
/// App nicht aus dem Speicher geworfen, während sie lädt; dort gibt es
/// weder das Signal noch den Bedarf.
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#endif
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und /// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende /// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
@@ -82,7 +89,9 @@ struct FlixyApp: App {
// Absicherung für SwiftUI-Vorschauen, die sie nicht lesen. // Absicherung für SwiftUI-Vorschauen, die sie nicht lesen.
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.task { .task {
#if os(iOS)
appDelegate.downloads = downloads appDelegate.downloads = downloads
#endif
await downloads.restore() await downloads.restore()
// Beim Start einmal prüfen: während die App weg war, // Beim Start einmal prüfen: während die App weg war,
// können Folgen gesehen oder gelöscht worden sein. // können Folgen gesehen oder gelöscht worden sein.
@@ -96,6 +105,7 @@ struct FlixyApp: App {
} }
} }
#if os(iOS)
/// Der klassische App-Delegat, für das eine, was SwiftUI nicht abdeckt. /// Der klassische App-Delegat, für das eine, was SwiftUI nicht abdeckt.
@MainActor @MainActor
final class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
@@ -140,3 +150,4 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
} }
} }
} }
#endif
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Flixy</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.scarriffleservices.flixy.auth</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.scarriffleservices.flixy</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.entertainment</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHumanReadableCopyright</key>
<string></string>
</dict>
</plist>
+13
View File
@@ -1,3 +1,4 @@
#if os(iOS)
import SwiftUI import SwiftUI
import UIKit import UIKit
@@ -74,3 +75,15 @@ extension View {
modifier(LandscapeWhileVisible(isEnabled: isEnabled)) modifier(LandscapeWhileVisible(isEnabled: isEnabled))
} }
} }
#else
import SwiftUI
/// Auf dem Mac dreht sich nichts.
///
/// Ein Fenster hat keine Ausrichtung, die sich sperren ließe — der Nutzer
/// bestimmt seine Größe. Die Stelle bleibt trotzdem bestehen, damit der
/// Player auf beiden Plattformen dieselbe Zeile schreiben kann.
extension View {
func landscapeWhileVisible(_ isEnabled: Bool = true) -> some View { self }
}
#endif
+13 -3
View File
@@ -1,6 +1,10 @@
import AuthenticationServices import AuthenticationServices
import Foundation import Foundation
#if canImport(UIKit)
import UIKit import UIKit
#else
import AppKit
#endif
/// Öffnet die Anmeldeseite von Plex in einem Fenster innerhalb der App. /// Öffnet die Anmeldeseite von Plex in einem Fenster innerhalb der App.
/// ///
@@ -94,13 +98,19 @@ extension PlexWebAuthenticator {
extension PlexWebAuthenticator: ASWebAuthenticationPresentationContextProviding { extension PlexWebAuthenticator: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
// Das Fenster der App. Über die verbundenen Szenen gesucht, weil eine // Das Fenster der App. Gesucht statt gehalten, weil eine SwiftUI-App
// SwiftUI-App keinen festen Verweis darauf hat — und ein frisch // keinen festen Verweis darauf hat — und ein frisch erzeugtes Fenster
// erzeugtes Fenster erschiene hinter dem sichtbaren. // erschiene hinter dem sichtbaren.
#if canImport(UIKit)
let scene = UIApplication.shared.connectedScenes let scene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene } .compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive } .first { $0.activationState == .foregroundActive }
return scene?.keyWindow ?? ASPresentationAnchor() return scene?.keyWindow ?? ASPresentationAnchor()
#else
return NSApplication.shared.keyWindow
?? NSApplication.shared.windows.first
?? ASPresentationAnchor()
#endif
} }
} }
+2 -2
View File
@@ -39,10 +39,10 @@ struct MediaDetailView: View {
} }
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(state.value?.title ?? route.title) .navigationTitle(state.value?.title ?? route.title)
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbar { .toolbar {
if let item = state.value { if let item = state.value {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .flixyTrailing) {
ItemActionsMenu(item: item) { await load() } ItemActionsMenu(item: item) { await load() }
} }
} }
+1 -1
View File
@@ -26,7 +26,7 @@ struct DownloadsView: View {
} }
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.downloads")) .navigationTitle(Text("sidebar.downloads"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.confirmationDialog( .confirmationDialog(
Text("downloads.removeConfirm"), Text("downloads.removeConfirm"),
isPresented: Binding( isPresented: Binding(
+43 -35
View File
@@ -4,14 +4,19 @@ import MediaKit
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln. /// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
/// ///
/// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wandert der Aufmacher /// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wandert der Aufmacher alle
/// alle paar Sekunden weiter und lässt sich auch von Hand durchblättern; die /// paar Sekunden weiter und lässt sich auch von Hand durchblättern; die Punkte
/// Punkte darunter zeigen, wie viele es sind und wo man gerade ist. /// darunter zeigen, wie viele es sind und wo man gerade ist.
/// ///
/// Das Weiterwandern hält an, sobald jemand selbst blättert — der Zeitgeber /// Das Weiterwandern hört auf, sobald jemand die Fläche berührt. Wer hier die
/// hängt an der aktuellen Seite und fängt bei jedem Wechsel von vorn an. /// Hand hat, fährt selbst — sonst rutscht der Aufmacher genau in der Sekunde
/// Sonst rutschte der Aufmacher genau in dem Moment weiter, in dem man sich /// weiter, in der man nach „Abspielen" greift, und man startet den falschen
/// einen Titel ansieht. /// Film. Das ist mir beim Ausprobieren selbst passiert.
///
/// Geblättert wird mit einem einrastenden Streifen, nicht mit `TabView`: den
/// Seitenstil gibt es auf dem Mac nicht, und ein TabView im TabView hatte
/// nebenbei die Eigenschaft, beim Wechsel kurz zwei Seiten gleichzeitig zu
/// zeigen.
struct HeroCarousel: View { struct HeroCarousel: View {
let items: [MediaItem] let items: [MediaItem]
@@ -21,47 +26,48 @@ struct HeroCarousel: View {
/// Sekunden ist der Satz zu Ende, bevor man ihn gelesen hat. /// Sekunden ist der Satz zu Ende, bevor man ihn gelesen hat.
private static let dwell: Duration = .seconds(7) private static let dwell: Duration = .seconds(7)
@State private var index = 0 @State private var shownID: String?
/// Ob noch von selbst weitergewandert wird. /// Ob noch von selbst weitergewandert wird.
///
/// Sobald jemand die Fläche berührt, hört es auf — und zwar für gut. Wer
/// hier die Hand hat, fährt selbst. Andernfalls rutscht der Aufmacher
/// genau in der Sekunde weiter, in der man nach „Abspielen" greift, und
/// man startet den falschen Film. Das ist mir beim Ausprobieren selbst
/// passiert.
@State private var isAdvancing = true @State private var isAdvancing = true
/// Bewegung, die von selbst losgeht, ist für manche Menschen unangenehm /// Bewegung, die von selbst losgeht, ist für manche Menschen unangenehm
/// bis unbrauchbar. Wer sie abgestellt hat, blättert von Hand. /// bis unbrauchbar. Wer sie abgestellt hat, blättert von Hand.
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.flixyAccent) private var accent @Environment(\.flixyAccent) private var accent
private var currentIndex: Int {
items.firstIndex { $0.id == shownID } ?? 0
}
var body: some View { var body: some View {
VStack(spacing: FlixySpacing.regular) { VStack(spacing: FlixySpacing.regular) {
TabView(selection: $index) { ScrollView(.horizontal) {
ForEach(Array(items.enumerated()), id: \.element.id) { position, item in LazyHStack(spacing: 0) {
ForEach(items) { item in
HeroSection(item: item) HeroSection(item: item)
.tag(position) .containerRelativeFrame(.horizontal)
.id(item.id)
} }
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .scrollTargetLayout()
// Die eigene Anzeige des TabView läge über dem Bild und damit über }
// den Knöpfen. Die Höhe kommt aus dem Seitenverhältnis, weil ein .scrollTargetBehavior(.paging)
// TabView sonst nimmt, was es kriegen kann. .scrollPosition(id: $shownID)
.scrollIndicators(.hidden)
.aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit)
// Gleichzeitig und ohne Mindestweg: fängt jede Berührung ab, ohne // Jede Berührung hält das Weiterwandern an — auch die, die nur ein
// dem Blättern des TabView oder den Knöpfen darin etwas // Antippen wird. Gleichzeitig und ohne Mindestweg, damit weder dem
// wegzunehmen. // Blättern noch den Knöpfen etwas weggenommen wird.
.simultaneousGesture( .simultaneousGesture(
DragGesture(minimumDistance: 0).onChanged { _ in isAdvancing = false } DragGesture(minimumDistance: 0).onChanged { _ in isAdvancing = false }
) )
if items.count > 1 { indicator } if items.count > 1 { indicator }
} }
.task(id: index) { await advance() } .task(id: shownID) { await advance() }
.onChange(of: items.map(\.id)) { _, ids in .onChange(of: items.map(\.id), initial: true) { _, ids in
// Nach dem Aktualisieren kann die Liste kürzer sein als die // Beim ersten Zeichnen und nach jedem Aktualisieren: steht die
// Stelle, an der man gerade war. // gezeigte Kennung nicht mehr in der Liste, beginnt es von vorn.
if index >= ids.count { index = 0 } if shownID == nil || !ids.contains(shownID ?? "") { shownID = ids.first }
} }
} }
@@ -69,20 +75,22 @@ struct HeroCarousel: View {
HStack(spacing: FlixySpacing.snug) { HStack(spacing: FlixySpacing.snug) {
ForEach(items.indices, id: \.self) { position in ForEach(items.indices, id: \.self) { position in
Capsule() Capsule()
.fill(position == index ? accent : Color.flixyTextMuted.opacity(0.4)) .fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4))
.frame(width: position == index ? 18 : 6, height: 6) .frame(width: position == currentIndex ? 18 : 6, height: 6)
} }
} }
.animation(.snappy(duration: 0.25), value: index) .animation(.snappy(duration: 0.25), value: currentIndex)
.accessibilityHidden(true) .accessibilityHidden(true)
} }
private func advance() async { private func advance() async {
guard items.count > 1, !reduceMotion, isAdvancing else { return } guard items.count > 1, !reduceMotion, isAdvancing, shownID != nil else { return }
try? await Task.sleep(for: Self.dwell) try? await Task.sleep(for: Self.dwell)
guard !Task.isCancelled else { return } guard !Task.isCancelled, isAdvancing else { return }
let next = (currentIndex + 1) % items.count
withAnimation(.easeInOut(duration: 0.5)) { withAnimation(.easeInOut(duration: 0.5)) {
index = (index + 1) % items.count shownID = items[next].id
} }
} }
} }
+2 -4
View File
@@ -41,14 +41,12 @@ struct HomeView: View {
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt // über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
// ohnehin mehr. // ohnehin mehr.
.navigationTitle("") .navigationTitle("")
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe // Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild // bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben // lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
// Aufmacher. // Aufmacher.
.toolbarBackground(.hidden, for: .navigationBar) .flixyClearToolbarBackground()
// Helle Symbole, weil darunter ein Bild liegt und kein heller Grund.
.toolbarColorScheme(.dark, for: .navigationBar)
.task(id: session.serverName) { await load() } .task(id: session.serverName) { await load() }
.refreshable { await load() } .refreshable { await load() }
} }
+2 -2
View File
@@ -73,7 +73,7 @@ struct LibraryFilterSheet: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("filter.title")) .navigationTitle(Text("filter.title"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { dismiss() } Button("action.cancel") { dismiss() }
@@ -87,7 +87,7 @@ struct LibraryFilterSheet: View {
} }
.task { await loadGenres() } .task { await loadGenres() }
} }
.presentationDetents([.medium, .large]) .flixyResizableSheet()
} }
private func loadGenres() async { private func loadGenres() async {
+1 -1
View File
@@ -47,7 +47,7 @@ struct LibraryHubView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.library")) .navigationTitle(Text("sidebar.library"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
} }
private func symbol(for kind: MediaLibrary.Kind) -> String { private func symbol(for kind: MediaLibrary.Kind) -> String {
+3 -3
View File
@@ -51,7 +51,7 @@ struct LibraryView: View {
} }
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(library.title) .navigationTitle(library.title)
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbar { toolbar } .toolbar { toolbar }
.sheet(isPresented: $showsFilters) { .sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, libraryKind: library.kind) LibraryFilterSheet(query: $query, libraryKind: library.kind)
@@ -120,7 +120,7 @@ struct LibraryView: View {
@ToolbarContentBuilder @ToolbarContentBuilder
private var toolbar: some ToolbarContent { private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .flixyTrailing) {
Button { Button {
showsFilters = true showsFilters = true
} label: { } label: {
@@ -131,7 +131,7 @@ struct LibraryView: View {
.accessibilityLabel(Text("library.filters")) .accessibilityLabel(Text("library.filters"))
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .flixyTrailing) {
Button { Button {
layout = layout == .grid ? .list : .grid layout = layout == .grid ? .list : .grid
} label: { } label: {
@@ -28,9 +28,9 @@ struct ChannelSelectionSheet: View {
} }
} }
.navigationTitle(Text("liveTV.selection")) .navigationTitle(Text("liveTV.selection"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .flixyLeading) {
// „Alle" statt „keine": eine leere Auswahl bedeutet // „Alle" statt „keine": eine leere Auswahl bedeutet
// ohnehin alle, und ein Knopf, der scheinbar nichts tut, // ohnehin alle, und ein Knopf, der scheinbar nichts tut,
// wäre verwirrend. // wäre verwirrend.
+9 -7
View File
@@ -96,10 +96,10 @@ struct LiveTVView: View {
} }
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.liveTV")) .navigationTitle(Text("sidebar.liveTV"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbar { .toolbar {
if case .available = availability, !channels.isEmpty { if case .available = availability, !channels.isEmpty {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .flixyTrailing) {
Button { Button {
showsSelection = true showsSelection = true
} label: { } label: {
@@ -107,7 +107,7 @@ struct LiveTVView: View {
} }
.accessibilityLabel(Text("liveTV.selection")) .accessibilityLabel(Text("liveTV.selection"))
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .flixyTrailing) {
Picker("liveTV.mode", selection: $showsGuide) { Picker("liveTV.mode", selection: $showsGuide) {
Image(systemName: "calendar.day.timeline.left").tag(true) Image(systemName: "calendar.day.timeline.left").tag(true)
Image(systemName: "list.bullet").tag(false) Image(systemName: "list.bullet").tag(false)
@@ -167,9 +167,11 @@ struct LiveTVView: View {
.frame(maxHeight: 200) .frame(maxHeight: 200)
Button { Button {
UIPasteboard.general.string = trace FlixyClipboard.copy(
trace
.map { "\($0.path) → \($0.result)" } .map { "\($0.path) → \($0.result)" }
.joined(separator: "\n") .joined(separator: "\n")
)
} label: { } label: {
Label("diagnostics.copy", systemImage: "doc.on.doc") Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.flixyCaption) .font(.flixyCaption)
@@ -386,7 +388,7 @@ private struct TuneFailureSheet: View {
} }
Button { Button {
UIPasteboard.general.string = failure.trace.joined(separator: "\n") FlixyClipboard.copy(failure.trace.joined(separator: "\n"))
} label: { } label: {
Label("diagnostics.copy", systemImage: "doc.on.doc") Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.flixyCaption) .font(.flixyCaption)
@@ -398,13 +400,13 @@ private struct TuneFailureSheet: View {
} }
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("liveTV.tuneFailed")) .navigationTitle(Text("liveTV.tuneFailed"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() } Button("action.close") { dismiss() }
} }
} }
} }
.presentationDetents([.medium, .large]) .flixyResizableSheet()
} }
} }
+1 -1
View File
@@ -348,6 +348,6 @@ private struct ProgramDetailSheet: View {
} }
} }
} }
.presentationDetents([.medium, .large]) .flixyResizableSheet()
} }
} }
@@ -17,7 +17,7 @@ struct PlaceholderScreen: View {
} }
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(destination.title) .navigationTitle(destination.title)
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbarBackground(Color.flixySurface, for: .navigationBar)
} }
} }
+23
View File
@@ -1,5 +1,10 @@
import AVKit import AVKit
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
/// Der Knopf, der die Empfängerliste öffnet. /// 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 /// 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. /// aufzählen könnte — Apple TVs, AirPlay-Lautsprecher, HomePods im Verbund.
/// Sie nachzubauen hieße, eine unvollständige Liste zu zeigen. /// Sie nachzubauen hieße, eine unvollständige Liste zu zeigen.
#if canImport(UIKit)
struct AirPlayButton: UIViewRepresentable { struct AirPlayButton: UIViewRepresentable {
let tint: Color let tint: Color
@@ -25,3 +31,20 @@ struct AirPlayButton: UIViewRepresentable {
view.activeTintColor = UIColor(tint) 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 Observation
import FlixyDownloads import FlixyDownloads
import FlixyPlayback import FlixyPlayback
#if canImport(FlixyVLC)
import FlixyVLC import FlixyVLC
#endif
import PlexKit import PlexKit
/// Führt einen Abspielwunsch aus. /// Führt einen Abspielwunsch aus.
@@ -712,9 +714,12 @@ final class PlayerModel {
self?.restoreFromPictureInPicture?() self?.restoreFromPictureInPicture?()
} }
let controller = AVPictureInPictureController(playerLayer: surface.playerLayer) let controller = AVPictureInPictureController(playerLayer: surface.playerLayer)
#if os(iOS)
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die // 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 controller?.canStartPictureInPictureAutomaticallyFromInline = true
#endif
controller?.delegate = bridge controller?.delegate = bridge
pictureInPictureDelegate = bridge pictureInPictureDelegate = bridge
pictureInPicture = controller pictureInPicture = controller
@@ -730,12 +735,19 @@ final class PlayerModel {
} }
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine { private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
#if canImport(FlixyVLC)
switch decision.engine { switch decision.engine {
case .vlc: VLCPlayerEngine() case .vlc: VLCPlayerEngine()
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und // Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
// genau darin wechselt AVPlayer die Bitrate von selbst. // genau darin wechselt AVPlayer die Bitrate von selbst.
case .avPlayer, .none: AVPlayerEngine() 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) { 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 — // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
// und im Querformat erst recht nicht. // und im Querformat erst recht nicht.
.animation(.easeInOut(duration: 0.2), value: model.isBuffering) .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) .statusBarHidden(true)
#endif
.persistentSystemOverlays(showsControls ? .automatic : .hidden) .persistentSystemOverlays(showsControls ? .automatic : .hidden)
.landscapeWhileVisible(settings.locksLandscape) .landscapeWhileVisible(settings.locksLandscape)
.contentShape(.rect) .contentShape(.rect)
+2 -2
View File
@@ -54,14 +54,14 @@ struct QualitySheet: View {
} }
} }
.navigationTitle(Text("player.quality")) .navigationTitle(Text("player.quality"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("action.done") { dismiss() } Button("action.done") { dismiss() }
} }
} }
} }
.presentationDetents([.medium, .large]) .flixyResizableSheet()
} }
/// Was gerade läuft — gemessen, nicht gewünscht. /// Was gerade läuft — gemessen, nicht gewünscht.
+2 -2
View File
@@ -69,14 +69,14 @@ struct TrackPickerSheet: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text(kind.title)) .navigationTitle(Text(kind.title))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() } Button("action.close") { dismiss() }
} }
} }
} }
.presentationDetents([.medium]) .flixyMediumSheet()
} }
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View { private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
+91 -14
View File
@@ -1,7 +1,24 @@
import AVKit import AVKit
import SwiftUI import SwiftUI
import FlixyPlayback import FlixyPlayback
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
#if canImport(FlixyVLC)
import 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. /// Die Bildfläche des laufendie Engine.
/// ///
@@ -22,14 +39,21 @@ struct VideoSurface: View {
let surface: PlayerLayerView let surface: PlayerLayerView
var body: some View { var body: some View {
switch engine { #if canImport(FlixyVLC)
case is AVPlayerEngine: if let vlcEngine = engine as? VLCPlayerEngine {
UIViewContainer(view: surface) SurfaceContainer(view: vlcEngine.videoView)
case let vlcEngine as VLCPlayerEngine: } else if engine is AVPlayerEngine {
UIViewContainer(view: vlcEngine.videoView) SurfaceContainer(view: surface)
default: } else {
Color.black 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, /// 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 /// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das
/// UIKit. /// UIKit.
#if canImport(UIKit)
final class PlayerLayerView: UIView { final class PlayerLayerView: UIView {
override static var layerClass: AnyClass { AVPlayerLayer.self } override static var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } 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. /// 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 /// 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. /// die Fläche zurück, falls sie zwischenzeitlich woanders eingehängt wurde.
private struct UIViewContainer: UIViewRepresentable { #if canImport(UIKit)
let view: UIView private struct SurfaceContainer: UIViewRepresentable {
let view: PlatformView
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> PlatformView {
let host = UIView() let host = UIView()
host.backgroundColor = .black host.backgroundColor = .black
adopt(into: host) adopt(view, into: host)
return host return host
} }
func updateUIView(_ host: UIView, context: Context) { func updateUIView(_ host: PlatformView, context: Context) {
guard view.superview !== host else { return } guard view.superview !== host else { return }
adopt(into: host) 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
} }
private func adopt(into host: UIView) { 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.translatesAutoresizingMaskIntoConstraints = true
view.frame = host.bounds view.frame = host.bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
#else
view.translatesAutoresizingMaskIntoConstraints = true
view.frame = host.bounds
view.autoresizingMask = [.width, .height]
#endif
host.addSubview(view) host.addSubview(view)
} }
}
+2 -2
View File
@@ -26,8 +26,8 @@ struct SearchView: View {
} }
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("search.title")) .navigationTitle(Text("search.title"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always)) .searchable(text: $text)
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer // Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los, // kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
// von denen elf verworfen werden. // von denen elf verworfen werden.
+2 -3
View File
@@ -35,8 +35,7 @@ struct AppearanceView: View {
if showsCustomField { if showsCustomField {
HStack { HStack {
TextField("appearance.hexPlaceholder", text: $customHex) TextField("appearance.hexPlaceholder", text: $customHex)
.textInputAutocapitalization(.characters) .flixyPlainEntry()
.autocorrectionDisabled()
.onSubmit(applyCustomColor) .onSubmit(applyCustomColor)
Button("action.apply", action: applyCustomColor) Button("action.apply", action: applyCustomColor)
@@ -71,7 +70,7 @@ struct AppearanceView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("appearance.title")) .navigationTitle(Text("appearance.title"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.onAppear { .onAppear {
if theme.selection.id == AccentOption.customIdentifier { if theme.selection.id == AccentOption.customIdentifier {
customHex = theme.selection.color.hexString customHex = theme.selection.color.hexString
+3 -3
View File
@@ -60,7 +60,7 @@ struct DiagnosticsView: View {
Section { Section {
Button { Button {
UIPasteboard.general.string = report(for: attempt) FlixyClipboard.copy(report(for: attempt))
} label: { } label: {
Label("diagnostics.copy", systemImage: "doc.on.doc") Label("diagnostics.copy", systemImage: "doc.on.doc")
} }
@@ -92,7 +92,7 @@ struct DiagnosticsView: View {
.textSelection(.enabled) .textSelection(.enabled)
Button { Button {
UIPasteboard.general.string = liveTVStructure FlixyClipboard.copy(liveTVStructure)
} label: { } label: {
Label("diagnostics.copy", systemImage: "doc.on.doc") Label("diagnostics.copy", systemImage: "doc.on.doc")
} }
@@ -118,7 +118,7 @@ struct DiagnosticsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("diagnostics.title")) .navigationTitle(Text("diagnostics.title"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
} }
/// Fragt den Server, wie seine Antwort auf `/livetv/dvrs` aufgebaut ist. /// Fragt den Server, wie seine Antwort auf `/livetv/dvrs` aufgebaut ist.
@@ -76,7 +76,7 @@ struct DownloadSettingsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.downloads")) .navigationTitle(Text("sidebar.downloads"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
} }
private var storageLimitBinding: Binding<Int64> { private var storageLimitBinding: Binding<Int64> {
+5 -1
View File
@@ -15,8 +15,12 @@ struct HomeLayoutView: View {
list list
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("homeLayout.title")) .navigationTitle(Text("homeLayout.title"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
// Auf dem Mac gibt es den Knopf nicht — dort zieht man Zeilen ohne
// vorherigen Moduswechsel.
#if os(iOS)
.toolbar { EditButton() } .toolbar { EditButton() }
#endif
.task { await load() } .task { await load() }
} }
+1 -1
View File
@@ -62,7 +62,7 @@ struct LanguagesView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("languages.title")) .navigationTitle(Text("languages.title"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
} }
/// Der Name der Sprache in der Sprache des Geräts. /// Der Name der Sprache in der Sprache des Geräts.
@@ -89,7 +89,7 @@ struct PlaybackSettingsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("settings.playback")) .navigationTitle(Text("settings.playback"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
} }
private func rateLabel(_ rate: Double) -> String { private func rateLabel(_ rate: Double) -> String {
@@ -49,7 +49,7 @@ struct ServerSettingsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("settings.server")) .navigationTitle(Text("settings.server"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
} }
private var kindLabel: String { private var kindLabel: String {
+1 -1
View File
@@ -83,7 +83,7 @@ struct SettingsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.settings")) .navigationTitle(Text("sidebar.settings"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.confirmationDialog( .confirmationDialog(
Text("settings.signOutConfirm"), Text("settings.signOutConfirm"),
isPresented: $showsSignOutConfirmation, isPresented: $showsSignOutConfirmation,
+3 -6
View File
@@ -32,7 +32,7 @@ struct JellyfinSignInView: View {
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
.navigationTitle(Text("signIn.jellyfin.title")) .navigationTitle(Text("signIn.jellyfin.title"))
.navigationBarTitleDisplayMode(.inline) .flixyInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { Button("action.cancel") {
@@ -102,9 +102,7 @@ struct JellyfinSignInView: View {
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address) TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.textContentType(.URL) .textContentType(.URL)
.keyboardType(.URL) .flixyPlainEntry(isAddress: true)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go) .submitLabel(.go)
.focused($focusedField, equals: .address) .focused($focusedField, equals: .address)
.onSubmit { Task { await model.findServer() } } .onSubmit { Task { await model.findServer() } }
@@ -188,8 +186,7 @@ struct JellyfinSignInView: View {
TextField("signIn.jellyfin.userName", text: $model.userName) TextField("signIn.jellyfin.userName", text: $model.userName)
.textContentType(.username) .textContentType(.username)
.textInputAutocapitalization(.never) .flixyPlainEntry()
.autocorrectionDisabled()
.submitLabel(.next) .submitLabel(.next)
.focused($focusedField, equals: .userName) .focused($focusedField, equals: .userName)
.onSubmit { focusedField = .password } .onSubmit { focusedField = .password }
@@ -0,0 +1,110 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
/// Was auf iPhone und Mac verschieden heißt — an einer Stelle.
///
/// Nicht jede Ansicht soll `#if os(iOS)` tragen. Ein Formular, das auf beiden
/// Plattformen dasselbe meint, soll auf beiden gleich aussehen; wo die
/// Systeme verschiedene Namen dafür haben, steht der Unterschied hier und
/// nirgends sonst. Sonst wandert er in zwanzig Dateien und wird beim
/// nächsten Bildschirm vergessen.
extension View {
/// Eine schmale Titelzeile — auf dem Mac gibt es sie nicht.
public func flixyInlineTitle() -> some View {
#if os(iOS)
navigationBarTitleDisplayMode(.inline)
#else
self
#endif
}
/// Ein Blatt, das die halbe Höhe einnimmt.
///
/// Auf dem Mac ist ein Blatt ein Fenster: dort zählt keine Rastung,
/// sondern eine vernünftige Mindestgröße.
public func flixyMediumSheet() -> some View {
#if os(iOS)
presentationDetents([.medium])
#else
frame(minWidth: 420, minHeight: 460)
#endif
}
/// Dasselbe, aber auf Wunsch bis zur vollen Höhe aufziehbar.
public func flixyResizableSheet() -> some View {
#if os(iOS)
presentationDetents([.medium, .large])
#else
frame(minWidth: 460, minHeight: 520)
#endif
}
/// Eingabefelder für Adressen und Namen: keine Großschreibung, keine
/// Korrektur — auf dem Mac gibt es beides ohnehin nicht als Tastatur.
public func flixyPlainEntry(isAddress: Bool = false) -> some View {
#if os(iOS)
self
.keyboardType(isAddress ? .URL : .default)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#else
autocorrectionDisabled()
#endif
}
}
extension View {
/// Keine eigene Fläche hinter der Titelzeile.
///
/// Auf der Startseite liegt darunter der Aufmacher — eine weichgezeichnete
/// Leiste verschleierte die Hälfte davon. Auf dem Mac gibt es die Leiste in
/// dieser Form nicht.
public func flixyClearToolbarBackground() -> some View {
#if os(iOS)
self
.toolbarBackground(.hidden, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
#else
self
#endif
}
}
/// Etwas in die Zwischenablage legen.
///
/// UIKit und AppKit heißen verschieden und meinen dasselbe; die Diagnose soll
/// deswegen nicht mit `#if` beginnen.
public enum FlixyClipboard {
public static func copy(_ text: String) {
#if canImport(UIKit)
UIPasteboard.general.string = text
#else
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
#endif
}
}
extension ToolbarItemPlacement {
/// Rechts in der Titelzeile.
public static var flixyTrailing: ToolbarItemPlacement {
#if os(iOS)
.topBarTrailing
#else
.primaryAction
#endif
}
/// Links in der Titelzeile.
public static var flixyLeading: ToolbarItemPlacement {
#if os(iOS)
.topBarLeading
#else
.navigation
#endif
}
}
+57
View File
@@ -4,6 +4,7 @@ options:
bundleIdPrefix: com.scarriffleservices bundleIdPrefix: com.scarriffleservices
deploymentTarget: deploymentTarget:
iOS: "26.0" iOS: "26.0"
macOS: "26.0"
createIntermediateGroups: true createIntermediateGroups: true
groupSortPosition: top groupSortPosition: top
developmentLanguage: en developmentLanguage: en
@@ -147,6 +148,62 @@ targets:
# Die Live Activity für Downloads. Eine eigene Erweiterung, weil iOS die # Die Live Activity für Downloads. Eine eigene Erweiterung, weil iOS die
# Darstellung im Sperrbildschirm in einem getrennten Prozess zeichnet — die # Darstellung im Sperrbildschirm in einem getrennten Prozess zeichnet — die
# App selbst läuft zu dem Zeitpunkt meist gar nicht. # App selbst läuft zu dem Zeitpunkt meist gar nicht.
# Dieselben Quellen, eigenes Ziel.
#
# Ein gemeinsames Target für beide Plattformen wäre weniger Text, aber die
# Info.plist ist voller iOS-Angaben — Startbildschirm, erlaubte
# Ausrichtungen, Live Activities. Die müssten dann alle bedingt werden, und
# jede spätere Änderung an der iOS-Seite ginge über eine Stelle, die auch
# den Mac betrifft. Zwei Ziele sind ein paar Zeilen mehr und ein Sorge
# weniger.
#
# Ohne DownloadWidget (Live Activities gibt es auf dem Mac nicht) und ohne
# FlixyVLC: das Binärpaket ist auf iOS festgelegt. Der Player fällt dort
# allein auf den Systemplayer zurück — was er nicht öffnen kann, sagt er.
FlixyMac:
type: application
platform: macOS
sources:
- path: Flixy
excludes:
- "AppIcon.icon"
dependencies:
- package: FlixyKit
product: MediaKit
- package: FlixyKit
product: PlexKit
- package: FlixyKit
product: JellyfinKit
- package: FlixyKit
product: FlixyDesign
- package: FlixyKit
product: FlixyPlayback
- package: FlixyKit
product: FlixyDownloads
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy
PRODUCT_NAME: Flixy
# Selbst signiert, ohne Entwicklerkonto: ein Build zum Ansehen soll
# nicht jedes Mal nach dem Schlüsselbund-Passwort fragen.
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "-"
DEVELOPMENT_TEAM: ""
ENABLE_HARDENED_RUNTIME: NO
COMBINE_HIDPI_IMAGES: YES
info:
path: Flixy/Info-macOS.plist
properties:
CFBundleDisplayName: Flixy
LSApplicationCategoryType: public.app-category.entertainment
NSHumanReadableCopyright: ""
CFBundleURLTypes:
- CFBundleURLName: com.scarriffleservices.flixy.auth
CFBundleURLSchemes:
- com.scarriffleservices.flixy
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
DownloadWidget: DownloadWidget:
type: app-extension type: app-extension
platform: iOS platform: iOS