4e00037a89
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>
102 lines
4.5 KiB
Swift
102 lines
4.5 KiB
Swift
// Geist — the punktfunk brand typeface (the same family the website ships). Bundled as static
|
|
// OTF weights in this kit's resources and registered with Core Text at first use, so it works
|
|
// identically in the Xcode app and the `swift run` dev shell (Bundle.module resolves to the
|
|
// package resource bundle in both). Geist Sans carries titles/UI; Geist Mono carries the technical
|
|
// readouts — host addresses, status labels, the stream-stats HUD — for the industrial look.
|
|
//
|
|
// Licensed under the SIL Open Font License 1.1 (Resources/Fonts/Geist-OFL.txt).
|
|
|
|
import CoreText
|
|
import SwiftUI
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#elseif canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
public enum BrandFont {
|
|
public enum Weight {
|
|
case regular, medium, semibold, bold
|
|
}
|
|
|
|
/// PostScript names of the bundled faces (verified from each OTF's name table). Geist Sans only
|
|
/// — Geist Mono is intentionally not shipped; the app's typeface is Geist Sans throughout.
|
|
private static let sansFaces = ["Geist-Regular", "Geist-Medium", "Geist-SemiBold", "Geist-Bold"]
|
|
|
|
/// Registered exactly once per process — a static `let` initializer is run lazily and is
|
|
/// guaranteed thread-safe + run-at-most-once by the runtime.
|
|
private static let registered: Void = {
|
|
for face in sansFaces {
|
|
guard let url = Bundle.module.url(
|
|
forResource: face, withExtension: "otf", subdirectory: "Fonts") else {
|
|
#if DEBUG
|
|
print("BrandFont: bundled face \(face).otf not found — text will fall back to system")
|
|
#endif
|
|
continue
|
|
}
|
|
var error: Unmanaged<CFError>?
|
|
if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) {
|
|
#if DEBUG
|
|
let message = error?.takeRetainedValue().localizedDescription ?? "unknown error"
|
|
print("BrandFont: failed to register \(face): \(message)")
|
|
#endif
|
|
}
|
|
}
|
|
}()
|
|
|
|
/// Force registration before the first `Font.custom` lookup. Cheap to call repeatedly.
|
|
public static func registerIfNeeded() { _ = registered }
|
|
|
|
fileprivate static func sansFace(_ weight: Weight) -> String {
|
|
switch weight {
|
|
case .regular: return "Geist-Regular"
|
|
case .medium: return "Geist-Medium"
|
|
case .semibold: return "Geist-SemiBold"
|
|
case .bold: return "Geist-Bold"
|
|
}
|
|
}
|
|
}
|
|
|
|
public extension Color {
|
|
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
|
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
|
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
|
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
|
static let brand: Color = {
|
|
#if canImport(UIKit)
|
|
return Color(UIColor { traits in
|
|
traits.userInterfaceStyle == .dark
|
|
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
})
|
|
#elseif canImport(AppKit)
|
|
return Color(NSColor(name: nil) { appearance in
|
|
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
|
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
})
|
|
#else
|
|
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
|
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
|
#endif
|
|
}()
|
|
}
|
|
|
|
public extension Font {
|
|
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
|
static func geist(
|
|
_ size: CGFloat, _ weight: BrandFont.Weight = .regular,
|
|
relativeTo textStyle: TextStyle = .body
|
|
) -> Font {
|
|
BrandFont.registerIfNeeded()
|
|
return .custom(BrandFont.sansFace(weight), size: size, relativeTo: textStyle)
|
|
}
|
|
|
|
/// Geist Sans at a FIXED point size that does not scale with Dynamic Type — for glyphs pinned
|
|
/// inside a fixed-size container (e.g. the monogram tile), where a scaled letter would overflow.
|
|
static func geistFixed(_ size: CGFloat, _ weight: BrandFont.Weight = .regular) -> Font {
|
|
BrandFont.registerIfNeeded()
|
|
return .custom(BrandFont.sansFace(weight), fixedSize: size)
|
|
}
|
|
}
|