Files
punktfunk/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift
T
enricobuehler 3ba19f28a2
apple / swift (push) Waiting to run
apple / screenshots (push) Blocked by required conditions
release / apple (push) Waiting to run
ci / rust (push) Failing after 50s
ci / web (push) Successful in 57s
decky / build-publish (push) Successful in 14s
ci / docs-site (push) Successful in 1m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
flatpak / build-publish (push) Failing after 2m14s
ci / bench (push) Successful in 5m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5m46s
docker / deploy-docs (push) Successful in 23s
windows-host / package (push) Successful in 11m40s
arch / build-publish (push) Successful in 18m10s
deb / build-publish (push) Successful in 18m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
android / android (push) Successful in 21m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m3s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m2s
feat(apple): the gamepad UI comes to tvOS - focus-driven, with real session controls
The console UI now runs on tvOS through the NATIVE focus engine: carousel
cards and settings rows are focusable Buttons (Siri Remote and pads both
navigate; imperative scrollTo replaces the drop-prone scrollPosition binding),
while iOS/macOS keep the 60 Hz poll untouched - on tvOS it carries only what
focus has no concept of: X/Y screen actions and left/right value adjust with
the poll's dominant-axis feel (onMoveCommand proved input-source-dependent:
keyboard intercepted, pad dpad not -> double steps). Text entry uses the
system fullscreen keyboard (TVTextEntry); pairing + library present as covers
under the launcher; the game library defaults ON; settings values slide a
quiet 14 pt in the step's direction.

Session controls: controller/remote input routes EXCLUSIVELY through
GameController during a stream (GCEventViewController, interaction disabled) -
a pad's B no longer doubles as a UIKit menu press that ended sessions
mid-game. Deliberate exits only: the cross-client escape chord (hold
L1+R1+Start+Select 1.5 s - pf-client-core's contract, now implemented on all
Apple platforms) and holding the remote's Back >= 1 s; the start-of-stream
banner (now also on tvOS) teaches both. The Siri Remote's touch surface
drives the host pointer - press = left click, Play/Pause = right click,
release-tail jumps gated so motion stays truly relative.

tvOS 26 regressions fixed at the root: the app-wide brand tint rendered every
unfocused control as a blank pill (tint dropped on tvOS) and the 17 pt root
font shrank the whole platform (29 pt there), plus 10-foot sizing across host
cards, the gamepad screens, and the stats HUD (whose misleading "Press Menu"
hint is gone). Acknowledgements scrolls by focus-sized chunks and Menu pops
instead of suspending; full-width focusSections make the home actions
reachable from any column. The presenter defaults to stage-3 glass pacing on
tvOS (a 60 Hz panel fed a 60 fps stream is the sticky-FIFO worst case behind
the 50 ms display stage) and is pickable from the gamepad settings; HDR
capability advertises from AVPlayer.eligibleForHDRPlayback instead of the
current mode's EDR headroom, so an SDR home screen no longer hides an HDR TV.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:59:01 +02:00

183 lines
7.2 KiB
Swift

// 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),
]
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
static var presenters: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2", "stage2"),
("Stage 3", "stage3"),
]
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
/// an untouched picker shows what actually runs.
static var presenterDefault: String {
#if os(tvOS)
"stage3"
#else
"stage2"
#endif
}
/// 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)
}
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).
@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)
native = [("This device",
Int(max(bounds.width, bounds.height)),
Int(min(bounds.width, bounds.height)))]
#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 }
}
/// 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()
}
}