#if os(tvOS)
import SwiftUI
import PhysarumEngine
/// Summon-able lean-back control panel: current preset name, a Cycle on/off toggle, and a
/// focusable horizontal carousel of presets. Opened by click or swipe-up; the focus engine
/// drives all navigation; dismissed only by Menu/Back (no auto-dismiss, no swipe-to-close).
struct TVOverlayView: View {
let appState: AppState
let controller: AmbientController
@FocusState private var focusedCard: String?
var body: some View {
VStack {
Spacer()
VStack(spacing: 24) {
Text(appState.selectedPreset.name)
.font(.largeTitle.bold())
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
// Centered, focus-reachable cycle toggle (swipe ▲ from the carousel lands here);
// label mirrors the HUD wording ("Cycle On/Off").
Button {
controller.apply(.togglePlaylist)
} label: {
Label(controller.isCycling ? "Cycle On" : "Cycle Off",
systemImage: controller.isCycling ? "arrow.triangle.2.circlepath" : "pause.fill")
}
.frame(maxWidth: .infinity)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 32) {
// The carousel mirrors the rotation (pinned slots; nothing pinned → all
// filled) so every card jump lands in the controller's playlist.
ForEach(appState.slotStore.slots.rotation, id: \.id) { preset in
Button { controller.apply(.jumpTo(preset.id)) } label: {
TVPresetCard(preset: preset,
selected: preset.id == appState.selectedID,
thumbURL: PresetCatalog.thumbnailURL(for: preset, store: appState.presetStore),
reloadToken: appState.thumbnailRevision)
}
.buttonStyle(.card)
.focused($focusedCard, equals: preset.id)
.accessibilityLabel(preset.name)
}
}
.padding(.horizontal, 80)
.padding(.vertical, 40)
}
.focusSection()
}
.padding(60)
.background(.black.opacity(0.55))
}
// Land focus on the current preset when the overlay appears; from there the focus engine
// handles ALL navigation (◀▶ carousel, ▲▼ to/from the Cycle button). No custom move
// interception — that's what made the button unreachable + ◀▶ flaky. Closes only via Menu.
.onAppear {
focusedCard = appState.selectedID
appState.backfillThumbnails() // render any user-slot thumbnails missing on this device
}
}
}
private struct TVPresetCard: View {
let preset: Preset
let selected: Bool
let thumbURL: URL?
let reloadToken: Int
@State private var image: UIImage?
var body: some View {
VStack(spacing: 12) {
thumbnail
.frame(width: 320, height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(selected ? .white : .clear, lineWidth: 4))
Text(preset.name).font(.headline).foregroundStyle(.white).lineLimit(1)
}
// Reload when the resolved URL changes or a backfill bumps the revision.
.task(id: "\(thumbURL?.path ?? "none")|\(reloadToken)") {
image = thumbURL.flatMap { UIImage(contentsOfFile: $0.path) }
}
}
@ViewBuilder private var thumbnail: some View {
if let image {
Image(uiImage: image).resizable().scaledToFill()
} else {
Rectangle().fill(.gray.opacity(0.3))
}
}
}
#endif