HisenseBar v1.0.0: native menu-bar remote for Hisense VIDAA TVs
- 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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user