Zum Inhalt

apple/PhysarumApp/Sources/TV/TVOverlayView.swift

#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