Files
punktfunk/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift
T
enricobuehler 30bd10e301
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 4m7s
android / android (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 1m47s
ci / bun-nix (pull_request) Successful in 42s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 14m32s
feat(clients): an OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears
Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.

A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.

A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.

The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.

Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.

That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.

Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
2026-08-08 12:57:12 +02:00

249 lines
11 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.
// The option lists every settings surface renders from — one source of truth shared by the
// touch/desktop SettingsView (Pickers), the tvOS pushed selection rows, and the gamepad settings
// screen (GamepadSettingsView's left/right cycling). Pure data + small pure helpers; anything that
// reads live view state (e.g. the bitrate slider mapping) stays on SettingsView.
#if os(macOS)
import AppKit
#endif
import PunktfunkKit
import SwiftUI
enum SettingsOptions {
/// Compositor choices — the `tag` is the wire value (`PunktfunkConnection.Compositor` raw).
static let compositors: [(label: String, tag: Int)] = [
("Automatic", 0),
("KWin (KDE Plasma)", 1),
("wlroots (Sway / Hyprland)", 2),
("Mutter (GNOME)", 3),
("gamescope", 4),
]
static let audioChannels: [(label: String, tag: Int)] = [
("Stereo", 2),
("5.1 Surround", 6),
("7.1 Surround", 8),
]
/// Virtual-pad types — the `tag` is the wire value (`PunktfunkConnection.GamepadType` raw).
static let padTypes: [(label: String, tag: Int)] = [
("Automatic", 0),
("Xbox 360", 1),
("Xbox One", 3),
("DualSense", 2),
("DualShock 4", 4),
]
/// System-button routing (the cross-client `system_buttons` key): where the guide
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
static let systemButtons: [(label: String, tag: String)] = [
("Automatic", "auto"),
("Send to host", "forward"),
("This device", "local"),
]
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
/// everywhere but macOS.
static let guideGestures: [(label: String, tag: String)] = [
("Automatic", "auto"),
("On", "on"),
("Off", "off"),
]
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) — only meaningful while
/// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch
/// is off rather than showing a picker that decides nothing.
static let gamepadUIModes: [(label: String, tag: String)] = [
("With a controller", GamepadUIEnvironment.modeWhenConnected),
("Always", GamepadUIEnvironment.modeAlways),
]
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
/// PUNKTFUNK_PRESENTER debug env lever.
static let presentPriorities: [(label: String, tag: String)] = [
("Lowest latency", "latency"),
("Smoothness", "smooth"),
]
static let presentPriorityDefault = "latency"
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
/// frames). The ms hints derive from the chosen refresh setting — each buffered frame costs
/// about one refresh interval of display latency and absorbs about one interval of arrival
/// jitter.
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
let periodMs = 1000.0 / Double(max(24, refreshHz))
func hint(_ frames: Int) -> String {
String(format: "+%.0f ms", Double(frames) * periodMs)
}
return [
("Automatic", 0),
("1 frame (\(hint(1)))", 1),
("2 frames (\(hint(2)))", 2),
("3 frames (\(hint(3)))", 3),
]
}
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
static let statsVerbosities: [(label: String, tag: String)] =
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
/// AV1 appears only on devices with an AV1 hardware decoder (the same
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a
/// dead setting the host could never honor. Ordered by the host's resolve precedence
/// (HEVC > AV1 > H.264).
static let codecs: [(label: String, tag: String)] = {
var options: [(label: String, tag: String)] = [
("Automatic", "auto"),
("HEVC (H.265)", "hevc"),
("H.264 (AVC)", "h264"),
]
if AV1.hardwareDecodeSupported {
options.insert(("AV1", "av1"), at: 2)
}
// PyroWave is the opt-in wired-LAN low-latency codec (100400 Mbps all-intra wavelet,
// 8-bit SDR): selecting it advertises + prefers it for the session. Offered only when
// the Metal decode probe passes (same gate SessionModel advertises by) — elsewhere the
// host could never emit it.
if MetalWaveletDecoder.supported {
options.append(("PyroWave (wired LAN)", "pyrowave"))
}
return options
}()
// MARK: - Bitrate
/// Discrete bitrate steps for the surfaces with no Slider (tvOS pushed pickers, the gamepad
/// settings' left/right cycling), up to the same 3 Gbps ceiling the slider has.
static let bitratePresets: [(label: String, tag: Int)] = [
("Automatic", 0),
("10 Mbps", 10_000),
("20 Mbps", 20_000),
("40 Mbps", 40_000),
("80 Mbps", 80_000),
("150 Mbps", 150_000),
("300 Mbps", 300_000),
("500 Mbps", 500_000),
("1 Gbps", 1_000_000),
("1.5 Gbps", 1_500_000),
("2 Gbps", 2_000_000),
("3 Gbps", 3_000_000),
]
/// The presets plus the currently stored value when it isn't one of them (set via the touch
/// slider or a synced device) — so the current choice stays visible/selectable.
static func bitrateOptions(current: Int) -> [(label: String, tag: Int)] {
var options = bitratePresets
if !options.contains(where: { $0.tag == current }) {
options.insert(
(SpeedTestSheet.mbpsLabel(kbps: current) + " (custom)", current), at: 1)
}
return options
}
// MARK: - Controllers
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale pin
/// stays visible instead of leaving the selection tag-less — any pinned id that is NOT among
/// the selectable (extended) entries, present-but-unusable included.
@MainActor
static func controllerOptions(_ gamepads: GamepadManager) -> [(label: String, tag: String)] {
let selectable = gamepads.controllers.filter(\.isExtended)
var options: [(label: String, tag: String)] = [("Automatic", "")]
options += selectable.map { ($0.name, $0.id) }
if !gamepads.preferredID.isEmpty,
!selectable.contains(where: { $0.id == gamepads.preferredID }) {
options.append(("Unavailable controller", gamepads.preferredID))
}
return options
}
// MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the
// touch/remote tvOS SettingsView builds its own preset list)
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
("720p", 1280, 720),
("1080p", 1920, 1080),
("1440p", 2560, 1440),
("4K", 3840, 2160),
("Ultrawide 1080p", 2560, 1080),
("Ultrawide 1440p", 3440, 1440),
("Super ultrawide", 5120, 1440),
]
/// This device's native mode first, then the presets, deduped by dimensions (native wins a
/// tie).
///
/// On iOS the native row is followed by its **safe-area** variant, which is the same mode
/// narrowed so the picture clears the sensor housing and the rounded corners — see
/// [`SafeDisplay`] for why a narrower mode is the whole fix. It is emitted unconditionally and
/// left to the dedup below: on a device with no housing the two modes are identical, the
/// duplicate is dropped, and no pointless row appears.
@MainActor
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
var native: [(name: String, w: Int, h: Int)] = []
#if os(iOS) || os(tvOS)
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
let nativeW = Int(max(bounds.width, bounds.height))
let nativeH = Int(min(bounds.width, bounds.height))
native = [("This device", nativeW, nativeH)]
#if os(iOS)
let safe = SafeDisplay.mode(
nativeWidth: nativeW, nativeHeight: nativeH,
sideInsetPoints: mainWindowSideInset(), scale: UIScreen.main.nativeScale)
native.append(("This device (safe area)", safe.width, safe.height))
#endif
#else
if let screen = NSScreen.main {
let scale = screen.backingScaleFactor
native = [("This display",
Int(screen.frame.width * scale),
Int(screen.frame.height * scale))]
}
#endif
var seen = Set<String>()
return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted }
}
#if os(iOS)
/// The key window's per-side safe-area inset in points, resolved for the LANDSCAPE stream even
/// when this settings screen is currently portrait (see `SafeDisplay.sideInsetPoints`).
///
/// Zero when no window is up yet — the safe mode then equals the native one and `resolutionModes`
/// dedups the row away, which is the right answer for a device we can't measure.
@MainActor
private static func mainWindowSideInset() -> Double {
let insets = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow }?
.safeAreaInsets
guard let insets else { return 0 }
return SafeDisplay.sideInsetPoints(
left: Double(insets.left), right: Double(insets.right), top: Double(insets.top),
isPhone: UIDevice.current.userInterfaceIdiom == .phone)
}
#endif
/// Refresh rates the device can actually display (no point asking the host to render frames
/// the screen can't show), plus any stored custom value so it stays selectable.
@MainActor
static func refreshRates(including current: Int) -> [Int] {
#if os(iOS) || os(tvOS)
let maxHz = UIScreen.main.maximumFramesPerSecond
#else
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
#endif
var rates = [60, 120, 240].filter { $0 <= maxHz }
if rates.isEmpty { rates = [maxHz] }
if !rates.contains(current) { rates.append(current) }
return rates.sorted()
}
}