- 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
896 lines
31 KiB
Swift
896 lines
31 KiB
Swift
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()
|
|
}
|
|
}
|