ecbbff5544
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
404 lines
16 KiB
Swift
404 lines
16 KiB
Swift
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host
|
|
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built
|
|
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
|
|
// required (a tap still works as a fallback). Scope: browse saved + discovered hosts, connect, and
|
|
// — when the library flag is on — jump into a saved host's library (Y).
|
|
//
|
|
// All the scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the launcher's
|
|
// chrome. Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
|
|
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
|
|
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
|
|
// status bar / home indicator. As a background it draws behind without affecting layout, so the
|
|
// GeometryReader is sized to the safe area. The title and the controller-glyph hints are pinned with
|
|
// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's
|
|
// vertical budget — and the card is sized off the remaining height. tvOS/macOS never mount this view.
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
#if os(iOS)
|
|
import GameController
|
|
|
|
/// One navigable tile: a saved host or a discovered-but-unsaved one. Hashable so it can be the
|
|
/// carousel's scroll-position identity.
|
|
private enum GamepadHomeTarget: Hashable {
|
|
case saved(UUID)
|
|
case discovered(String)
|
|
}
|
|
|
|
/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render
|
|
/// from the live stores so nothing goes stale.
|
|
private struct HomeTile: Identifiable {
|
|
let id: GamepadHomeTarget
|
|
let title: String
|
|
let subtitle: String
|
|
let isOnline: Bool
|
|
let isPaired: Bool
|
|
let isConnecting: Bool
|
|
/// Saved (solid monogram) vs. discovered-but-unsaved (tinted outline).
|
|
let filled: Bool
|
|
/// Only saved hosts have a library (matches the touch grid's context-menu gate).
|
|
let hasLibrary: Bool
|
|
let activate: () -> Void
|
|
}
|
|
|
|
struct GamepadHomeView: View {
|
|
@ObservedObject var store: HostStore
|
|
@ObservedObject var model: SessionModel
|
|
@ObservedObject var discovery: HostDiscovery
|
|
@Binding var libraryTarget: StoredHost?
|
|
let connect: (StoredHost) -> Void
|
|
let connectDiscovered: (DiscoveredHost) -> Void
|
|
|
|
/// Same experimental gate the touch grid's "Browse Library…" context-menu item uses.
|
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
|
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
|
@State private var selection: GamepadHomeTarget?
|
|
@State private var breathe = false
|
|
|
|
private var compact: Bool { vSizeClass == .compact }
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
hero(for: geo.size)
|
|
}
|
|
// Pinned inside the safe area, out of the carousel's vertical budget — never clipped.
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
titleView
|
|
.padding(.top, compact ? 4 : 10)
|
|
.padding(.bottom, compact ? 4 : 8)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
|
if !tiles.isEmpty {
|
|
hintBar
|
|
.padding(.leading, 22)
|
|
.padding(.vertical, compact ? 6 : 10)
|
|
}
|
|
}
|
|
.background { background }
|
|
.onAppear {
|
|
discovery.start()
|
|
withAnimation(.easeInOut(duration: 4).repeatForever(autoreverses: true)) { breathe = true }
|
|
}
|
|
.onDisappear { discovery.stop() }
|
|
.alert(
|
|
"Connection failed",
|
|
isPresented: Binding(
|
|
get: { model.errorMessage != nil },
|
|
set: { if !$0 { model.errorMessage = nil } })
|
|
) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(model.errorMessage ?? "")
|
|
}
|
|
}
|
|
|
|
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
|
|
|
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
|
if tiles.isEmpty {
|
|
emptyState.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
let cardWidth = min(340, size.width * 0.84)
|
|
// 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
|
|
// the strip + detail always fit the region the safe-area insets leave.
|
|
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
|
|
VStack(spacing: compact ? 8 : 10) {
|
|
Spacer(minLength: 0)
|
|
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
|
detailPanel
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Chrome
|
|
|
|
private var background: some View {
|
|
ZStack {
|
|
LinearGradient(
|
|
colors: [.black, Color.brand.opacity(0.22), .black],
|
|
startPoint: .top, endPoint: .bottom)
|
|
// A soft brand orb behind the strip gives the flat gradient depth; it breathes slowly.
|
|
Circle()
|
|
.fill(RadialGradient(
|
|
colors: [Color.brand.opacity(0.55), .clear],
|
|
center: .center, startRadius: 0, endRadius: 300))
|
|
.frame(width: 560, height: 560)
|
|
.blur(radius: 70)
|
|
.scaleEffect(breathe ? 1.08 : 0.92)
|
|
.opacity(breathe ? 0.5 : 0.32)
|
|
.offset(y: -20)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
|
|
private var titleView: some View {
|
|
Text("Select a Host")
|
|
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: "gamecontroller")
|
|
.font(.system(size: 46, weight: .light))
|
|
.foregroundStyle(Color.brand)
|
|
Text("No hosts yet")
|
|
.font(.geist(20, .semibold, relativeTo: .title3))
|
|
.foregroundStyle(.white)
|
|
Text("Add one with touch first — it'll show up here for the controller.")
|
|
.font(.geist(15, relativeTo: .body))
|
|
.foregroundStyle(.white.opacity(0.6))
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: 320)
|
|
}
|
|
}
|
|
|
|
// MARK: - Carousel
|
|
|
|
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
|
GamepadCarousel(
|
|
items: tiles,
|
|
selection: $selection,
|
|
itemWidth: cardWidth,
|
|
spacing: 30,
|
|
onActivate: { $0.activate() },
|
|
onSecondary: { openLibraryForSelected() },
|
|
// Stop consuming the controller while the library is presented on top — otherwise the
|
|
// launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
|
|
isActive: libraryTarget == nil
|
|
) { tile in
|
|
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
|
}
|
|
.frame(height: cardHeight + 40)
|
|
}
|
|
|
|
/// The host tile plus its focus treatment. Every continuous visual reads the scroll view's own
|
|
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
|
|
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
|
|
/// brightness/saturation + a depth blur on the recessed neighbors.
|
|
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
|
|
GamepadHostTile(tile: tile, size: size)
|
|
.scrollTransition { content, phase in
|
|
let d = CGFloat(min(abs(phase.value), 1))
|
|
let scale = 1 - d * 0.12
|
|
let bright = Double(-d * 0.24)
|
|
let sat = Double(1 - d * 0.42)
|
|
let soft = d * 3
|
|
let fade = Double(1 - d * 0.22)
|
|
return content
|
|
.scaleEffect(scale)
|
|
.brightness(bright)
|
|
.saturation(sat)
|
|
.blur(radius: soft)
|
|
.opacity(fade)
|
|
}
|
|
}
|
|
|
|
/// The "now focused" host, spelled out below the strip — empty (not hidden) so the layout
|
|
/// doesn't jump as the selection changes.
|
|
@ViewBuilder private var detailPanel: some View {
|
|
let tile = tiles.first { $0.id == selection }
|
|
VStack(spacing: 6) {
|
|
Text(tile?.title ?? " ")
|
|
.font(.geist(22, .bold, relativeTo: .title2))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
HStack(spacing: 10) {
|
|
Text(tile?.subtitle ?? " ")
|
|
.font(.geist(13, relativeTo: .caption))
|
|
.foregroundStyle(.white.opacity(0.6))
|
|
if let tile {
|
|
statusPill(online: tile.isOnline, paired: tile.isPaired)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 24)
|
|
.animation(.smooth(duration: 0.25), value: selection)
|
|
}
|
|
|
|
private func statusPill(online: Bool, paired: Bool) -> some View {
|
|
HStack(spacing: 6) {
|
|
Circle()
|
|
.fill(online ? Color.green : Color.white.opacity(0.35))
|
|
.frame(width: 6, height: 6)
|
|
Text(online ? "ONLINE" : "OFFLINE")
|
|
if paired { Text("· PAIRED") }
|
|
}
|
|
.font(.geist(11, .medium, relativeTo: .caption2))
|
|
.tracking(0.8)
|
|
.foregroundStyle(.white.opacity(0.55))
|
|
}
|
|
|
|
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
|
|
|
private var hintBar: some View {
|
|
HStack(spacing: 18) {
|
|
hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Connect")
|
|
if showsLibraryHint {
|
|
hint(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")
|
|
}
|
|
}
|
|
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
}
|
|
|
|
private func hint(glyph: String, text: String) -> some View {
|
|
HStack(spacing: 7) {
|
|
Image(systemName: glyph)
|
|
.font(.system(size: 19))
|
|
.foregroundStyle(.white)
|
|
Text(text)
|
|
}
|
|
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
|
}
|
|
|
|
private var showsLibraryHint: Bool {
|
|
guard libraryEnabled else { return false }
|
|
return tiles.first { $0.id == selection }?.hasLibrary ?? false
|
|
}
|
|
|
|
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
|
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
|
private func buttonGlyph(
|
|
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
|
) -> String {
|
|
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
|
?? fallback
|
|
}
|
|
|
|
// MARK: - Data + actions
|
|
|
|
/// Built fresh each render from the live stores (no stale value capture) — saved hosts first,
|
|
/// then discovered-but-unsaved ones.
|
|
private var tiles: [HomeTile] {
|
|
let saved = store.hosts.map { host in
|
|
HomeTile(
|
|
id: .saved(host.id),
|
|
title: host.displayName,
|
|
subtitle: "\(host.address):\(String(host.port))",
|
|
isOnline: isOnline(host),
|
|
isPaired: host.pinnedSHA256 != nil,
|
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
|
filled: true,
|
|
hasLibrary: true,
|
|
activate: { connect(host) })
|
|
}
|
|
let discovered = discoveredUnsaved.map { d in
|
|
HomeTile(
|
|
id: .discovered(d.id),
|
|
title: d.name,
|
|
subtitle: "\(d.host):\(String(d.port))",
|
|
isOnline: true,
|
|
isPaired: false,
|
|
isConnecting: false,
|
|
filled: false,
|
|
hasLibrary: false,
|
|
activate: { connectDiscovered(d) })
|
|
}
|
|
return saved + discovered
|
|
}
|
|
|
|
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
|
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
|
|
private func openLibraryForSelected() {
|
|
guard libraryEnabled, case .saved(let id) = selection,
|
|
let host = store.hosts.first(where: { $0.id == id })
|
|
else { return }
|
|
libraryTarget = host
|
|
}
|
|
|
|
private func isOnline(_ host: StoredHost) -> Bool {
|
|
discovery.hosts.contains { host.matches($0) }
|
|
}
|
|
|
|
private var discoveredUnsaved: [DiscoveredHost] {
|
|
discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } }
|
|
}
|
|
}
|
|
|
|
/// One "console tile" in the host carousel — a dark-glass landscape card, bigger and bolder than the
|
|
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
|
|
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
|
|
private struct GamepadHostTile: View {
|
|
let tile: HomeTile
|
|
let size: CGSize
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(alignment: .top) {
|
|
monogramBadge
|
|
Spacer(minLength: 0)
|
|
if tile.isOnline {
|
|
Circle()
|
|
.fill(Color.green)
|
|
.frame(width: 9, height: 9)
|
|
.shadow(color: .green.opacity(0.7), radius: 5)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
Text(tile.title)
|
|
.font(.geist(23, .bold, relativeTo: .title2))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
Text(tile.subtitle)
|
|
.font(.geist(13, relativeTo: .caption))
|
|
.foregroundStyle(.white.opacity(0.55))
|
|
.lineLimit(1)
|
|
.padding(.top, 2)
|
|
}
|
|
.padding(20)
|
|
.frame(width: size.width, height: size.height, alignment: .leading)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
.environment(\.colorScheme, .dark)
|
|
}
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
.strokeBorder(
|
|
LinearGradient(
|
|
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
|
startPoint: .top, endPoint: .bottom),
|
|
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
|
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
|
|
}
|
|
|
|
private var monogramBadge: some View {
|
|
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
|
|
return ZStack {
|
|
shape.fill(tile.filled
|
|
? AnyShapeStyle(LinearGradient(
|
|
colors: [Color.brand, Color.brand.opacity(0.68)],
|
|
startPoint: .top, endPoint: .bottom))
|
|
: AnyShapeStyle(Color.brand.opacity(0.16)))
|
|
if tile.isConnecting {
|
|
ProgressView().tint(.white)
|
|
} else {
|
|
Text(monogram(tile.title))
|
|
.font(.geistFixed(25, .bold))
|
|
.foregroundStyle(tile.filled ? .white : Color.brand)
|
|
}
|
|
}
|
|
.frame(width: 52, height: 52)
|
|
.overlay {
|
|
if !tile.filled {
|
|
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func monogram(_ name: String) -> String {
|
|
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
|
return String(first).uppercased()
|
|
}
|
|
}
|
|
#endif
|