2026-06-30 00:12:41 -04:00
|
|
|
// Remote tab: control any device the bridge's device registry marks controllable
|
|
|
|
|
// (/remote/targets). Controls adapt to the selected target's capabilities; the
|
|
|
|
|
// Remote targets come from the synced device registry, not hardcoded names.
|
2026-06-09 06:38:45 -07:00
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
import LilithDesignTokens
|
|
|
|
|
|
|
|
|
|
struct RemoteView: View {
|
|
|
|
|
@EnvironmentObject private var settings: BridgeSettings
|
|
|
|
|
|
2026-06-30 00:12:41 -04:00
|
|
|
@State private var targets: [RemoteTarget] = []
|
|
|
|
|
@State private var selected: RemoteTarget?
|
2026-06-09 06:38:45 -07:00
|
|
|
@State private var status: RemoteStatus?
|
|
|
|
|
@State private var errorText: String?
|
|
|
|
|
@State private var volume: Double = 100
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
NavigationStack {
|
|
|
|
|
ZStack {
|
|
|
|
|
AppColors.background.ignoresSafeArea()
|
|
|
|
|
VStack(spacing: AppSpacing.xl) {
|
|
|
|
|
nowPlaying
|
2026-06-30 00:12:41 -04:00
|
|
|
if selected?.reachable == false {
|
|
|
|
|
Label("Not controllable from the bridge yet", systemImage: "bolt.slash")
|
|
|
|
|
.font(AppTypography.caption())
|
|
|
|
|
.foregroundStyle(AppColors.textSecondary)
|
|
|
|
|
} else {
|
|
|
|
|
transport
|
|
|
|
|
if selected?.can("volume") ?? true {
|
|
|
|
|
volumeControl
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-09 06:38:45 -07:00
|
|
|
if let errorText {
|
|
|
|
|
Text(errorText).font(AppTypography.caption()).foregroundStyle(AppColors.Semantic.error)
|
|
|
|
|
}
|
|
|
|
|
Spacer()
|
|
|
|
|
}
|
|
|
|
|
.padding(AppSpacing.lg)
|
|
|
|
|
}
|
2026-06-30 00:12:41 -04:00
|
|
|
.navigationTitle(selected?.name ?? "Remote")
|
|
|
|
|
.toolbar {
|
|
|
|
|
if targets.count > 1 {
|
|
|
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
|
|
|
Picker("Device", selection: $selected) {
|
|
|
|
|
ForEach(targets) { t in
|
|
|
|
|
Label(t.name, systemImage: t.kind == "roku" ? "appletvremote.gen1" : "tv")
|
|
|
|
|
.tag(Optional(t))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.pickerStyle(.menu)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-09 06:38:45 -07:00
|
|
|
.task { await pollLoop() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var nowPlaying: some View {
|
|
|
|
|
VStack(spacing: AppSpacing.sm) {
|
|
|
|
|
Image(systemName: "tv")
|
|
|
|
|
.font(.system(size: 56))
|
|
|
|
|
.foregroundStyle(status?.playing == true ? AppColors.primary : AppColors.textTertiary)
|
|
|
|
|
.padding(.top, AppSpacing.xl)
|
|
|
|
|
Text(status?.title ?? "Nothing playing")
|
|
|
|
|
.font(AppTypography.h5())
|
|
|
|
|
.foregroundStyle(AppColors.textPrimary)
|
|
|
|
|
.multilineTextAlignment(.center)
|
|
|
|
|
.lineLimit(2)
|
|
|
|
|
if let s = status, let pos = s.position, let dur = s.duration, dur > 0 {
|
|
|
|
|
ProgressView(value: min(1, pos / dur)).tint(AppColors.primary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var transport: some View {
|
|
|
|
|
HStack(spacing: AppSpacing.xl) {
|
2026-06-30 00:12:41 -04:00
|
|
|
if can("prev") {
|
|
|
|
|
transportButton("backward.end.fill") { await send { try await $0.remoteCommand(action: "prev", target: targetID) } }
|
|
|
|
|
}
|
|
|
|
|
if can("seek") {
|
|
|
|
|
transportButton("gobackward.30") { await send { try await $0.remoteCommand(action: "seek", value: -30, target: targetID) } }
|
|
|
|
|
}
|
|
|
|
|
if can("playpause") {
|
|
|
|
|
transportButton(status?.paused == true ? "play.fill" : "pause.fill", big: true) {
|
|
|
|
|
await send { try await $0.remoteCommand(action: "playpause", target: targetID) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if can("seek") {
|
|
|
|
|
transportButton("goforward.30") { await send { try await $0.remoteCommand(action: "seek", value: 30, target: targetID) } }
|
|
|
|
|
}
|
|
|
|
|
if can("next") {
|
|
|
|
|
transportButton("forward.end.fill") { await send { try await $0.remoteCommand(action: "next", target: targetID) } }
|
2026-06-09 06:38:45 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.foregroundStyle(AppColors.textPrimary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var volumeControl: some View {
|
|
|
|
|
HStack(spacing: AppSpacing.md) {
|
|
|
|
|
Image(systemName: "speaker.fill").foregroundStyle(AppColors.textSecondary)
|
|
|
|
|
Slider(value: $volume, in: 0...130, step: 1, onEditingChanged: { editing in
|
2026-06-30 00:12:41 -04:00
|
|
|
if !editing { Task { await send { try await $0.remoteCommand(action: "volume", value: volume, target: targetID) } } }
|
2026-06-09 06:38:45 -07:00
|
|
|
})
|
|
|
|
|
.tint(AppColors.primary)
|
|
|
|
|
Image(systemName: "speaker.wave.3.fill").foregroundStyle(AppColors.textSecondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 00:12:41 -04:00
|
|
|
private var targetID: String? { selected?.id }
|
|
|
|
|
|
|
|
|
|
/// With no target list yet (old bridge), every control stays available.
|
|
|
|
|
private func can(_ capability: String) -> Bool {
|
|
|
|
|
selected?.can(capability) ?? true
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 06:38:45 -07:00
|
|
|
private func transportButton(_ symbol: String, big: Bool = false, _ action: @escaping () async -> Void) -> some View {
|
|
|
|
|
Button { Task { await action() } } label: {
|
|
|
|
|
Image(systemName: symbol).font(.system(size: big ? 44 : 26))
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func send(_ op: (BridgeClient) async throws -> Void) async {
|
|
|
|
|
guard let client = settings.client else { return }
|
|
|
|
|
do {
|
|
|
|
|
try await op(client)
|
|
|
|
|
errorText = nil
|
2026-06-30 00:12:41 -04:00
|
|
|
status = try? await client.remoteStatus(target: targetID)
|
2026-06-09 06:38:45 -07:00
|
|
|
} catch {
|
|
|
|
|
errorText = error.localizedDescription
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func pollLoop() async {
|
|
|
|
|
while !Task.isCancelled {
|
|
|
|
|
if let client = settings.client {
|
2026-06-30 00:12:41 -04:00
|
|
|
if targets.isEmpty, let fetched = try? await client.fetchRemoteTargets(), !fetched.isEmpty {
|
|
|
|
|
targets = fetched
|
|
|
|
|
if selected == nil {
|
|
|
|
|
selected = fetched.first(where: { $0.reachable }) ?? fetched.first
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if selected?.reachable != false, let s = try? await client.remoteStatus(target: targetID) {
|
2026-06-09 06:38:45 -07:00
|
|
|
status = s
|
|
|
|
|
if let v = s.volume { volume = v }
|
|
|
|
|
errorText = nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
try? await Task.sleep(for: .seconds(3))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|