// 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. import SwiftUI import LilithDesignTokens struct RemoteView: View { @EnvironmentObject private var settings: BridgeSettings @State private var targets: [RemoteTarget] = [] @State private var selected: RemoteTarget? @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 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 } } if let errorText { Text(errorText).font(AppTypography.caption()).foregroundStyle(AppColors.Semantic.error) } Spacer() } .padding(AppSpacing.lg) } .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) } } } .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) { 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) } } } } .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 if !editing { Task { await send { try await $0.remoteCommand(action: "volume", value: volume, target: targetID) } } } }) .tint(AppColors.primary) Image(systemName: "speaker.wave.3.fill").foregroundStyle(AppColors.textSecondary) } } 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 } 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 status = try? await client.remoteStatus(target: targetID) } catch { errorText = error.localizedDescription } } private func pollLoop() async { while !Task.isCancelled { if let client = settings.client { 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) { status = s if let v = s.volume { volume = v } errorText = nil } } try? await Task.sleep(for: .seconds(3)) } } }