Files
punktfunk/clients/apple/Sources/PunktfunkClient/SettingsView.swift
T
enricobuehler bfd8c7be93
ci / rust (push) Has been cancelled
feat(apple): tvOS client — third app target, first-lit in the Apple TV simulator
The same app now runs on tvOS (target Punktfunk-tvOS, bundle io.unom.punktfunk.tvos),
validated live against the box: vkcube at 1280x720@60, 60 fps in the Apple TV 4K
simulator, glass HUD with a focusable Disconnect button.

- PunktfunkCore.xcframework grows tvOS device + universal-simulator slices. These are
  TIER-3 Rust targets (no prebuilt std): BUILD_TVOS=1 builds them with nightly and
  -Zbuild-std from rust-src — the full quic stack (quinn/rustls-ring/tokio) compiles
  for tvOS unchanged.
- The UIKit stream view covers iOS AND tvOS, with pointer interaction, pointer lock,
  touch forwarding and InputCapture gated to iOS — tvOS is view-only until gamepad
  capture lands (the natural tvOS input).
- SessionAudio on tvOS: .playback session, no mic (no app-accessible microphone).
- App chrome gates: keyboardShortcut/textSelection/controlSize/statusBarHidden are
  iOS/macOS-only; host cards use the focus-native .card button style on tvOS; the
  Audio settings section hides (system-routed); mode seeding works from the TV screen
  (1920x1080@60).
- Package platforms += .tvOS(.v17); new Xcode target + shared scheme
  (TARGETED_DEVICE_FAMILY 3, local-network usage description included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:10:40 +02:00

130 lines
5.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// App settings (,): the stream mode + the host compositor. The host creates a native
// virtual output at exactly this size/refresh there is no scaling anywhere in the
// pipeline.
#if os(macOS)
import AppKit
#endif
import PunktfunkKit
import SwiftUI
struct SettingsView: View {
@AppStorage("punktfunk.width") private var width = 1920
@AppStorage("punktfunk.height") private var height = 1080
@AppStorage("punktfunk.hz") private var hz = 60
@AppStorage("punktfunk.compositor") private var compositor = 0
@AppStorage("punktfunk.micEnabled") private var micEnabled = true
#if os(macOS)
@AppStorage("punktfunk.speakerUID") private var speakerUID = ""
@AppStorage("punktfunk.micUID") private var micUID = ""
@State private var outputDevices: [AudioDevice] = []
@State private var inputDevices: [AudioDevice] = []
#endif
var body: some View {
Form {
Section {
HStack {
TextField("Resolution", value: $width, format: .number.grouping(.never))
Text("×")
TextField("", value: $height, format: .number.grouping(.never))
.labelsHidden()
}
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() }
}
} header: {
Text("Stream mode")
} footer: {
Text("The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling.")
.font(.caption)
.foregroundStyle(.secondary)
}
#if !os(tvOS)
Section {
#if os(macOS)
Picker("Speaker", selection: $speakerUID) {
Text("System default").tag("")
ForEach(outputDevices) { device in
Text(device.name).tag(device.uid)
}
if !speakerUID.isEmpty,
!outputDevices.contains(where: { $0.uid == speakerUID }) {
Text("Unavailable device").tag(speakerUID)
}
}
#endif
#if !os(tvOS)
Toggle("Send microphone to the host", isOn: $micEnabled)
#endif
#if os(macOS)
Picker("Microphone", selection: $micUID) {
Text("System default").tag("")
ForEach(inputDevices) { device in
Text(device.name).tag(device.uid)
}
if !micUID.isEmpty,
!inputDevices.contains(where: { $0.uid == micUID }) {
Text("Unavailable device").tag(micUID)
}
}
.disabled(!micEnabled)
#endif
} header: {
Text("Audio")
} footer: {
Text("Host audio plays through the speaker; the microphone feeds the "
+ "host's virtual mic. System default follows macOS device changes. "
+ "Applies from the next session.")
.font(.caption)
.foregroundStyle(.secondary)
}
#endif
Section {
Picker("Compositor", selection: $compositor) {
Text("Automatic").tag(0)
Text("KWin (KDE Plasma)").tag(1)
Text("wlroots (Sway / Hyprland)").tag(2)
Text("Mutter (GNOME)").tag(3)
Text("gamescope").tag(4)
}
} header: {
Text("Host compositor")
} footer: {
Text("Which compositor drives the virtual output on the host. A specific "
+ "choice is honored only if that backend is available there — "
+ "otherwise the host falls back to auto-detection.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
#if os(macOS)
.frame(width: 380)
.fixedSize()
.onAppear {
outputDevices = AudioDevices.outputs()
inputDevices = AudioDevices.inputs()
}
#endif
}
private func fillFromMainScreen() {
#if os(macOS)
guard let screen = NSScreen.main else { return }
let scale = screen.backingScaleFactor
width = Int(screen.frame.width * scale)
height = Int(screen.frame.height * scale)
hz = screen.maximumFramesPerSecond
#else
// nativeBounds is portrait-oriented pixels streams are landscape.
let bounds = UIScreen.main.nativeBounds
width = Int(max(bounds.width, bounds.height))
height = Int(min(bounds.width, bounds.height))
hz = UIScreen.main.maximumFramesPerSecond
#endif
}
}