HisenseBar v1.0.0: native menu-bar remote for Hisense VIDAA TVs
build / sanity (push) Waiting to run
build / macos-build (push) Waiting to run

- SwiftUI popover: apps grid w/ live search, sources, glass remote, gradient volume slider, animations
- Persistent python protocol bridge (vidaa-control, MQTT/TLS :36669)
- Wake-on-LAN power-on, one-time PIN pairing with token auto-refresh
- MCP server for AI agents (11 tv_* tools) + live test script
- Gitea Actions CI workflow
This commit is contained in:
2026-10-13 18:20:11 +03:00
commit cc2018aae7
12 changed files with 1699 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
import Foundation
/// JSON dict from the bridge; immutable after parse, safe to pass across isolation.
struct BridgeReply: @unchecked Sendable {
let dict: [String: Any]
subscript(_ key: String) -> Any? { dict[key] }
}
/// Actor owning the persistent python bridge process (line-JSON over stdio).
actor BridgeTV {
enum BridgeError: Error, LocalizedError {
case spawn(String)
case timeout
case closed
var errorDescription: String? {
switch self {
case .spawn(let why): return "bridge start failed: \(why)"
case .timeout: return "bridge timeout"
case .closed: return "bridge closed"
}
}
}
static let shared = BridgeTV()
private var process: Process?
private var nextID = 1
private var waiters: [Int: CheckedContinuation<BridgeReply, Error>] = [:]
private var buffer = Data()
private var eventHandler: (@Sendable (BridgeReply) -> Void)?
private let venvSite = "/Users/ahmedmohammed/Documents/rae/.venv/lib/python3.9/site-packages"
private let python = "/Users/ahmedmohammed/Documents/rae/.venv/bin/python3"
private var scriptURL: URL {
if let bundled = Bundle.main.url(forResource: "bridge", withExtension: "py") { return bundled }
return URL(fileURLWithPath: "/Users/ahmedmohammed/Documents/rae/HisenseBar/bridge.py")
}
func setEventHandler(_ handler: @escaping @Sendable (BridgeReply) -> Void) {
eventHandler = handler
}
private func ensureProcess() throws -> Process {
if let p = process, p.isRunning { return p }
waiters.values.forEach { $0.resume(throwing: BridgeError.closed) }
waiters.removeAll()
buffer.removeAll()
let p = Process()
p.executableURL = URL(fileURLWithPath: python)
p.arguments = [scriptURL.path,
UserDefaults.standard.string(forKey: "hisensebar.ip") ?? "192.168.1.146",
UserDefaults.standard.string(forKey: "hisensebar.mac") ?? "",
NSString(string: "~/Library/Application Support/HisenseBar/tokens.json").expandingTildeInPath]
var env = ProcessInfo.processInfo.environment
env["VIDAA_SITE"] = venvSite
env["PYTHONUNBUFFERED"] = "1"
p.environment = env
let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe()
p.standardInput = inPipe
p.standardOutput = outPipe
p.standardError = errPipe
do { try p.run() } catch { throw BridgeError.spawn("\(error)") }
process = p
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else {
handle.readabilityHandler = nil
Task { await self?.handleClosed() }
return
}
Task { await self?.feed(data) }
}
return p
}
private func feed(_ data: Data) {
buffer.append(data)
while let nl = buffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = buffer.subdata(in: buffer.startIndex..<nl)
buffer.removeSubrange(buffer.startIndex...nl)
guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] else { continue }
let reply = BridgeReply(dict: obj)
if let id = obj["id"] as? Int, let cont = waiters.removeValue(forKey: id) {
cont.resume(returning: reply)
} else if let handler = eventHandler {
handler(reply)
}
}
}
private func handleClosed() {
waiters.values.forEach { $0.resume(throwing: BridgeError.closed) }
waiters.removeAll()
process = nil
}
func request(_ cmd: [String: Any], timeout: TimeInterval = 30) async throws -> BridgeReply {
let p = try ensureProcess()
let id = nextID
nextID += 1
var msg = cmd
msg["id"] = id
let payload = try JSONSerialization.data(withJSONObject: msg) + Data("\n".utf8)
return try await withCheckedThrowingContinuation { cont in
waiters[id] = cont
guard let pipe = p.standardInput as? Pipe else {
waiters.removeValue(forKey: id)
cont.resume(throwing: BridgeError.closed)
return
}
do {
try pipe.fileHandleForWriting.write(contentsOf: payload)
} catch {
waiters.removeValue(forKey: id)
cont.resume(throwing: BridgeError.closed)
}
Task {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
await self.timeoutID(id)
}
}
}
private func timeoutID(_ id: Int) {
if let cont = waiters.removeValue(forKey: id) {
cont.resume(throwing: BridgeError.timeout)
}
}
}
+895
View File
@@ -0,0 +1,895 @@
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<Void, Never>?
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<Void, Never>?
@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()
}
}