import SwiftUI import os // MARK: - Model @MainActor @Observable final class TVModel { static let shared = TVModel() private let log = Logger(subsystem: "rae.hisensebar", category: "net") nonisolated static func diag(_ line: String) { let path = "/tmp/hisensebar-diag.log" let text = "\(Date()) \(line)\n" if let fh = FileHandle(forWritingAtPath: path) { try? fh.seekToEnd(); try? fh.write(Data(text.utf8)); try? fh.close() } else { try? Data(text.utf8).write(to: URL(fileURLWithPath: path)) } } enum Status: Equatable { case disconnected, connecting, connected case pairingAwaitingPIN, pairingFailed(String) } var status: Status = .disconnected var volume = 0 var muted = false var apps: [TVApp] = [] var sources: [TVSource] = [] var nowLine = "" var settingsIP = UserDefaults.standard.string(forKey: "hisensebar.ip") ?? "192.168.1.146" var settingsMAC = UserDefaults.standard.string(forKey: "hisensebar.mac") ?? "e0:3e:cb:f4:68:be" var showSettings = false var pairPIN = "" var busy = false var needsPairing = false private let tv = BridgeTV.shared private var volumeDebounce: Task? var isConnected: Bool { status == .connected } private var started = false init() { if (UserDefaults.standard.string(forKey: "hisensebar.mac") ?? "").isEmpty { UserDefaults.standard.set("e0:3e:cb:f4:68:be", forKey: "hisensebar.mac") } Task { await start() } } func start() async { guard !started else { return } started = true await tv.setEventHandler { [weak self] reply in Task { @MainActor in self?.handleBridgeEvent(reply) } } await connect() } private func bridgeDict(_ key: String) -> [String: Any] { let stored = UserDefaults.standard.string(forKey: key) ?? "" return stored.isEmpty ? [:] : [key: stored] } func connect() async { status = .connecting do { let reply = try await tv.request(["cmd": "connect", "host": settingsIP, "mac": settingsMAC]) guard reply["ok"] as? Bool == true else { throw BridgeError2(msg: reply["error"] as? String ?? "connect failed") } status = .connected if let v = try? await getVolume() { volume = v } await refreshLists() } catch { Self.diag("connect failed: \(error)") status = .disconnected } } struct BridgeError2: LocalizedError { let msg: String; var errorDescription: String? { msg } } private func handleBridgeEvent(_ reply: BridgeReply) { guard reply["event"] as? String == "state", let data = reply["data"] as? [String: Any] else { return } applyBroadcast(data) } private func applyBroadcast(_ json: [String: Any]) { if let vt = (json["volume_type"] as? Int) ?? Int(json["volume_type"] as? String ?? "0") { let raw = (json["volume_value"] as? Int) ?? Int(json["volume_value"] as? String ?? "") ?? 0 if vt == 0 { volume = raw } if vt == 2 { muted = (raw == 1) } } if let statetype = json["statetype"] as? String { let app = (json["app_name"] as? String) ?? (json["appname"] as? String) ?? "" let src = (json["sourcename"] as? String) ?? (json["sourceid"] as? String) ?? "" var parts: [String] = [] if !app.isEmpty { parts.append(app) } if !src.isEmpty { parts.append(src) } nowLine = statetype == "fake_sleep_0" ? "TV asleep" : (parts.isEmpty ? "" : parts.joined(separator: " · ")) } } // MARK: Pairing func startPairing() async { do { status = .connecting _ = try await tv.request(["cmd": "connect", "host": settingsIP, "mac": settingsMAC]) let reply = try await tv.request(["cmd": "pair_start"]) guard reply["ok"] as? Bool == true else { throw BridgeError2(msg: reply["error"] as? String ?? "pair start failed") } status = .pairingAwaitingPIN } catch { Self.diag("pairing connect failed: \(error)") status = .pairingFailed("Could not reach TV at \(settingsIP): \(error)") } } func submitPIN() async { guard status == .pairingAwaitingPIN else { return } do { let reply = try await tv.request(["cmd": "pair_auth", "pin": pairPIN]) guard reply["ok"] as? Bool == true else { throw BridgeError2(msg: reply["error"] as? String ?? "pairing refused") } pairPIN = "" UserDefaults.standard.set(settingsIP, forKey: "hisensebar.ip") UserDefaults.standard.set(settingsMAC, forKey: "hisensebar.mac") needsPairing = false status = .connected if let v = try? await getVolume() { volume = v } await refreshLists() } catch { status = .pairingFailed("Wrong PIN or TV refused pairing") } } // MARK: Commands private func getVolume() async throws -> Int? { let reply = try await tv.request(["cmd": "volume"]) return reply["volume"] as? Int } func setVolumeLive(_ value: Int) { volume = value volumeDebounce?.cancel() volumeDebounce = Task { [tv] in try? await Task.sleep(nanoseconds: 150_000_000) guard !Task.isCancelled else { return } _ = try? await tv.request(["cmd": "set_volume", "value": value]) } } func toggleMute() async { muted.toggle() _ = try? await tv.request(["cmd": "key", "key": "KEY_MUTE"]) } func launch(_ app: TVApp) async { var appDict: [String: Any] = ["name": app.name] if let id = app.appID { appDict["appId"] = id } if let url = app.url { appDict["url"] = url } _ = try? await tv.request(["cmd": "launch", "app": appDict]) } func selectSource(_ source: TVSource) async { _ = try? await tv.request(["cmd": "set_source", "source": source.sourceID, "name": source.rawName]) } func sendKey(_ key: String) async { _ = try? await tv.request(["cmd": "key", "key": key]) } func powerOff() async { await sendKey("KEY_POWER") } func powerOn() async { guard !settingsMAC.isEmpty else { return } _ = try? await tv.request(["cmd": "wake", "mac": settingsMAC]) try? await Task.sleep(nanoseconds: 6_000_000_000) await connect() } func refreshLists() async { if let reply = try? await tv.request(["cmd": "apps"]), let list = reply["apps"] as? [[String: Any]] { apps = list.compactMap(TVApp.init(dict:)) } if let reply = try? await tv.request(["cmd": "sources"]), let list = reply["sources"] as? [[String: Any]] { sources = list.compactMap(TVSource.init(dict:)) } } func rediscover() async { busy = true if settingsMAC.isEmpty, let arpMAC = arpMAC(for: settingsIP) { settingsMAC = arpMAC } busy = false } private func arpMAC(for ip: String) -> String? { // lazy: read the ARP table instead of raw socket ARP; filled after first TV contact let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/sbin/arp") proc.arguments = ["-a"] let out = Pipe() proc.standardOutput = out do { try proc.run() } catch { return nil } let data = out.fileHandleForReading.readDataToEndOfFile() proc.waitUntilExit() guard let text = String(data: data, encoding: .utf8) else { return nil } for line in text.split(separator: "\n") where line.contains(ip) { let parts = line.split(separator: " ") if let mac = parts.last(where: { $0.contains(":") && $0.count == 17 }) { return String(mac).uppercased() } } return nil } } // MARK: - TV data struct TVApp: Identifiable, Hashable { let name: String let appID: String? let url: String? let iconURL: URL? var id: String { appID ?? name } init?(dict: [String: Any]) { guard let name = (dict["name"] as? String) ?? (dict["appname"] as? String) else { return nil } self.name = name self.appID = dict["appId"] as? String self.url = dict["url"] as? String if let icon = dict["httpIcon"] as? String, icon.contains("http") { let cleaned = icon.replacingOccurrences(of: "data:image/png;base64,iconDownload", with: "") self.iconURL = URL(string: cleaned) } else { self.iconURL = nil } } } struct TVSource: Identifiable, Hashable { let sourceID: String let rawName: String let name: String var id: String { sourceID } init?(dict: [String: Any]) { guard let sid = dict["sourceid"] as? String else { return nil } self.sourceID = sid self.rawName = (dict["sourcename"] as? String) ?? sid let friendly = dict["displayname2"] as? String ?? "" let display = dict["displayname"] as? String ?? sid self.name = friendly.isEmpty ? display : "\(display) · \(friendly)" } } // MARK: - App @main struct HisenseBarApp: App { init() { Task { @MainActor in await TVModel.shared.start() } } var body: some Scene { MenuBarExtra("Hisense TV", systemImage: "tv") { TVPopover() .environment(TVModel.shared) .frame(width: 330) } .menuBarExtraStyle(.window) } } // MARK: - Design system struct PressableButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.93 : 1) .animation(.spring(response: 0.28, dampingFraction: 0.55), value: configuration.isPressed) } } extension View { func pressable() -> some View { buttonStyle(PressableButtonStyle()) } } /// Circular glass key used across the remote. struct GlassKey: View { let label: String var icon: String? var tint: Color = .primary var action: () -> Void @State private var pressed = false var body: some View { Button(action: action) { Group { if let icon { Image(systemName: icon).font(.system(size: 15, weight: .medium)) } else { Text(label).font(.system(size: 12, weight: .semibold)) } } .foregroundStyle(tint) .frame(width: 46, height: 46) .background(Circle().fill(.ultraThinMaterial)) .background(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)) .scaleEffect(pressed ? 0.88 : 1) .animation(.spring(response: 0.25, dampingFraction: 0.5), value: pressed) } .buttonStyle(.plain) .simultaneousGesture(DragGesture(minimumDistance: 0) .onChanged { _ in pressed = true } .onEnded { _ in pressed = false }) } } /// Gradient capsule volume slider. struct VolumeSlider: View { @Binding var value: Double @State private var dragging = false var body: some View { GeometryReader { geo in let fraction = max(0.0, min(1.0, value / 100.0)) ZStack(alignment: .leading) { Capsule().fill(Color.primary.opacity(0.12)).frame(height: 8) Capsule() .fill(LinearGradient(colors: [.blue, .cyan], startPoint: .leading, endPoint: .trailing)) .frame(width: max(8, geo.size.width * fraction), height: 8) Circle() .fill(Color.white) .frame(width: dragging ? 20 : 16, height: dragging ? 20 : 16) .shadow(color: .blue.opacity(0.45), radius: dragging ? 6 : 3, y: 1) .offset(x: max(0, geo.size.width * fraction - (dragging ? 10 : 8))) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: dragging) } .frame(maxHeight: .infinity) .contentShape(Rectangle()) .gesture(DragGesture(minimumDistance: 0).onChanged { g in dragging = true value = max(0, min(100, g.location.x / geo.size.width * 100.0)) }.onEnded { _ in dragging = false }) } .frame(height: 24) } } /// Animated three-dot waiting indicator. struct PulsingDots: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var on = false var body: some View { HStack(spacing: 5) { ForEach(0..<3, id: \.self) { i in Circle().fill(Color.accentColor) .frame(width: 6, height: 6) .opacity(reduceMotion ? 0.8 : (on ? 1 : 0.25)) .animation(reduceMotion ? nil : .easeInOut(duration: 0.6).repeatForever(autoreverses: true).delay(Double(i) * 0.18), value: on) } } .onAppear { on = true } } } // MARK: - Popover enum TVTab: String, CaseIterable { case apps = "Apps", sources = "Sources", remote = "Remote" var icon: String { switch self { case .apps: "square.grid.2x2" case .sources: "switch.2" case .remote: "appletv.remote" } } } struct TVPopover: View { @Environment(TVModel.self) private var model @State private var tab: TVTab = .apps @State private var appeared = false var body: some View { VStack(spacing: 12) { HeaderView() if model.needsPairing { PairingSection() } else { ControlsView() TabSelector(tab: $tab) Group { switch tab { case .apps: AppsTabView() case .sources: SourcesTabView() case .remote: RemoteTabView() } } .frame(minHeight: 250) .transition(.opacity.combined(with: .move(edge: .trailing))) .id(tab) .animation(.spring(response: 0.3, dampingFraction: 0.85), value: tab) } FooterView() } .padding(14) .fontDesign(.rounded) .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 8) .onAppear { withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { appeared = true } } } } struct TabSelector: View { @Binding var tab: TVTab @Namespace private var ns var body: some View { HStack(spacing: 2) { ForEach(TVTab.allCases, id: \.self) { t in Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { tab = t } } label: { Label(t.rawValue, systemImage: t.icon) .font(.caption.weight(.semibold)) .foregroundStyle(tab == t ? Color.white : Color.secondary) .padding(.vertical, 6) .frame(maxWidth: .infinity) .background { if tab == t { Capsule() .fill(Color.accentColor.gradient) .matchedGeometryEffect(id: "tabcap", in: ns) } } } .buttonStyle(.plain) } } .padding(3) .background(Capsule().fill(Color.primary.opacity(0.07))) } } struct HeaderView: View { @Environment(TVModel.self) private var model @State private var pulse = false var body: some View { HStack(spacing: 10) { ZStack { RoundedRectangle(cornerRadius: 9) .fill(LinearGradient(colors: [.blue, .cyan], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 34, height: 34) Image(systemName: "tv") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.white) } VStack(alignment: .leading, spacing: 1) { Text("Living Room TV").font(.headline) HStack(spacing: 5) { if model.status == .connecting { PulsingDots() } else { Circle() .fill(statusColor) .frame(width: 7, height: 7) .scaleEffect(model.status == .connecting ? 1 : 1) } Text(statusText).font(.caption).foregroundStyle(.secondary) } } Spacer() } .padding(.vertical, 2) } private var statusColor: Color { switch model.status { case .connected: .green case .connecting: .orange case .disconnected: .secondary.opacity(0.6) case .pairingAwaitingPIN: .yellow case .pairingFailed: .red } } private var statusText: String { switch model.status { case .connected: return model.nowLine.isEmpty ? "Connected" : model.nowLine case .connecting: return "Connecting…" case .disconnected: return "Disconnected" case .pairingAwaitingPIN: return "Enter PIN from TV" case .pairingFailed(let why): return why } } } struct ControlsView: View { @Environment(TVModel.self) private var model var body: some View { VStack(spacing: 8) { HStack(spacing: 10) { GlassKey(label: "", icon: "power", tint: .red) { Task { await model.powerOff() } } .help("Power off") VolumeSlider(value: Binding( get: { Double(model.volume) }, set: { model.setVolumeLive(Int($0.rounded())) } )) GlassKey(label: "", icon: model.muted ? "speaker.slash.fill" : "speaker.wave.2") { Task { await model.toggleMute() } } .help("Mute") } } } } struct FooterView: View { @Environment(TVModel.self) private var model var body: some View { @Bindable var model = model HStack { Text("VIDAA").font(.caption2.weight(.medium)).foregroundStyle(.tertiary) Spacer() Button { model.showSettings = true } label: { Image(systemName: "gearshape") .font(.system(size: 13)) .foregroundStyle(.secondary) } .pressable() .help("Settings") } .sheet(isPresented: $model.showSettings) { SettingsView() } } } struct AppsTabView: View { @Environment(TVModel.self) private var model @State private var search = "" private var filtered: [TVApp] { search.isEmpty ? model.apps : model.apps.filter { $0.name.localizedCaseInsensitiveContains(search) } } var body: some View { VStack(spacing: 8) { TextField("Search apps…", text: $search) .textFieldStyle(.roundedBorder) .fontDesign(.rounded) .onSubmit { if let top = filtered.first { Task { await model.launch(top) } } } ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 68), spacing: 8)], spacing: 10) { ForEach(Array(filtered.enumerated()), id: \.element.id) { idx, app in Button { Task { await model.launch(app) } } label: { AppTile(app: app) } .buttonStyle(.plain) .opacity(1.0) .scaleEffect(1.0) .transition(.opacity.combined(with: .scale(scale: 0.9))) } } .padding(.horizontal, 2) } } } } struct AppTile: View { let app: TVApp @State private var hover = false @State private var pressed = false var body: some View { VStack(spacing: 5) { icon .frame(height: 46) .frame(maxWidth: .infinity) Text(app.name) .font(.caption2) .lineLimit(1) .foregroundStyle(.primary) } .padding(6) .background( RoundedRectangle(cornerRadius: 12) .fill(Color.primary.opacity(hover ? 0.07 : 0.04)) ) .overlay( RoundedRectangle(cornerRadius: 12) .strokeBorder(Color.primary.opacity(hover ? 0.12 : 0), lineWidth: 0.5) ) .scaleEffect(pressed ? 0.94 : (hover ? 1.04 : 1)) .shadow(color: .black.opacity(hover ? 0.12 : 0), radius: 5, y: 2) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: hover) .animation(.spring(response: 0.2, dampingFraction: 0.6), value: pressed) .onHover { hover = $0 } .simultaneousGesture(DragGesture(minimumDistance: 0) .onChanged { _ in pressed = true } .onEnded { _ in pressed = false }) } @ViewBuilder private var icon: some View { if let url = app.iconURL { AsyncImage(url: url) { img in img.resizable().scaledToFit() } placeholder: { ZStack { RoundedRectangle(cornerRadius: 10).fill(Color.accentColor.opacity(0.12)) Image(systemName: "app.fill").foregroundStyle(.secondary) } } } else { ZStack { RoundedRectangle(cornerRadius: 10) .fill(LinearGradient(colors: [.accentColor.opacity(0.7), .accentColor.opacity(0.4)], startPoint: .top, endPoint: .bottom)) Text(String(app.name.prefix(2))).font(.callout).bold().foregroundStyle(.white) } } } } struct SourcesTabView: View { @Environment(TVModel.self) private var model var body: some View { ScrollView { VStack(spacing: 6) { ForEach(model.sources) { source in SourceRow(source: source) { Task { await model.selectSource(source) } } } } .padding(.horizontal, 2) } } } struct SourceRow: View { let source: TVSource let action: () -> Void @State private var hover = false @State private var pressed = false var body: some View { Button(action: action) { HStack(spacing: 10) { Image(systemName: iconName) .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.accentColor) .frame(width: 28, height: 28) .background(Circle().fill(Color.accentColor.opacity(0.12))) VStack(alignment: .leading, spacing: 0) { Text(source.name).font(.callout.weight(.medium)) if source.rawName != source.name { Text(source.rawName).font(.caption2).foregroundStyle(.tertiary) } } Spacer() Image(systemName: "arrow.right.circle") .foregroundStyle(hover ? Color.accentColor : Color.clear) } .padding(.vertical, 7) .padding(.horizontal, 10) .background( RoundedRectangle(cornerRadius: 10) .fill(Color.primary.opacity(hover ? 0.06 : 0.03)) ) .scaleEffect(pressed ? 0.98 : 1) } .buttonStyle(.plain) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: hover) .onHover { hover = $0 } .simultaneousGesture(DragGesture(minimumDistance: 0) .onChanged { _ in pressed = true } .onEnded { _ in pressed = false }) } private var iconName: String { switch source.sourceID.uppercased() { case "TV": "tv" case let s where s.contains("HDMI"): "cable.connector" case "AVS", "AV": "music.note.tv" case "MUSICSHARING": "music.note.list" default: "play.rectangle" } } } struct RemoteTabView: View { @Environment(TVModel.self) private var model var body: some View { ScrollView { VStack(spacing: 14) { DPadCluster(model: model) HStack(spacing: 10) { GlassKey(label: "", icon: "backward.end.fill") { Task { await model.sendKey("KEY_REWIND") } } GlassKey(label: "", icon: "playpause.fill") { Task { await model.sendKey("KEY_PLAY") } } GlassKey(label: "", icon: "forward.end.fill") { Task { await model.sendKey("KEY_FORWARDS") } } GlassKey(label: "", icon: "info.circle") { Task { await model.sendKey("KEY_INFO") } } } HStack(spacing: 10) { GlassKey(label: "Back") { Task { await model.sendKey("KEY_RETURNS") } } GlassKey(label: "Home") { Task { await model.sendKey("KEY_HOME") } } GlassKey(label: "Menu") { Task { await model.sendKey("KEY_MENU") } } GlassKey(label: "Exit") { Task { await model.sendKey("KEY_EXIT") } } } VStack(spacing: 6) { HStack(spacing: 6) { ForEach(1...5, id: \.self) { n in NumKey(n: n, model: model) } } HStack(spacing: 6) { ForEach([6, 7, 8, 9, 0], id: \.self) { n in NumKey(n: n, model: model) } } } } .padding(4) } } } struct DPadCluster: View { let model: TVModel var body: some View { VStack(spacing: 8) { RepeatGlass(key: "KEY_UP", model: model) HStack(spacing: 8) { RepeatGlass(key: "KEY_LEFT", model: model) GlassKey(label: "OK", tint: .accentColor) { Task { await model.sendKey("KEY_OK") } } RepeatGlass(key: "KEY_RIGHT", model: model) } RepeatGlass(key: "KEY_DOWN", model: model) } } } struct NumKey: View { let n: Int let model: TVModel var body: some View { GlassKey(label: "\(n)") { Task { await model.sendKey("KEY_\(n)") } } .frame(maxWidth: .infinity) } } // Hold-to-repeat glass d-pad key struct RepeatGlass: View { let key: String let model: TVModel @State private var task: Task? @State private var pressed = false var body: some View { Button { Task { await model.sendKey(key) } } label: { Image(systemName: iconName) .font(.system(size: 15, weight: .medium)) .frame(width: 46, height: 46) .background(Circle().fill(.ultraThinMaterial)) .background(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)) .scaleEffect(pressed ? 0.88 : 1) .animation(.spring(response: 0.25, dampingFraction: 0.5), value: pressed) } .buttonStyle(.plain) .onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in pressed = pressing if pressing { task = Task { @MainActor in while !Task.isCancelled { await model.sendKey(key) try? await Task.sleep(nanoseconds: 180_000_000) } } } else { task?.cancel() } }, perform: {}) .simultaneousGesture(DragGesture(minimumDistance: 0) .onChanged { _ in pressed = true } .onEnded { _ in pressed = false }) } private var iconName: String { switch key { case "KEY_UP": "arrowtriangle.up.fill" case "KEY_DOWN": "arrowtriangle.down.fill" case "KEY_LEFT": "arrowtriangle.left.fill" case "KEY_RIGHT": "arrowtriangle.right.fill" default: "circle" } } } // MARK: - Settings struct SettingsView: View { @Environment(TVModel.self) private var model @Environment(\.dismiss) private var dismiss var body: some View { @Bindable var model = model VStack(alignment: .leading, spacing: 10) { Text("TV Connection").font(.headline) HStack { Text("IP:").frame(width: 40, alignment: .leading) TextField("192.168.1.146", text: $model.settingsIP).textFieldStyle(.roundedBorder) Button(model.busy ? "…" : "Find") { Task { await model.rediscover() } } } HStack { Text("MAC:").frame(width: 40, alignment: .leading) TextField("e0:3e:cb:f4:68:be", text: $model.settingsMAC).textFieldStyle(.roundedBorder) } Text("MAC is used for Wake-on-LAN (power on).").font(.caption).foregroundStyle(.secondary) Divider() PairingSection() HStack { Spacer() Button("Save") { UserDefaults.standard.set(model.settingsIP, forKey: "hisensebar.ip") UserDefaults.standard.set(model.settingsMAC, forKey: "hisensebar.mac") dismiss() Task { await model.connect() } } .keyboardShortcut(.defaultAction) Button("Cancel") { dismiss() } } } .padding(16) .fontDesign(.rounded) .frame(width: 340) } } struct PairingSection: View { @Environment(TVModel.self) private var model var body: some View { @Bindable var model = model VStack(alignment: .leading, spacing: 8) { Text("Pairing").font(.headline) switch model.status { case .pairingAwaitingPIN: HStack(spacing: 8) { PulsingDots() Text("A PIN is on your TV screen — enter it:").font(.caption) } HStack { TextField("PIN", text: $model.pairPIN) .textFieldStyle(.roundedBorder) .onSubmit { Task { await model.submitPIN() } } Button("Pair") { Task { await model.submitPIN() } } .pressable() } case .pairingFailed(let why): Text(why).font(.caption).foregroundStyle(.red) PairButton() default: PairButton() } } } } struct PairButton: View { @Environment(TVModel.self) private var model var body: some View { Button { Task { await model.startPairing() } } label: { Label("Pair TV — shows a PIN on the screen", systemImage: "antenna.radiowaves.left.and.right") } .pressable() } }