feat(apple): tvOS client — third app target, first-lit in the Apple TV simulator
ci / rust (push) Has been cancelled

The same app now runs on tvOS (target Punktfunk-tvOS, bundle io.unom.punktfunk.tvos),
validated live against the box: vkcube at 1280x720@60, 60 fps in the Apple TV 4K
simulator, glass HUD with a focusable Disconnect button.

- PunktfunkCore.xcframework grows tvOS device + universal-simulator slices. These are
  TIER-3 Rust targets (no prebuilt std): BUILD_TVOS=1 builds them with nightly and
  -Zbuild-std from rust-src — the full quic stack (quinn/rustls-ring/tokio) compiles
  for tvOS unchanged.
- The UIKit stream view covers iOS AND tvOS, with pointer interaction, pointer lock,
  touch forwarding and InputCapture gated to iOS — tvOS is view-only until gamepad
  capture lands (the natural tvOS input).
- SessionAudio on tvOS: .playback session, no mic (no app-accessible microphone).
- App chrome gates: keyboardShortcut/textSelection/controlSize/statusBarHidden are
  iOS/macOS-only; host cards use the focus-native .card button style on tvOS; the
  Audio settings section hides (system-routed); mode seeding works from the TV screen
  (1920x1080@60).
- Package platforms += .tvOS(.v17); new Xcode target + shared scheme
  (TARGETED_DEVICE_FAMILY 3, local-network usage description included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:10:40 +02:00
parent ee12e535ee
commit bfd8c7be93
11 changed files with 302 additions and 23 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "PunktfunkKit",
platforms: [.macOS(.v14), .iOS(.v17)],
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],
products: [
.library(name: "PunktfunkKit", targets: ["PunktfunkKit"]),
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
@@ -9,11 +9,13 @@
/* Begin PBXBuildFile section */
AA0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000000000000000000006 /* PunktfunkKit */; };
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -46,6 +48,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
CC0000000000000000000004 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -63,6 +73,7 @@
children = (
AA0000000000000000000001 /* Punktfunk.app */,
BB0000000000000000000001 /* Punktfunk-iOS.app */,
CC0000000000000000000001 /* Punktfunk-tvOS.app */,
);
name = Products;
sourceTree = "<group>";
@@ -118,6 +129,30 @@
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
productType = "com.apple.product-type.application";
};
CC0000000000000000000009 /* Punktfunk-tvOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = CC000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk-tvOS" */;
buildPhases = (
CC000000000000000000000B /* Sources */,
CC0000000000000000000004 /* Frameworks */,
CC000000000000000000000C /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* App */,
AA0000000000000000000003 /* Sources/PunktfunkClient */,
);
name = "Punktfunk-tvOS";
packageProductDependencies = (
CC0000000000000000000006 /* PunktfunkKit */,
);
productName = "Punktfunk-tvOS";
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -150,6 +185,7 @@
targets = (
AA0000000000000000000009 /* Punktfunk */,
BB0000000000000000000009 /* Punktfunk-iOS */,
CC0000000000000000000009 /* Punktfunk-tvOS */,
);
};
/* End PBXProject section */
@@ -169,6 +205,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
CC000000000000000000000C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -186,6 +229,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
CC000000000000000000000B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
@@ -434,9 +484,70 @@
};
name = Release;
};
CC0000000000000000000012 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.tvos;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 17.0;
};
name = Debug;
};
CC0000000000000000000013 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.tvos;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 17.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
CC000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk-tvOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CC0000000000000000000012 /* Debug */,
CC0000000000000000000013 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -482,6 +593,10 @@
isa = XCSwiftPackageProductDependency;
productName = PunktfunkKit;
};
CC0000000000000000000006 /* PunktfunkKit */ = {
isa = XCSwiftPackageProductDependency;
productName = PunktfunkKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = AA000000000000000000000D /* Project object */;
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CC0000000000000000000009"
BuildableName = "Punktfunk-tvOS.app"
BlueprintName = "Punktfunk-tvOS"
ReferencedContainer = "container:Punktfunk.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CC0000000000000000000009"
BuildableName = "Punktfunk-tvOS.app"
BlueprintName = "Punktfunk-tvOS"
ReferencedContainer = "container:Punktfunk.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CC0000000000000000000009"
BuildableName = "Punktfunk-tvOS.app"
BlueprintName = "Punktfunk-tvOS"
ReferencedContainer = "container:Punktfunk.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -21,7 +21,9 @@ struct AddHostSheet: View {
.formStyle(.grouped)
HStack {
Button("Cancel", role: .cancel) { dismiss() }
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
Spacer()
Button("Add Host") {
onAdd(StoredHost(
@@ -31,7 +33,9 @@ struct AddHostSheet: View {
dismiss()
}
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
}
#if os(iOS)
@@ -23,7 +23,7 @@ struct ContentView: View {
@AppStorage("punktfunk.compositor") private var compositor = 0
@State private var showAddHost = false
@State private var pairingTarget: StoredHost?
#if os(iOS)
#if !os(macOS)
@State private var showSettings = false
#endif
@@ -88,13 +88,16 @@ struct ContentView: View {
#if os(macOS)
.frame(minWidth: 640, minHeight: 360)
.background(Color.black)
#else
#elseif os(iOS)
// Streaming is immersive: edge-to-edge under the status bar and home
// indicator, both hidden for the session (they return with the hosts grid).
.background(Color.black)
.ignoresSafeArea()
.statusBarHidden(true)
.persistentSystemOverlays(.hidden)
#else
.background(Color.black)
.ignoresSafeArea()
#endif
}
@@ -118,7 +121,7 @@ struct ContentView: View {
}
.navigationTitle("Punktfunkempfänger")
.toolbar {
#if os(iOS)
#if !os(macOS)
// Adjacent trailing items share one glass pill (the system default).
ToolbarItem(placement: .topBarTrailing) { settingsButton }
ToolbarItem(placement: .topBarTrailing) { addHostButton }
@@ -142,7 +145,7 @@ struct ContentView: View {
.sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
#if os(iOS)
#if !os(macOS)
.sheet(isPresented: $showSettings) {
NavigationStack {
SettingsView()
@@ -185,7 +188,7 @@ struct ContentView: View {
}
}
#if os(iOS)
#if !os(macOS)
private var settingsButton: some View {
Button {
showSettings = true
@@ -270,7 +273,11 @@ struct ContentView: View {
}
}
}
#if os(tvOS)
.buttonStyle(.card)
#else
.buttonStyle(.plain)
#endif
.disabled(model.isBusy)
.contextMenu {
Button("Pair with PIN…") {
@@ -289,7 +296,7 @@ struct ContentView: View {
/// compiled-in AppStorage defaults only apply until any value is saved; macOS keeps
/// 1080p a desktop window is not the screen.)
private func seedDefaultModeIfNeeded() {
#if os(iOS)
#if !os(macOS)
let defaults = UserDefaults.standard
guard defaults.object(forKey: "punktfunk.width") == nil else { return }
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
@@ -332,19 +339,25 @@ struct ContentView: View {
.multilineTextAlignment(.center)
Text(Self.format(fingerprint: fingerprint))
.font(.system(.callout, design: .monospaced))
#if !os(tvOS)
.textSelection(.enabled)
#endif
.padding(10)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: 12) {
Button("Cancel", role: .cancel) { model.rejectTrust() }
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
Button("Trust & Connect") {
if let fp = model.confirmTrust(), let host = model.activeHost {
store.pin(host.id, fingerprint: fp)
}
}
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
#if os(iOS)
.controlSize(.large)
@@ -419,7 +432,7 @@ struct ContentView: View {
: "Click the stream to capture input")
.font(.caption2)
.foregroundStyle(.secondary)
#else
#elseif os(iOS)
// Touch always plays directly; (hardware keyboard) toggles kb/mouse.
Text(model.mouseCaptured
? "⌘⎋ releases keyboard & mouse"
@@ -429,7 +442,9 @@ struct ContentView: View {
#endif
Button("Disconnect (⌘D)") { model.disconnect() }
.font(.caption)
#if !os(tvOS)
.keyboardShortcut("d", modifiers: .command)
#endif
}
.padding(10)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
@@ -71,7 +71,9 @@ struct PairSheet: View {
token.cancelled = true
dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
Spacer()
if busy {
ProgressView()
@@ -80,7 +82,9 @@ struct PairSheet: View {
}
Button("Pair & Connect") { runCeremony() }
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
}
#if os(iOS)
@@ -42,6 +42,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
#if !os(tvOS)
Section {
#if os(macOS)
Picker("Speaker", selection: $speakerUID) {
@@ -55,7 +56,9 @@ struct SettingsView: View {
}
}
#endif
#if !os(tvOS)
Toggle("Send microphone to the host", isOn: $micEnabled)
#endif
#if os(macOS)
Picker("Microphone", selection: $micUID) {
Text("System default").tag("")
@@ -78,6 +81,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
#endif
Section {
Picker("Compositor", selection: $compositor) {
Text("Automatic").tag(0)
@@ -26,7 +26,7 @@
#if os(macOS)
import AppKit
#endif
#if os(iOS)
#if canImport(UIKit)
import UIKit
#endif
import Foundation
@@ -161,8 +161,18 @@ public final class SessionAudio {
} catch {
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
}
#elseif os(tvOS)
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
}
#endif
startPlayback(speakerUID: speakerUID)
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
#else
guard micEnabled else { return }
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
@@ -177,6 +187,7 @@ public final class SessionAudio {
default:
log.warning("microphone access denied — mic uplink disabled (System Settings → Privacy)")
}
#endif
}
/// Stop both directions. Safe from any thread; waits the drain thread out ( its
@@ -199,7 +210,7 @@ public final class SessionAudio {
if wasDraining {
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
}
#if os(iOS)
#if !os(macOS)
// Release the session so audio we interrupted (Music, podcasts) gets its
// resume cue.
do {
@@ -310,6 +321,7 @@ public final class SessionAudio {
// MARK: - Mic (mic host)
#if !os(tvOS)
private func startCapture(micUID: String) {
let engine = AVAudioEngine()
let input = engine.inputNode
@@ -408,6 +420,7 @@ public final class SessionAudio {
stateLock.unlock()
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
}
#endif
#if os(macOS)
private static func setDevice(_ id: AudioDeviceID, on unit: AudioUnit) -> Bool {
@@ -15,7 +15,7 @@
// The public type is named StreamView like its macOS twin (each is platform-gated), so
// the SwiftUI app layer is identical on both platforms.
#if os(iOS)
#if os(iOS) || os(tvOS)
import AVFoundation
import GameController
import PunktfunkCore
@@ -66,20 +66,24 @@ public struct StreamView: UIViewControllerRepresentable {
}
}
public final class StreamViewController: UIViewController, UIPointerInteractionDelegate {
public final class StreamViewController: UIViewController {
public private(set) var connection: PunktfunkConnection?
private var pump: StreamPump?
private var inputCapture: InputCapture?
private var captured = false
private var observers: [NSObjectProtocol] = []
#if os(iOS)
private var inputCapture: InputCapture?
fileprivate var captured = false
private var pointerInteraction: UIPointerInteraction?
#endif
var onCaptureChange: ((Bool) -> Void)?
var captureEnabled = true {
didSet {
guard captureEnabled != oldValue else { return }
#if os(iOS)
setCaptured(captureEnabled)
#endif
}
}
@@ -90,6 +94,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
public override func loadView() {
view = StreamLayerUIView()
#if os(iOS)
// Hide the iPadOS cursor while it hovers the video: the host renders its own
// cursor from our raw deltas, so the local one only diverges from it. (True
// pointer LOCK prefersPointerLocked isn't consulted through
@@ -97,16 +102,13 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
let interaction = UIPointerInteraction(delegate: self)
view.addInteraction(interaction)
pointerInteraction = interaction
#endif
}
public func pointerInteraction(
_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion
) -> UIPointerStyle? {
captured ? .hidden() : nil
}
#if os(iOS)
public override var prefersPointerLocked: Bool { captured }
public override var prefersHomeIndicatorAutoHidden: Bool { true }
#endif
func start(
connection: PunktfunkConnection,
@@ -116,6 +118,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
stop()
self.connection = connection
loadViewIfNeeded()
#if os(iOS)
// Read the LIVE mode per touch batch an accepted requestMode() mid-stream
// changes the letterbox, and touches must follow it.
streamView.currentHostMode = { [weak connection] in
@@ -137,6 +140,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
}
capture.start()
inputCapture = capture
#endif
let pump = StreamPump()
pump.start(
@@ -144,6 +148,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
onFrame: onFrame, onSessionEnd: onSessionEnd)
self.pump = pump
#if os(iOS)
// GC only delivers while active; everything held is flushed by InputCapture's
// own resign observer here we just mirror the capture state for the HUD and
// the pointer lock.
@@ -156,21 +161,25 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
if captureEnabled {
setCaptured(true) // entering a session is the deliberate "capture me" moment
}
#endif
}
func stop() {
setCaptured(false)
observers.forEach(NotificationCenter.default.removeObserver(_:))
observers.removeAll()
#if os(iOS)
setCaptured(false)
inputCapture?.stop()
inputCapture = nil
streamView.onTouchEvent = nil
streamView.currentHostMode = nil
#endif
pump?.stop()
pump = nil
connection = nil
streamView.onTouchEvent = nil
streamView.currentHostMode = nil
}
#if os(iOS)
private func setCaptured(_ on: Bool) {
if on {
guard captureEnabled, !captured, pump != nil else { return }
@@ -187,6 +196,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
let captured = captured
DispatchQueue.main.async { onCaptureChange?(captured) }
}
#endif
deinit {
observers.forEach(NotificationCenter.default.removeObserver(_:))
@@ -194,6 +204,16 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
}
}
#if os(iOS)
extension StreamViewController: UIPointerInteractionDelegate {
public func pointerInteraction(
_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion
) -> UIPointerStyle? {
captured ? .hidden() : nil
}
}
#endif
/// The layer-backed video surface + touch source. Touches are mapped through the
/// aspect-fit letterbox into host-mode pixels (surface == host mode, so the host-side
/// rescale is the identity); touches outside the video area are clamped onto its edge.
@@ -204,23 +224,28 @@ final class StreamLayerUIView: UIView {
layer as! AVSampleBufferDisplayLayer
}
#if os(iOS)
/// Reads the LIVE negotiated mode in pixels (the touch coordinate space).
var currentHostMode: (() -> CGSize)?
var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
/// Wire touch ids per active UITouch; ids are reused after the touch ends.
private var touchIDs: [ObjectIdentifier: UInt32] = [:]
#endif
override init(frame: CGRect) {
super.init(frame: frame)
displayLayer.videoGravity = .resizeAspect
#if os(iOS)
isMultipleTouchEnabled = true
#endif
backgroundColor = .black
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("not used") }
#if os(iOS)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
forward(touches, kind: .down)
}
@@ -277,11 +302,14 @@ final class StreamLayerUIView: UIView {
while touchIDs.values.contains(id) { id += 1 }
return id
}
#endif
}
#if os(iOS)
extension CGFloat {
fileprivate func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
#endif
#endif