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
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>
423 lines
19 KiB
Swift
423 lines
19 KiB
Swift
// Chrome shared by the gamepad-driven screens (GamepadHomeView, GamepadSettingsView,
|
||
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
|
||
// controller-glyph hint bar, and the connected-controller status chip. One look across every
|
||
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
|
||
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS — where the same screens are driven by
|
||
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
|
||
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
#if os(iOS) || os(macOS) || os(tvOS)
|
||
import GameController
|
||
|
||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
||
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
|
||
@MainActor
|
||
func buttonGlyph(
|
||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||
) -> String {
|
||
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
||
?? fallback
|
||
}
|
||
|
||
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
|
||
/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar
|
||
/// at all, so the iOS value hugs the top edge there.
|
||
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
||
#if os(macOS)
|
||
26
|
||
#else
|
||
compact ? 4 : 10
|
||
#endif
|
||
}
|
||
|
||
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
|
||
/// in-hand compact-aware sizes elsewhere.
|
||
func gamepadTitleSize(compact: Bool) -> CGFloat {
|
||
#if os(tvOS)
|
||
44
|
||
#else
|
||
compact ? 20 : 30
|
||
#endif
|
||
}
|
||
|
||
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
|
||
/// GamepadAddHostView) — one set of numbers so the two screens read as the same surface,
|
||
/// sized for the couch on tvOS and for the hand elsewhere.
|
||
enum GamepadFormMetrics {
|
||
#if os(tvOS)
|
||
static let headerFont: CGFloat = 17
|
||
static let labelFont: CGFloat = 23
|
||
static let valueFont: CGFloat = 21
|
||
static let iconFont: CGFloat = 24
|
||
static let iconWidth: CGFloat = 40
|
||
static let chevronFont: CGFloat = 16
|
||
static let rowHPad: CGFloat = 24
|
||
static let rowVPad: CGFloat = 19
|
||
static let rowCorner: CGFloat = 18
|
||
static let rowMaxWidth: CGFloat = 920
|
||
static let detailFont: CGFloat = 19
|
||
static let closeFont: CGFloat = 20
|
||
static let closeSide: CGFloat = 48
|
||
#else
|
||
static let headerFont: CGFloat = 12
|
||
static let labelFont: CGFloat = 16
|
||
static let valueFont: CGFloat = 15
|
||
static let iconFont: CGFloat = 17
|
||
static let iconWidth: CGFloat = 28
|
||
static let chevronFont: CGFloat = 12
|
||
static let rowHPad: CGFloat = 16
|
||
static let rowVPad: CGFloat = 13
|
||
static let rowCorner: CGFloat = 14
|
||
static let rowMaxWidth: CGFloat = 620
|
||
static let detailFont: CGFloat = 13
|
||
static let closeFont: CGFloat = 14
|
||
static let closeSide: CGFloat = 34
|
||
#endif
|
||
}
|
||
|
||
/// One glyph + label cell in a hint bar.
|
||
struct GamepadHint: Identifiable {
|
||
let glyph: String
|
||
let text: String
|
||
var id: String { glyph + text }
|
||
}
|
||
|
||
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
|
||
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration —
|
||
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
|
||
/// the backdrop instead of dissolving into it.
|
||
struct GamepadHintBar: View {
|
||
let hints: [GamepadHint]
|
||
|
||
// 10-foot legend on tvOS, in-hand sizes elsewhere.
|
||
#if os(tvOS)
|
||
private static let glyphFont: CGFloat = 27
|
||
private static let textFont: CGFloat = 20
|
||
private static let pad: CGFloat = 18
|
||
#else
|
||
private static let glyphFont: CGFloat = 19
|
||
private static let textFont: CGFloat = 14
|
||
private static let pad: CGFloat = 13
|
||
#endif
|
||
|
||
var body: some View {
|
||
HStack(spacing: 18) {
|
||
ForEach(hints) { hint in
|
||
HStack(spacing: 7) {
|
||
Image(systemName: hint.glyph)
|
||
.font(.system(size: Self.glyphFont))
|
||
.foregroundStyle(.white)
|
||
Text(hint.text)
|
||
}
|
||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||
}
|
||
}
|
||
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
|
||
.foregroundStyle(.white.opacity(0.85))
|
||
.padding(Self.pad)
|
||
.consoleGlass(Capsule())
|
||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||
}
|
||
}
|
||
|
||
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
|
||
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
|
||
/// animated `MeshGradient` — a continuous silk of colour whose control points wander on slow,
|
||
/// out-of-phase sinusoids — finished with an elliptical vignette (pools light in the centre, sinks
|
||
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
|
||
/// radial-blob field, unchanged, so nothing regresses.
|
||
///
|
||
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
|
||
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
|
||
/// bundled in one of the two. MeshGradient + TimelineView give the silky look with none of that
|
||
/// risk. Applied via `.background { }` — NOT a ZStack sibling — so the `.ignoresSafeArea()` here
|
||
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
|
||
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||
struct GamepadScreenBackground: View {
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
|
||
var body: some View {
|
||
Group {
|
||
if reduceMotion {
|
||
composite(at: 0)
|
||
} else {
|
||
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
|
||
// redraw cost of a battery-fed couch device vs. the display's native rate.
|
||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||
composite(at: context.date.timeIntervalSinceReferenceDate)
|
||
}
|
||
}
|
||
}
|
||
.ignoresSafeArea()
|
||
}
|
||
|
||
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
|
||
/// title/hints legibility scrim.
|
||
private func composite(at t: TimeInterval) -> some View {
|
||
ZStack {
|
||
Color.black
|
||
colorField(at: t)
|
||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
|
||
// Soft (extends past the frame) so the corners deepen rather than crush to black.
|
||
EllipticalGradient(
|
||
colors: [.clear, .black.opacity(0.42)],
|
||
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
|
||
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
|
||
// darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to
|
||
// blur), so it stays a gradient, just a light one now.
|
||
LinearGradient(
|
||
stops: [
|
||
.init(color: .black.opacity(0.38), location: 0),
|
||
.init(color: .black.opacity(0.06), location: 0.32),
|
||
.init(color: .black.opacity(0.08), location: 0.68),
|
||
.init(color: .black.opacity(0.40), location: 1),
|
||
],
|
||
startPoint: .top, endPoint: .bottom)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
|
||
if #available(iOS 18, macOS 15, tvOS 18, *) {
|
||
MeshGradient(
|
||
width: 4, height: 4,
|
||
points: Self.meshPoints(at: t),
|
||
colors: Self.meshColors,
|
||
smoothsColors: true)
|
||
} else {
|
||
LegacyBlobField(t: t)
|
||
}
|
||
}
|
||
|
||
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
|
||
|
||
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
|
||
/// mid-tone violets, and the four interior points hold the bright brand family — a violet and a
|
||
/// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on
|
||
/// the right, and the silk shifts temperature as those interior points drift.
|
||
private static let meshColors: [Color] = {
|
||
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
|
||
return [
|
||
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
|
||
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
|
||
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
|
||
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
|
||
]
|
||
}()
|
||
|
||
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
|
||
/// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black
|
||
/// behind it), while only the four interior points wander on slow, out-of-phase sinusoids
|
||
/// (periods ~90–130 s) so the bright colour pools breathe without ever looking like they loop.
|
||
private static func meshPoints(at t: TimeInterval) -> [SIMD2<Float>] {
|
||
func wob(_ bx: Float, _ by: Float, _ a: Float,
|
||
_ sx: Double, _ sy: Double, _ ph: Double) -> SIMD2<Float> {
|
||
SIMD2(bx + a * Float(sin(t * sx + ph)), by + a * Float(cos(t * sy + ph * 1.3)))
|
||
}
|
||
return [
|
||
SIMD2(0, 0), SIMD2(0.333, 0), SIMD2(0.667, 0), SIMD2(1, 0),
|
||
SIMD2(0, 0.333),
|
||
wob(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
|
||
wob(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
|
||
SIMD2(1, 0.333),
|
||
SIMD2(0, 0.667),
|
||
wob(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
|
||
wob(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
|
||
SIMD2(1, 0.667),
|
||
SIMD2(0, 1), SIMD2(0.333, 1), SIMD2(0.667, 1), SIMD2(1, 1),
|
||
]
|
||
}
|
||
}
|
||
|
||
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four
|
||
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
|
||
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
|
||
private struct LegacyBlobField: View {
|
||
let t: TimeInterval
|
||
|
||
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
|
||
/// (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||
private struct Blob {
|
||
let color: Color
|
||
let center: CGPoint
|
||
let drift: CGSize
|
||
let speed: (x: Double, y: Double)
|
||
let phase: (x: Double, y: Double)
|
||
let radius: CGFloat
|
||
let breathe: (amount: CGFloat, speed: Double)
|
||
let opacity: Double
|
||
}
|
||
|
||
private static let blobs: [Blob] = [
|
||
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
||
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||
speed: (0.111, 0.083), phase: (0.0, 1.9),
|
||
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
|
||
Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo
|
||
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
|
||
speed: (0.071, 0.096), phase: (2.4, 0.7),
|
||
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
|
||
Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum
|
||
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
|
||
speed: (0.089, 0.067), phase: (4.1, 3.2),
|
||
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
|
||
Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue
|
||
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
|
||
speed: (0.059, 0.104), phase: (1.2, 5.0),
|
||
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
|
||
]
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
let side = max(geo.size.width, geo.size.height)
|
||
ZStack {
|
||
ForEach(Self.blobs.indices, id: \.self) { i in
|
||
blobView(Self.blobs[i], in: geo.size, side: side)
|
||
}
|
||
}
|
||
.drawingGroup()
|
||
}
|
||
}
|
||
|
||
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
|
||
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
|
||
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||
let r = side * blob.radius
|
||
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
|
||
return Circle()
|
||
.fill(RadialGradient(
|
||
colors: [blob.color, blob.color.opacity(0)],
|
||
center: .center, startRadius: 0, endRadius: r / 2))
|
||
.frame(width: r, height: r)
|
||
.position(x: x * size.width, y: y * size.height)
|
||
.opacity(blob.opacity)
|
||
.blendMode(.plusLighter)
|
||
}
|
||
}
|
||
|
||
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
|
||
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
|
||
/// render interleaved. Pure blur — a dark material faded out by a gradient mask, no dark tint — so
|
||
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
|
||
struct GamepadTrayScrim: View {
|
||
let edge: VerticalEdge
|
||
|
||
var body: some View {
|
||
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
|
||
let toContent: UnitPoint = edge == .top ? .bottom : .top
|
||
Rectangle()
|
||
.fill(.ultraThinMaterial)
|
||
// These trays always sit on the dark console UI; force dark so the material frosts dark
|
||
// (white text stays legible) regardless of the system appearance.
|
||
.environment(\.colorScheme, .dark)
|
||
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
|
||
.mask {
|
||
LinearGradient(
|
||
stops: [
|
||
.init(color: .black, location: 0),
|
||
.init(color: .black.opacity(0.9), location: 0.5),
|
||
.init(color: .clear, location: 1),
|
||
],
|
||
startPoint: fromEdge, endPoint: toContent)
|
||
}
|
||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
||
// text always sits on the strong part, rows blur out before they reach it.
|
||
.padding(edge == .top ? .bottom : .top, -32)
|
||
.ignoresSafeArea()
|
||
}
|
||
}
|
||
|
||
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's
|
||
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
|
||
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
|
||
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
|
||
/// so the rows read as glass while the screen stays restful.
|
||
struct GamepadFormBackground: View {
|
||
var body: some View {
|
||
ZStack {
|
||
Color(red: 0.075, green: 0.062, blue: 0.150)
|
||
// Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent
|
||
// (fraction radii) so the glow scale tracks the window on any screen.
|
||
EllipticalGradient(
|
||
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
|
||
center: UnitPoint(x: 0.26, y: 0.14),
|
||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||
EllipticalGradient(
|
||
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
|
||
center: UnitPoint(x: 0.82, y: 0.9),
|
||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||
}
|
||
.ignoresSafeArea()
|
||
}
|
||
}
|
||
|
||
#if os(tvOS)
|
||
/// Bare chrome for the focusable console Buttons (carousel cards, menu-list rows) on tvOS: the
|
||
/// tile/row draws its own look and the screen's own focus treatment marks the focused element
|
||
/// (the carousel's `.scrollTransition` center pop, the list row's `focused` styling), so the
|
||
/// system's lift/halo would double up on it. Press feedback is a small dip, matching the
|
||
/// interactive-glass feel elsewhere.
|
||
struct ConsoleBareButtonStyle: ButtonStyle {
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||
.animation(.smooth(duration: 0.15), value: configuration.isPressed)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
||
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||
/// when the pad or its battery state changes.
|
||
struct ControllerStatusChip: View {
|
||
let controller: GamepadManager.DiscoveredController
|
||
|
||
// Legible from the couch on tvOS, quiet in hand elsewhere.
|
||
#if os(tvOS)
|
||
private static let font: CGFloat = 17
|
||
private static let hPad: CGFloat = 16
|
||
private static let vPad: CGFloat = 10
|
||
#else
|
||
private static let font: CGFloat = 12
|
||
private static let hPad: CGFloat = 12
|
||
private static let vPad: CGFloat = 7
|
||
#endif
|
||
|
||
var body: some View {
|
||
HStack(spacing: 7) {
|
||
Image(systemName: controller.hasTouchpadAndMotion
|
||
? "playstation.logo" : "gamecontroller.fill")
|
||
.font(.system(size: Self.font))
|
||
Text(controller.name)
|
||
.lineLimit(1)
|
||
if let level = controller.batteryLevel {
|
||
Image(systemName: batterySymbol(level))
|
||
.font(.system(size: Self.font))
|
||
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
||
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
|
||
}
|
||
}
|
||
.font(.geist(Self.font, .medium, relativeTo: .caption))
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
.padding(.horizontal, Self.hPad)
|
||
.padding(.vertical, Self.vPad)
|
||
.background(Capsule().fill(.white.opacity(0.08)))
|
||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||
}
|
||
|
||
private func batterySymbol(_ level: Float) -> String {
|
||
if controller.isCharging { return "battery.100.bolt" }
|
||
switch level {
|
||
case ..<0.125: return "battery.0"
|
||
case ..<0.375: return "battery.25"
|
||
case ..<0.625: return "battery.50"
|
||
case ..<0.875: return "battery.75"
|
||
default: return "battery.100"
|
||
}
|
||
}
|
||
}
|
||
#endif
|