#if !os(tvOS)
import SwiftUI
/// Root: a full-bleed player with a floating glass topbar and an ADAPTIVE editor —
/// an `.inspector` column on regular width (macOS, iPad-landscape) or a bottom-sheet
/// over the still-running player on compact width / iPad-portrait — plus a library
/// overlay and a fullscreen mode. The player never stops; the sheet floats over it.
struct RootView: View {
@State private var state = AppState(slotStore: AppState.makeProductionSlotStore())
@State private var showEditor = false
@State private var showLibrary = false
@State private var showSave = false
@State private var pendingLibrary = false
@State private var fullscreen = false
@State private var didInitEditor = false
private let tier = FieldTier.current
@State private var cycleTimer: Timer?
// false = fit (whole sim visible, surround = preset bg colour); true = cover (fill, crop).
@AppStorage("renderCover") private var cover = false
@AppStorage("simFormat") private var format: SimFormat = .auto
@State private var fsHintShown = false
@State private var fsHintWork: DispatchWorkItem?
@State private var showCapture = false
@State private var showCaptureFS = false // fullscreen entry (sheet — no topbar anchor in fullscreen)
@State private var playerSize: CGSize = .zero
@State private var snapshotProvider = LiveSnapshotProvider()
@State private var errorPresenter = ErrorPresenter()
#if os(iOS)
// T2a editor-remote: mirror this editing session to an Apple TV over the local network.
@State private var remote = RemoteController()
@State private var discovery: BonjourDiscovery?
@State private var discoveredDevices: [RemoteEndpoint] = []
@State private var discoveryState: DiscoveryState = .searching
@State private var searchTimedOut = false
@State private var searchGeneration = 0
@State private var showDevicePicker = false
// T2b: paces the mic-factor stream to the ATV (~30 Hz). Always running; the handler early-returns
// unless connected + audio reactivity is on.
private let audioStreamTimer = Timer.publish(every: 1.0 / 30.0, on: .main, in: .common).autoconnect()
#endif
// Custom dockable side panels (regular width): Library + Editor flank the player as
// fixed-width, identically-styled surface columns. Collapsed (both hidden) → full-bleed.
// Toggled via the topbar buttons OR an edge swipe. Hand-rolled because NavigationSplitView's
// sidebar renders as a floating overlay (inconsistent with the flush inspector) and gives
// no swipe-to-collapse.
@State private var sidebarShown = true
@State private var inspectorShown = true
private let panelWidth: CGFloat = 340
private var panelAnim: Animation { .easeInOut(duration: 0.28) }
#if !os(macOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
#endif
@Environment(\.scenePhase) private var scenePhase
private var playerPaused: Bool {
playerShouldPause(libraryVisible: showLibrary, sceneActive: scenePhase == .active)
}
private func layout(for size: CGSize) -> WorkspaceLayout {
#if os(macOS)
return workspaceLayout(isMac: true, isCompactWidth: false, size: size)
#else
return workspaceLayout(isMac: false, isCompactWidth: hSizeClass == .compact, size: size)
#endif
}
private var showEyebrow: Bool {
#if os(macOS)
return true
#else
return hSizeClass != .compact
#endif
}
/// Detents for the editor sheet (sheet mode only — never the inspector/macOS path).
/// iPad-portrait full-screen opens roughly three-quarters height (the dense editor
/// is cramped at `.medium` on a large display, but a sliver of the living picture
/// stays visible), expandable to full. Compact (iPhone, iPad split-view) keeps the
/// medium-first, ambiance-first behaviour.
private var editorSheetDetents: Set<PresentationDetent> {
#if os(macOS)
return [.large]
#else
return hSizeClass == .compact ? [.medium, .large] : [.fraction(0.75), .large]
#endif
}
var body: some View {
GeometryReader { screen in
// The layout is decided from the FULL-screen geometry, which is stable.
// Deciding it from the inspector-reduced primary would flip
// .splitColumns⇄.overlaySheets (workspaceLayout keys off width>height) in a loop.
let layout = layout(for: screen.size)
Group {
switch layout {
case .splitColumns:
// The canvas stays full-bleed at ALL times; the panels float OVER it as
// overlays (drawn above the edge-swipe zones, below the topbar). Toggling a
// panel never resizes the player, so the engine field is never rebuilt.
playerLayer(layout: .splitColumns, showTopbar: false)
.overlay(alignment: .leading) { edgeSwipeZone(.leading) }
.overlay(alignment: .trailing) { edgeSwipeZone(.trailing) }
.overlay(alignment: .leading) {
if sidebarShown && !fullscreen {
LibraryView(state: state, isPresented: .constant(true), dismissOnSelect: false,
onSave: { showSave = true })
.frame(width: panelWidth)
.background(Theme.Colors.surface.ignoresSafeArea())
.task { state.backfillThumbnails() } // sidebar visible — fire once (idempotent)
.transition(.move(edge: .leading))
}
}
.overlay(alignment: .trailing) {
if inspectorShown && !fullscreen {
EditorView(state: state)
.frame(width: panelWidth)
.background(Theme.Colors.surface.ignoresSafeArea())
.transition(.move(edge: .trailing))
}
}
.overlay(alignment: .top) {
if !fullscreen {
// Topbar spans only the visible canvas (inset past any open panel),
// so it never overlaps a panel and its sidebar toggles sit right at
// the canvas edge next to each panel.
topbar(layout: .splitColumns)
.padding(.top, screen.safeAreaInsets.top)
.padding(.leading, sidebarShown ? panelWidth : 0)
.padding(.trailing, inspectorShown ? panelWidth : 0)
}
}
case .overlaySheets:
playerLayer(layout: .overlaySheets)
.sheet(isPresented: Binding(
get: { showEditor && !fullscreen },
set: { showEditor = $0 })) {
EditorView(state: state)
.presentationDetents(editorSheetDetents)
.presentationBackgroundInteraction(.enabled)
.presentationContentInteraction(.scrolls)
.errorAlert(errorPresenter)
}
.sheet(isPresented: $showLibrary) {
LibraryView(state: state, isPresented: $showLibrary, onSave: { showSave = true })
.errorAlert(errorPresenter)
}
.onChange(of: showEditor) { _, newValue in
if !newValue && pendingLibrary {
pendingLibrary = false
showLibrary = true
}
}
}
}
.sheet(isPresented: $showCaptureFS) {
captureSheet(width: playerSize.width, height: playerSize.height, isPresented: $showCaptureFS)
}
.sheet(isPresented: $showSave) { SaveDialog(state: state, isPresented: $showSave) }
.onAppear {
if !didInitEditor {
// split: both panels start open; sheet: player-first
showEditor = (layout == .splitColumns)
didInitEditor = true
state.snapshotProvider = snapshotProvider
state.migrateLegacyCustomsIfNeeded() // once: carry pre-slot customs into slots
#if DEBUG
// Capture hooks (DEBUG-only) to drive the app into a specific UI state for
// App Store media. HIDE_UI launches chrome-free for clean visual stills;
// SHOW_LIBRARY / SHOW_EDITOR / SHOW_CAPTURE open the matching surface OVER the
// live player so a screenshot shows the app genuinely IN USE (Apple 2.3.3 / 4.2).
// On split-columns the library + editor are docked panels (open by default);
// on the compact sheet/popover path they need a beat after appear so the
// presentation isn't swallowed mid-transition.
let capEnv = ProcessInfo.processInfo.environment
if capEnv["PHYSARUM_HIDE_UI"] == "1" { fullscreen = true }
if capEnv["PHYSARUM_SHOW_LIBRARY"] == "1" {
if layout == .splitColumns { sidebarShown = true }
else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { showLibrary = true } }
}
if capEnv["PHYSARUM_SHOW_EDITOR"] == "1" {
if layout == .splitColumns { inspectorShown = true }
else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { showEditor = true } }
}
if capEnv["PHYSARUM_SHOW_CAPTURE"] == "1" {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showCapture = true }
}
#endif
}
}
}
.preferredColorScheme(.dark)
.onChange(of: state.autoCycle) { _, _ in syncCycle() }
.onChange(of: showLibrary) { _, newValue in
syncCycle()
if newValue { state.backfillThumbnails() }
}
.onChange(of: scenePhase) { _, phase in
syncCycle()
if phase != .active { state.flushSync() } // iCloud-KVS pushes reliably on background
}
.onChange(of: state.audio.permissionDenied) { _, denied in
guard denied else { return }
errorPresenter.report(
title: "Microphone Access Off",
message: "Physarum uses the microphone to react to sound. Turn it on in Settings to enable audio-reactive visuals.",
recovery: .openSettings)
state.setAudioEnabled(false) // reflect reality: the toggle goes back off
}
#if os(iOS)
// Mirror local edits to the connected ATV: selection as a full hello (crossfade + adopt
// params), live param edits throttled. PresetParams is Equatable, so onChange dedupes no-ops.
.onChange(of: state.selectedID) { _, _ in
remote.sendSelection(id: state.selectedID, params: state.edited, cycling: state.autoCycle)
}
.onChange(of: state.edited) { _, _ in remote.sendParams(state.edited) }
// Forward the phone's own auto-cycle toggle to the ATV (re-hello: same selection, no crossfade).
.onChange(of: state.autoCycle) { _, _ in
remote.sendSelection(id: state.selectedID, params: state.edited, cycling: state.autoCycle)
}
// T2b: stream the phone's mic reactivity to the ATV while connected + audio on. The phone is the
// audio brain; the ATV (no mic) applies the 10 factors. Toggling audio off sends a final neutral
// frame so the ATV relaxes immediately rather than waiting out its staleness timer.
.onReceive(audioStreamTimer) { _ in
// Only stream while foreground-active: a backgrounded phone's mic is suspended anyway, and
// stopping lets the ATV stale-clear to a calm T1 rather than holding a frozen last beat.
guard scenePhase == .active, remote.state == .connected, state.audioReactive.enabled else { return }
remote.sendAudioFactors(bands: state.audio.latestBands, reactive: state.audioReactive, tuning: state.audioTuning)
}
.onChange(of: state.audioReactive.enabled) { _, on in
if !on, remote.state == .connected { remote.sendAudioFactorsNeutral() }
}
.sheet(isPresented: $showDevicePicker, onDismiss: { closeDevicePicker() }) { devicePickerSheet }
#endif
#if !os(macOS)
// True fullscreen: hiding the topbar alone leaves the system chrome —
// clock/battery and the home indicator — over the visual. (macOS gets
// real fullscreen from the native window button.)
.statusBarHidden(fullscreen)
.persistentSystemOverlays(fullscreen ? .hidden : .automatic)
#endif
.environment(errorPresenter)
.errorAlert(errorPresenter)
}
/// Full-bleed player + floating glass topbar, sized to the ACTUAL available area.
/// A second GeometryReader — hosted INSIDE `.inspector` — measures the primary
/// region, which the inspector column shrinks when open. Sizing the canvas and
/// topbar to that (instead of the full screen width) keeps the topbar's controls
/// on-screen on iPad-landscape; previously the full-width topbar overflowed and its
/// leading controls slid off the left edge behind the inspector.
/// (The explicit size also keeps full-bleed rendering: without the UILaunchScreen
/// Info.plist key iOS would run the app in 320×480 compatibility mode.)
@ViewBuilder
private func playerLayer(layout: WorkspaceLayout, showTopbar: Bool = true) -> some View {
GeometryReader { geo in
let fullW = geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing
let fullH = geo.size.height + geo.safeAreaInsets.top + geo.safeAreaInsets.bottom
ZStack(alignment: .top) {
// Initial field matches the real view target so the first draw doesn't
// discard a freshly built square engine (startup double-build).
MetalView(initialField: targetFieldSize(viewWidth: fullW, viewHeight: fullH, format: format, tier: tier),
selectedPreset: state.selectedPreset, editedParams: state.edited,
cover: cover, format: format, tier: tier, paused: playerPaused,
audioInput: state.audio,
audioReactive: state.audioReactive, audioTuning: state.audioTuning,
snapshotProvider: snapshotProvider)
.frame(width: fullW, height: fullH)
.clipped()
.contentShape(Rectangle())
.onTapGesture { handleCanvasTap() } // dismiss editor / reveal the fullscreen-exit hint
.accessibilityElement()
.accessibilityLabel(A11y.canvasLabel)
.accessibilityValue(A11y.canvasValue(presetName: state.selectedPreset.name))
.accessibilityAddTraits(.updatesFrequently)
if !fullscreen && showTopbar {
topbar(layout: layout)
.padding(.top, geo.safeAreaInsets.top)
}
}
.frame(width: fullW, height: fullH)
.ignoresSafeArea()
.overlay(alignment: .bottom) {
if fullscreen && fsHintShown {
captureFullscreenButton.padding(.bottom, 44)
}
}
.overlay(alignment: .topTrailing) {
if fullscreen && fsHintShown {
exitFullscreenControl
.padding(.top, geo.safeAreaInsets.top + Theme.Space.s3)
.padding(.trailing, Theme.Space.s3)
}
}
.onAppear { playerSize = CGSize(width: fullW, height: fullH) }
.onChange(of: geo.size) { _, _ in playerSize = CGSize(width: fullW, height: fullH) }
}
}
/// Auto-cycle only runs while the player is visible — advancing presets
/// (a full engine rebuild) under a covering sheet or in the background
/// is wasted work nobody sees.
private func syncCycle() {
let shouldRun = state.autoCycle && !playerPaused
if shouldRun, cycleTimer == nil { startCycle() }
if !shouldRun { stopCycle() }
}
@ViewBuilder
private func captureSheet(width: CGFloat, height: CGFloat, isPresented: Binding<Bool>) -> some View {
CaptureSheet(preset: state.edited, presetName: state.selectedPreset.name,
viewWidth: Double(width), viewHeight: Double(height),
cover: cover, format: format, snapshotProvider: snapshotProvider,
isPresented: isPresented)
// The shared presenter shows only the frontmost alert; mounting it here lets
// a capture/save error surface over this sheet/popover (the root mount can't,
// because the root VC is busy presenting this).
.errorAlert(errorPresenter)
.presentationDetents([.medium])
.presentationCompactAdaptation(.sheet)
}
private func topbar(layout: WorkspaceLayout) -> some View {
HStack(spacing: Theme.Space.s3) {
if layout == .splitColumns {
iconButton("sidebar.left", label: "Library panel",
hint: "Shows or hides the preset browser",
value: A11y.onOff(sidebarShown)) { withAnimation(panelAnim) { sidebarShown.toggle() } }
}
if showEyebrow {
Text("PHYSARUM")
.font(Theme.Fonts.eyebrow).tracking(2)
.foregroundStyle(Theme.Colors.fgSecondary)
}
Button { openLibrary(layout: layout) } label: {
HStack(spacing: 6) {
Circle().fill(Theme.Colors.signal(state.selectedPreset.tagSignal.rawValue)).frame(width: 7, height: 7)
Text(state.selectedPreset.name).font(Theme.Fonts.label).foregroundStyle(Theme.Colors.fgPrimary)
.lineLimit(1).truncationMode(.tail)
Image(systemName: "chevron.down").font(.system(size: 8)).foregroundStyle(Theme.Colors.fgTertiary)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Current preset")
.accessibilityHint("Opens the library to pick another preset")
.accessibilityValue(state.selectedPreset.name)
Spacer()
#if os(iOS)
appleTVButton
#endif
formatMenu
iconButton("tray.and.arrow.down", label: "Save preset",
hint: "Save the current preset into a slot") { showSave = true } // save the current preset into a slot
Button { showCapture = true } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 15))
.foregroundStyle(Theme.Colors.fgSecondary)
.frame(width: 34, height: 30)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Capture")
.accessibilityHint("Export a photo, video, or embed")
.popover(isPresented: $showCapture) {
captureSheet(width: playerSize.width, height: playerSize.height, isPresented: $showCapture)
}
iconButton("arrow.up.left.and.arrow.down.right", label: "Enter fullscreen") { fullscreen = true }
iconButton(layout == .splitColumns ? "sidebar.right" : "slider.horizontal.3",
label: "Editor panel", hint: "Shows or hides the parameter editor",
value: A11y.onOff(inspectorShown)) {
if layout == .splitColumns { withAnimation(panelAnim) { inspectorShown.toggle() } }
else { showEditor.toggle() }
}
}
.padding(.horizontal, Theme.Space.s4)
.padding(.vertical, Theme.Space.s2)
.glassPanel()
// The topbar is a single fixed-height row of icon glyphs + the preset pill;
// it cannot grow vertically. Cap its Dynamic Type so the glyph-adjacent text
// (eyebrow + preset name) stays bounded instead of crowding the icons at AX5.
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
.padding(Theme.Space.s3)
}
/// On compact the editor sheet and the library sheet share RootView as their
/// presenter. Dismissing one sheet and presenting another in the same synchronous
/// call can silently swallow the second presentation on iOS 17.x because the sheet
/// machinery requires the dismiss animation to complete first. We set a `pendingLibrary`
/// flag and let `.onChange(of: showEditor)` in `body` open the library once the dismiss
/// animation has finished. In `.splitColumns` the library is a docked panel, so the
/// topbar's preset pill TOGGLES the sidebar; collapsing it alongside a hidden inspector
/// makes the living image full-bleed.
private func openLibrary(layout: WorkspaceLayout) {
if layout == .overlaySheets && showEditor {
pendingLibrary = true
showEditor = false
} else if layout == .splitColumns {
withAnimation(panelAnim) { sidebarShown.toggle() }
} else {
showLibrary = true
}
}
private func iconButton(_ name: String, label: String, hint: String? = nil,
value: String? = nil, _ action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: name)
.font(.system(size: 15))
.foregroundStyle(Theme.Colors.fgSecondary)
.frame(width: 34, height: 30) // larger touch target
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(label)
.accessibilityHint(hint ?? "")
.accessibilityValue(value ?? "")
}
#if os(iOS)
/// Topbar affordance: tap to pick an Apple TV (when disconnected) or disconnect (when live).
/// Green when mirroring. macOS has no equivalent yet (NWBrowser remote is a fast-follow).
private var appleTVButton: some View {
Button {
switch remote.state {
case .connected, .connecting:
remote.disconnect()
discovery?.stop(); discovery = nil
case .disconnected, .failed:
startDiscovery()
}
} label: {
Image(systemName: remote.state == .connected ? "appletv.fill" : "appletv")
.font(.system(size: 15))
.foregroundStyle(remote.state == .connected ? Color.green : Theme.Colors.fgSecondary)
.frame(width: 34, height: 30)
.contentShape(Rectangle())
.overlay(alignment: .topTrailing) {
// T2b: a small badge while mic reactivity is streaming to the ATV.
if remote.state == .connected, state.audioReactive.enabled {
Image(systemName: "waveform")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(Color.green)
.padding(1)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel("Apple TV")
.accessibilityHint("Mirror this session to an Apple TV")
.accessibilityValue(remote.state == .connected ? "Connected" : "Disconnected")
}
private var devicePickerSheet: some View {
NavigationStack {
List {
if discoveredDevices.isEmpty {
if discoveryState == .failed || searchTimedOut {
VStack(alignment: .leading, spacing: Theme.Space.s2) {
Text(discoveryState == .failed
? "Local network access is off. Turn it on in Settings ▸ Physarum ▸ Local Network, then search again."
: "No Apple TVs found. Make sure Physarum is open on your Apple TV and on the same Wi-Fi.")
.font(Theme.Fonts.label).foregroundStyle(Theme.Colors.fgSecondary)
Button("Search again") { startDiscovery() }.font(Theme.Fonts.label)
.accessibilityLabel("Search again")
.accessibilityHint("Retry Apple TV discovery")
}
.padding(.vertical, Theme.Space.s2)
} else {
HStack(spacing: Theme.Space.s2) {
ProgressView()
Text("Searching for Apple TVs on your network…")
.font(Theme.Fonts.label).foregroundStyle(Theme.Colors.fgSecondary)
}
}
} else {
ForEach(discoveredDevices) { device in
Button { pick(device) } label: { Label(device.name, systemImage: "appletv") }
.accessibilityLabel("Connect")
.accessibilityHint("Mirror to this Apple TV")
.accessibilityValue(device.name)
}
}
}
.navigationTitle("Connect to Apple TV")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { closeDevicePicker() } }
}
// After a grace period with no results, stop spinning and offer "Search again". Keyed on the
// search generation so "Search again" restarts the countdown.
.task(id: searchGeneration) {
searchTimedOut = false
try? await Task.sleep(for: .seconds(6))
if discoveredDevices.isEmpty { searchTimedOut = true }
}
}
.presentationDetents([.medium])
}
private func startDiscovery() {
discovery?.stop() // tear down any prior browse before starting fresh
let discovery = BonjourDiscovery()
discoveredDevices = []
discoveryState = .searching
searchTimedOut = false
searchGeneration += 1
discovery.start(onResults: { discoveredDevices = $0 }, onState: { discoveryState = $0 })
self.discovery = discovery
showDevicePicker = true
}
private func pick(_ device: RemoteEndpoint) {
guard let discovery else { return }
let connection = discovery.connect(to: device)
remote.connect(connection,
initialState: RemoteState(selectedID: state.selectedID, params: state.edited, cycling: state.autoCycle))
closeDevicePicker()
}
private func closeDevicePicker() {
showDevicePicker = false
discovery?.stop()
discovery = nil
}
#endif
/// A thin strip over the player's left/right edge. A horizontal drag toggles the adjacent
/// dockable panel (drawer-style): at the leading edge drag-right shows / drag-left hides the
/// library; at the trailing edge drag-left shows / drag-right hides the editor. The topbar
/// buttons remain the discoverable path; this is the bonus swipe. Inset below the topbar so
/// it never steals its taps.
private func edgeSwipeZone(_ edge: HorizontalEdge) -> some View {
Color.clear
.frame(width: 36)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 24)
.onEnded { v in
guard abs(v.translation.width) > 44,
abs(v.translation.width) > abs(v.translation.height) else { return }
let draggingRight = v.translation.width > 0
withAnimation(panelAnim) {
if edge == .leading { sidebarShown = draggingRight }
else { inspectorShown = !draggingRight }
}
}
)
.padding(.top, 70)
}
/// Topbar canvas-format control: pick the field format + toggle fill-vs-fit.
private var formatMenu: some View {
Menu {
Picker("Format", selection: $format) {
Label("Auto", systemImage: "rectangle.dashed").tag(SimFormat.auto)
Label("Square", systemImage: "square").tag(SimFormat.square)
Label("Portrait", systemImage: "rectangle.portrait").tag(SimFormat.portrait)
Label("Landscape", systemImage: "rectangle").tag(SimFormat.landscape)
}
Toggle(isOn: $cover) { Label("Fill (crop edges)", systemImage: "rectangle.on.rectangle") }
} label: {
Image(systemName: "aspectratio")
.font(.system(size: 15))
.foregroundStyle(Theme.Colors.fgSecondary)
.frame(width: 34, height: 30)
.contentShape(Rectangle())
}
.accessibilityLabel("Canvas format")
.accessibilityHint("Aspect ratio and fill or fit")
}
/// A tap on the canvas: in fullscreen, briefly reveal the exit control; otherwise
/// dismiss the editor if it's open.
private func handleCanvasTap() {
if fullscreen {
revealFsHint()
} else if showEditor {
showEditor = false
}
}
private func revealFsHint() {
withAnimation { fsHintShown = true }
fsHintWork?.cancel()
let work = DispatchWorkItem { withAnimation { fsHintShown = false } }
fsHintWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: work)
}
/// Glass-pill capture button that appears alongside the fullscreen-exit hint.
private var captureFullscreenButton: some View {
Button {
fsHintWork?.cancel()
fsHintShown = false
showCaptureFS = true
} label: {
HStack(spacing: 6) {
Image(systemName: "square.and.arrow.up").font(.system(size: 12))
Text("Capture").font(Theme.Fonts.label)
}
.padding(.horizontal, Theme.Space.s4)
.padding(.vertical, Theme.Space.s2)
.foregroundStyle(Theme.Colors.fgPrimary)
.glassPanel()
}
.buttonStyle(.plain)
.accessibilityLabel("Capture")
.transition(.opacity)
}
/// Fades in on a tap during fullscreen, top-trailing (where the enter glyph sat in
/// the topbar), auto-hides. A compact glass glyph mirroring the enter button.
private var exitFullscreenControl: some View {
Button {
fsHintWork?.cancel()
fsHintShown = false
fullscreen = false
} label: {
Image(systemName: "arrow.down.right.and.arrow.up.left")
.font(.system(size: 15))
.foregroundStyle(Theme.Colors.fgSecondary)
.frame(width: 34, height: 30)
.contentShape(Rectangle())
.glassPanel()
}
.buttonStyle(.plain)
.accessibilityLabel("Exit fullscreen")
.transition(.opacity)
}
private func startCycle() {
cycleTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { _ in state.advance(by: 1) }
}
private func stopCycle() { cycleTimer?.invalidate(); cycleTimer = nil }
}
#endif