feat(apple): stage-2 default + pixel-perfect, decode robustness, UI/rumble polish
apple / swift (push) Successful in 1m4s
android / android (push) Successful in 4m33s
ci / rust (push) Successful in 5m4s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m12s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m48s
apple / screenshots (push) Successful in 5m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
apple / swift (push) Successful in 1m4s
android / android (push) Successful in 4m33s
ci / rust (push) Successful in 5m4s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m12s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m48s
apple / screenshots (push) Successful in 5m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
Stream reliability - Default to the stage-2 presenter (VTDecompressionSession + CAMetalLayer): it detects and recovers a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no app-side recovery (confirmed Apple limitation). Stage 1 is now a DEBUG-only presenter toggle, plus the automatic no-Metal fallback. - Stage-2 pixel-perfect: render the drawable at the decoded size (shader stays 1:1 = identity) and let the layer's contentsGravity scale via the system compositor — the same path stage-1's videoGravity used — instead of scaling in-shader. - Loss recovery in both pumps is now a persistent awaitingIDR want, retried until an IDR actually lands, so a keyframe request swallowed by the throttle can't strand a frozen frame; 100 ms keyframe throttle to match the Android path. - Fix "Publishing changes from within view updates": defer the HostStore writes out of the .onChange(of: model.phase) callback. - Move AVAudioSession setActive/setCategory off the main thread (async on a shared serial queue) to stop the UI-stall warning. Controllers - Rumble: capped-exponential backoff when the gamecontrollerd.haptics XPC breaks (-4811) so a transient server interruption self-heals instead of cascading; playsHapticsOnly so a controller engine doesn't join the always-active streaming audio session. - Host cards: iPad pointer "magnet" hover effect; iPhone press scale + light haptic. UI / design - Ship Geist (SIL OFL 1.1) as the app font (bundled OTFs + registration), with the license surfaced in Acknowledgements. - Restructure iOS/iPadOS Settings into a category NavigationSplitView; resolution wheel with custom-resolution entry; 10-bit HDR toggle in Display. - Industrial host-card redesign (left-aligned, bold, brand monogram tiles). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,26 +1,75 @@
|
||||
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
||||
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
||||
// host (tap to save + connect). Both share the "monogram module" look — a squared brand-purple
|
||||
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
|
||||
// (address, status), framed by a hairline panel border. Industrial, not soft.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
|
||||
private struct CardMetrics {
|
||||
let iconSize: CGFloat
|
||||
let iconBox: CGFloat
|
||||
let cardPadding: CGFloat
|
||||
let nameFont: Font
|
||||
let tile: CGFloat // monogram tile side
|
||||
let monogram: CGFloat // monogram letter point size
|
||||
let name: CGFloat // host-name point size
|
||||
let meta: CGFloat // address (mono) point size
|
||||
let status: CGFloat // status-label (mono) point size
|
||||
let padding: CGFloat
|
||||
let spacing: CGFloat // tile ↔ text gap
|
||||
let radius: CGFloat
|
||||
|
||||
static var current: CardMetrics {
|
||||
#if os(iOS)
|
||||
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||
padding: 16, spacing: 14, radius: 12)
|
||||
#elseif os(tvOS)
|
||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
||||
padding: 18, spacing: 18, radius: 14)
|
||||
#else
|
||||
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||
padding: 13, spacing: 12, radius: 10)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
||||
/// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet.
|
||||
private func monogram(_ name: String) -> String {
|
||||
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||
return String(first).uppercased()
|
||||
}
|
||||
|
||||
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
|
||||
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
||||
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.72)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(Color.brand.opacity(0.14)))
|
||||
if connecting {
|
||||
ProgressView().tint(filled ? .white : Color.brand)
|
||||
} else {
|
||||
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
|
||||
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
|
||||
// the clip below are belt-and-suspenders for an unusually wide glyph.
|
||||
Text(letter)
|
||||
.font(.geistFixed(m.monogram, .bold))
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(filled ? Color.white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: m.tile, height: m.tile)
|
||||
.clipShape(shape)
|
||||
.overlay {
|
||||
if !filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
|
||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||
struct HostCardView: View {
|
||||
let host: StoredHost
|
||||
@@ -41,66 +90,44 @@ struct HostCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
ZStack {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isConnecting ? 0.3 : 1)
|
||||
if isConnecting {
|
||||
ProgressView()
|
||||
}
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.displayName)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
statusRow(m)
|
||||
}
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
||||
Circle()
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
||||
.frame(width: 7, height: 7)
|
||||
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
||||
Text(host.displayName)
|
||||
.font(m.nameFont)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
if host.pinnedSHA256 != nil {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let last = host.lastConnected {
|
||||
Text("Connected \(last, format: .relative(presentation: .named))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
||||
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
||||
// and a brand accent bar down the leading edge for the most-recent host.
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .leading) {
|
||||
if isMostRecent {
|
||||
Rectangle().fill(Color.brand).frame(width: 3)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
||||
// Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content
|
||||
// tiles (it flattens hierarchy over an opaque grid) — see GlassStyle.swift.
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
if isMostRecent {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
||||
}
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
@@ -119,10 +146,31 @@ struct HostCardView: View {
|
||||
Button("Remove", role: .destructive, action: onRemove)
|
||||
}
|
||||
}
|
||||
|
||||
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
|
||||
/// certificate is pinned (the lock state, spelled out).
|
||||
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 6, height: 6)
|
||||
// The state is spelled out in the adjacent text, so the pip is decorative —
|
||||
// otherwise VoiceOver reads the status twice ("Online, ONLINE …").
|
||||
.accessibilityHidden(true)
|
||||
Text(isOnline ? "ONLINE" : "OFFLINE")
|
||||
if host.pinnedSHA256 != nil {
|
||||
Text("· PAIRED")
|
||||
}
|
||||
}
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
||||
/// tapping saves it and connects (or pairs, if the host requires it).
|
||||
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||
struct DiscoveredCardView: View {
|
||||
let discovered: DiscoveredHost
|
||||
let isBusy: Bool
|
||||
@@ -131,47 +179,77 @@ struct DiscoveredCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(discovered.name)
|
||||
.font(m.nameFont)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: discovered.requiresPairing
|
||||
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: m.status))
|
||||
.accessibilityHidden(true) // decorative; the adjacent text says the state
|
||||
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
|
||||
}
|
||||
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.secondary.opacity(0.25),
|
||||
Color.secondary.opacity(0.3),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The iOS host-card press/hover treatment, one style for both idioms:
|
||||
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
|
||||
/// inert without a pointer.)
|
||||
/// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the
|
||||
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
|
||||
/// press scale doubles as click feedback.)
|
||||
struct HostCardButtonStyle: ButtonStyle {
|
||||
var cornerRadius: CGFloat
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
|
||||
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
|
||||
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.hoverEffect(.highlight)
|
||||
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
|
||||
// hardware on iPad → silently ignored there.
|
||||
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
|
||||
pressed ? .impact(weight: .light) : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user