tv-anarchy/Sources/TVAnarchyiOS/RemoteView.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

152 lines
6.2 KiB
Swift

// 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))
}
}
}