diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index d21e31f..b4fd44b 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -487,6 +487,7 @@ CC0000000000000000000012 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F4H37KF6WC; @@ -502,7 +503,6 @@ MARKETING_VERSION = 0.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.tvos; PRODUCT_NAME = "$(TARGET_NAME)"; - ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; SDKROOT = appletvos; SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -515,6 +515,7 @@ CC0000000000000000000013 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F4H37KF6WC; @@ -530,7 +531,6 @@ MARKETING_VERSION = 0.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.tvos; PRODUCT_NAME = "$(TARGET_NAME)"; - ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; SDKROOT = appletvos; SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift index 16fe4a3..9003b09 100644 --- a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift @@ -17,8 +17,16 @@ struct AddHostSheet: View { TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room")) TextField("Address", text: $address, prompt: Text("IP or hostname")) TextField("Port", value: $port, format: .number.grouping(.never)) + #if os(tvOS) + // tvOS floats the label above a non-empty field INSIDE the pill, + // shoving the value off-center — the field is always prefilled + // here, so drop the label there. + .labelsHidden() + #endif } - .formStyle(.grouped) + #if !os(tvOS) + .formStyle(.grouped) + #endif HStack { Button("Cancel", role: .cancel) { dismiss() } #if !os(tvOS) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 13ae2fa..cfc7f60 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -173,8 +173,10 @@ struct ContentView: View { .background(.thickMaterial, ignoresSafeAreaEdges: .all) } .fullScreenCover(isPresented: $showSettings) { - SettingsView() - .background(.thickMaterial, ignoresSafeAreaEdges: .all) + NavigationStack { + SettingsView() + } + .background(.thickMaterial, ignoresSafeAreaEdges: .all) } #else .sheet(isPresented: $showAddHost) { diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/PairSheet.swift index 3a37dd0..20a3003 100644 --- a/clients/apple/Sources/PunktfunkClient/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/PairSheet.swift @@ -46,6 +46,9 @@ struct PairSheet: View { TextField( "Client name", text: $clientName, prompt: Text("How the host lists this Mac")) + #if os(tvOS) + .labelsHidden() // prefilled → tvOS floats the label off-center + #endif } header: { Label("Pair with \(host.displayName)", systemImage: "lock.shield") .foregroundStyle(.tint) @@ -65,7 +68,9 @@ struct PairSheet: View { } } } - .formStyle(.grouped) + #if !os(tvOS) + .formStyle(.grouped) + #endif HStack { Button("Cancel", role: .cancel) { token.cancelled = true diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 2bcff04..e358f43 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -23,6 +23,79 @@ struct SettingsView: View { #endif var body: some View { + #if os(tvOS) + // Native tv pattern: no inline text entry (typing numbers with a remote is + // miserable and the inline field chrome fights the focus system). The mode is + // a preset picker; pickers push selection lists like the system Settings app. + tvBody + #else + sharedBody + #endif + } + + #if os(tvOS) + private static let presets: [(label: String, tag: String)] = [ + ("720p @ 60", "1280x720x60"), + ("1080p @ 60", "1920x1080x60"), + ("4K @ 60", "3840x2160x60"), + ] + + private var modeTag: Binding { + Binding( + get: { "\(width)x\(height)x\(hz)" }, + set: { tag in + let parts = tag.split(separator: "x").compactMap { Int($0) } + guard parts.count == 3 else { return } + width = parts[0] + height = parts[1] + hz = parts[2] + }) + } + + private var tvBody: some View { + let currentTag = "\(width)x\(height)x\(hz)" + let bounds = UIScreen.main.nativeBounds + let nativeTag = "\(Int(max(bounds.width, bounds.height)))x" + + "\(Int(min(bounds.width, bounds.height)))x\(UIScreen.main.maximumFramesPerSecond)" + var options = Self.presets + if !options.contains(where: { $0.tag == nativeTag }) { + options.insert(("This TV (native)", nativeTag), at: 0) + } + if !options.contains(where: { $0.tag == currentTag }) { + options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0) + } + return Form { + Section { + Picker("Stream mode", selection: modeTag) { + ForEach(options, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + .pickerStyle(.navigationLink) + Picker("Compositor", selection: $compositor) { + Text("Automatic").tag(0) + Text("KWin (KDE Plasma)").tag(1) + Text("wlroots (Sway / Hyprland)").tag(2) + Text("Mutter (GNOME)").tag(3) + Text("gamescope").tag(4) + } + .pickerStyle(.navigationLink) + } footer: { + Text("The host creates a virtual output at exactly this mode — native " + + "resolution, no scaling. A specific compositor is honored only if " + + "available on the host.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section { + Button("Done") { dismiss() } + } + } + .navigationTitle("Settings") + } + #endif + + private var sharedBody: some View { Form { Section { HStack { @@ -100,11 +173,6 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - #if os(tvOS) - Section { - Button("Done") { dismiss() } - } - #endif } .formStyle(.grouped) #if os(macOS)