fix(apple/tvOS): television-idiomatic chrome — grid action tiles + full-screen covers
ci / rust (push) Has been cancelled

The iOS chrome half-worked on tvOS: toolbar items rendered tiny with clipped labels
and could not even be focused (which is why "+" never opened the add-host form), and
sheet presentations are not a tvOS idiom (the Settings form looked broken).

- The toolbar is gone on tvOS. Add Host and Settings live IN the hosts grid as
  full-size, focus-native tiles (.card style, same geometry as the host cards) — the
  natural way actions work on television.
- Every modal (Add Host, Settings, PIN pairing) presents as a fullScreenCover on tvOS;
  Settings gains a tvOS-only Done button (covers don't dismiss themselves).
- iOS/macOS keep their existing toolbar + sheets untouched.

Verified in the Apple TV simulator: title, host card and both action tiles render
full-size and focusable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:22:18 +02:00
parent 75396c20c2
commit 7dd479f9e4
2 changed files with 63 additions and 2 deletions
@@ -55,6 +55,17 @@ struct ContentView: View {
// On the outer Group so the sheet survives the trust-prompt home transition // On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop // (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session). // is sequential, a pairing connection would queue behind the live session).
#if os(tvOS)
.fullScreenCover(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in
guard pairingTarget?.id == host.id else { return }
store.pin(host.id, fingerprint: fingerprint)
var pinned = host
pinned.pinnedSHA256 = fingerprint
connect(pinned)
}
}
#else
.sheet(item: $pairingTarget) { host in .sheet(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in PairSheet(host: host) { fingerprint in
// Backstop against a stale ceremony surfacing after dismissal (PairSheet // Backstop against a stale ceremony surfacing after dismissal (PairSheet
@@ -66,6 +77,7 @@ struct ContentView: View {
connect(pinned) connect(pinned)
} }
} }
#endif
} }
private var sessionView: some View { private var sessionView: some View {
@@ -114,14 +126,23 @@ struct ContentView: View {
ForEach(store.hosts) { host in ForEach(store.hosts) { host in
hostCard(host) hostCard(host)
} }
#if os(tvOS)
actionTile("Add Host", systemImage: "plus") {
showAddHost = true
}
actionTile("Settings", systemImage: "gearshape") {
showSettings = true
}
#endif
} }
.padding() .padding()
} }
} }
} }
.navigationTitle("Punktfunkempfänger") .navigationTitle("Punktfunkempfänger")
#if !os(tvOS)
.toolbar { .toolbar {
#if !os(macOS) #if os(iOS)
// Adjacent trailing items share one glass pill (the system default). // Adjacent trailing items share one glass pill (the system default).
ToolbarItem(placement: .topBarTrailing) { settingsButton } ToolbarItem(placement: .topBarTrailing) { settingsButton }
ToolbarItem(placement: .topBarTrailing) { addHostButton } ToolbarItem(placement: .topBarTrailing) { addHostButton }
@@ -138,14 +159,23 @@ struct ContentView: View {
} }
#endif #endif
} }
#endif
} }
#if os(macOS) #if os(macOS)
.frame(minWidth: 480, minHeight: 360) .frame(minWidth: 480, minHeight: 360)
#endif #endif
#if os(tvOS)
.fullScreenCover(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
.fullScreenCover(isPresented: $showSettings) {
SettingsView()
}
#else
.sheet(isPresented: $showAddHost) { .sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) } AddHostSheet { store.add($0) }
} }
#if !os(macOS) #if os(iOS)
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
NavigationStack { NavigationStack {
SettingsView() SettingsView()
@@ -156,6 +186,7 @@ struct ContentView: View {
} }
} }
#endif #endif
#endif
.alert( .alert(
"Connection failed", "Connection failed",
isPresented: Binding( isPresented: Binding(
@@ -306,6 +337,30 @@ struct ContentView: View {
#endif #endif
} }
#if os(tvOS)
/// Grid-resident replacement for the toolbar (whose items are neither sized nor
/// focusable on tvOS): a full-size, focus-native tile per action.
private func actionTile(
_ label: String, systemImage: String, action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 10) {
Image(systemName: systemImage)
.font(.system(size: 56, weight: .light))
.foregroundStyle(.tint)
.frame(height: 76)
Text(label)
.font(.title3.weight(.semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
.padding(.horizontal, 12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.card)
}
#endif
/// The host of the most recent session its card carries the accent ring. /// The host of the most recent session its card carries the accent ring.
private var mostRecentHostID: UUID? { private var mostRecentHostID: UUID? {
store.hosts store.hosts
@@ -9,6 +9,7 @@ import PunktfunkKit
import SwiftUI import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage("punktfunk.width") private var width = 1920 @AppStorage("punktfunk.width") private var width = 1920
@AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.height") private var height = 1080
@AppStorage("punktfunk.hz") private var hz = 60 @AppStorage("punktfunk.hz") private var hz = 60
@@ -99,6 +100,11 @@ struct SettingsView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
#if os(tvOS)
Section {
Button("Done") { dismiss() }
}
#endif
} }
.formStyle(.grouped) .formStyle(.grouped)
#if os(macOS) #if os(macOS)