import { useEffect, useMemo, useRef, useState } from 'react' import { ACTION_LABELS, INPUT_ACTIONS, useInput, useGameAction, type InputAction, type InputDevice, } from '../input' import { ControllerBindingLabel, ControllerStylePreview, } from './ControllerIcons' import { useDualScreen } from '../dualScreen' import { getNativeDisplays, hasNativeDualScreenBridge, type AndroidDisplay, } from '../nativeDualScreen' type SettingsTab = 'display' | 'input' | 'bindings' type SettingsNavEntry = | { kind: 'back'; key: string; row: number; column: number } | { kind: 'tab'; key: string; row: number; column: number; tab: SettingsTab } | { kind: 'dualToggle'; key: string; row: number; column: number } | { kind: 'openCompanion'; key: string; row: number; column: number } | { kind: 'directTargeting'; key: string; row: number; column: number } | { kind: 'touchLock'; key: string; row: number; column: number } | { kind: 'iconStyle'; key: string; row: number; column: number; style: keyof typeof CONTROLLER_STYLE_LABELS } | { kind: 'device'; key: string; row: number; column: number; device: InputDevice } | { kind: 'binding'; key: string; row: number; column: number; action: InputAction } | { kind: 'resetBindings'; key: string; row: number; column: number } const CONTROLLER_STYLE_LABELS = { xbox: 'Xbox', playstation: 'PlayStation', nintendo: 'Nintendo', } as const const SETTINGS_TAB_ORDER = ['display', 'input', 'bindings'] as const const SETTINGS_TAB_LABELS: Record = { display: 'Display', input: 'Input', bindings: 'Bindings', } const SETTINGS_BINDING_COLUMNS = 2 const DIRECT_TARGET_ACTIONS = new Set([ 'targetParty1', 'targetParty2', 'targetParty3', 'targetParty4', 'targetParty5', 'targetParty6', 'toggleTargetGroup', ]) export function SettingsScreen({ onBack }: { onBack: () => void }) { const [device, setDevice] = useState('controller') const [settingsTab, setSettingsTab] = useState('display') const [selectedIndex, setSelectedIndex] = useState(1) const [displayMessage, setDisplayMessage] = useState('') const [androidDisplays, setAndroidDisplays] = useState([]) const navRefs = useRef>({}) const { bindings, capture, controllerIconStyle, directPartyTargeting, combatTouchLocked, beginCapture, cancelCapture, resetBindings, setControllerIconStyle, setDirectPartyTargeting, setCombatTouchLocked, } = useInput() const { enabled: dualScreenEnabled, connected: topDisplayConnected, setEnabled: setDualScreenEnabled, openTopDisplay, } = useDualScreen() const nativeDualScreen = hasNativeDualScreenBridge() const visibleActions = useMemo(() => INPUT_ACTIONS.filter((action) => ( directPartyTargeting ? action !== 'previousTarget' && action !== 'nextTarget' : !DIRECT_TARGET_ACTIONS.has(action) )), [directPartyTargeting]) const navEntries = useMemo(() => { const entries: SettingsNavEntry[] = [ { kind: 'back', key: 'back', row: 0, column: 0 }, ...SETTINGS_TAB_ORDER.map((tab, index) => ({ kind: 'tab' as const, key: `tab:${tab}`, row: 0, column: index + 1, tab, })), ] if (settingsTab === 'display') { entries.push( { kind: 'dualToggle', key: 'display:dual-toggle', row: 1, column: 1 }, { kind: 'openCompanion', key: 'display:open-companion', row: 1, column: 2 }, ) } else if (settingsTab === 'input') { entries.push( { kind: 'directTargeting', key: 'input:direct-targeting', row: 1, column: 1 }, { kind: 'touchLock', key: 'input:touch-lock', row: 1, column: 2 }, { kind: 'iconStyle', key: 'input:icons:xbox', row: 2, column: 0, style: 'xbox' }, { kind: 'iconStyle', key: 'input:icons:playstation', row: 2, column: 1, style: 'playstation' }, { kind: 'iconStyle', key: 'input:icons:nintendo', row: 2, column: 2, style: 'nintendo' }, ) } else { entries.push( { kind: 'device', key: 'bindings:device:controller', row: 1, column: 0, device: 'controller' }, { kind: 'device', key: 'bindings:device:pc', row: 1, column: 1, device: 'pc' }, ...visibleActions.map((action, index) => ({ kind: 'binding' as const, key: `bindings:action:${action}`, row: Math.floor(index / SETTINGS_BINDING_COLUMNS) + 2, column: index % SETTINGS_BINDING_COLUMNS, action, })), { kind: 'resetBindings', key: 'bindings:reset', row: Math.floor((visibleActions.length - 1) / SETTINGS_BINDING_COLUMNS) + 3, column: 1, }, ) } return entries }, [settingsTab, visibleActions]) const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1)) const activeEntry = navEntries[activeIndex] function selected(entryKey: string) { return activeEntry?.key === entryKey } function navRef(entryKey: string) { return (node: HTMLElement | null) => { navRefs.current[entryKey] = node } } function selectKey(entryKey: string) { const index = navEntries.findIndex((entry) => entry.key === entryKey) if (index >= 0) setSelectedIndex(index) } function selectTab(tab: SettingsTab) { setSettingsTab(tab) const tabIndex = SETTINGS_TAB_ORDER.indexOf(tab) setSelectedIndex(tabIndex + 1) } function firstContentKey(tab: SettingsTab) { if (tab === 'display') return 'display:dual-toggle' if (tab === 'input') return 'input:direct-targeting' return 'bindings:device:controller' } function moveSelection(action: InputAction) { if (!action.startsWith('navigate') || navEntries.length === 0) return setSelectedIndex((current) => { const bounded = Math.min(current, navEntries.length - 1) const active = navEntries[bounded] if (!active) return 0 if (action === 'navigateDown' && active.row === 0) { const nextIndex = navEntries.findIndex((entry) => entry.key === firstContentKey(settingsTab)) if (nextIndex >= 0) return nextIndex } if (action === 'navigateUp' && active.row === 1) { const tabIndex = navEntries.findIndex((entry) => entry.key === `tab:${settingsTab}`) if (tabIndex >= 0) return tabIndex } const candidates = navEntries .map((entry, index) => ({ entry, index })) .filter(({ index }) => index !== bounded) .filter(({ entry }) => { if (action === 'navigateLeft') return entry.column < active.column if (action === 'navigateRight') return entry.column > active.column if (action === 'navigateUp') return entry.row < active.row return entry.row > active.row }) if (candidates.length === 0) return bounded candidates.sort((a, b) => { const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column) const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column) const aSecondary = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(a.entry.row - active.row) : Math.abs(a.entry.column - active.column) const bSecondary = action === 'navigateLeft' || action === 'navigateRight' ? Math.abs(b.entry.row - active.row) : Math.abs(b.entry.column - active.column) return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index }) return candidates[0]?.index ?? bounded }) } function openEntry(entry: SettingsNavEntry | undefined) { if (!entry) return if (entry.kind === 'back') onBack() else if (entry.kind === 'tab') selectTab(entry.tab) else if (entry.kind === 'dualToggle') { setDualScreenEnabled(!dualScreenEnabled) setDisplayMessage('') } else if (entry.kind === 'openCompanion') { void launchTopDisplay() } else if (entry.kind === 'directTargeting') setDirectPartyTargeting(!directPartyTargeting) else if (entry.kind === 'touchLock') setCombatTouchLocked(!combatTouchLocked) else if (entry.kind === 'iconStyle') setControllerIconStyle(entry.style) else if (entry.kind === 'device') setDevice(entry.device) else if (entry.kind === 'binding') beginCapture(device, entry.action) else if (entry.kind === 'resetBindings') resetBindings(device) } async function refreshNativeDisplays() { if (!nativeDualScreen) return try { const result = await getNativeDisplays() setAndroidDisplays(result.displays) } catch { setAndroidDisplays([]) } } useEffect(() => { if (!nativeDualScreen) return getNativeDisplays() .then((result) => setAndroidDisplays(result.displays)) .catch(() => setAndroidDisplays([])) }, [nativeDualScreen]) useEffect(() => { if (!activeEntry) return navRefs.current[activeEntry.key]?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [activeEntry]) useGameAction((action, inputDevice) => { if (inputDevice !== 'controller' || capture) return if (action === 'back') { onBack() return } if (action === 'confirm') { openEntry(activeEntry) return } moveSelection(action) }) async function launchTopDisplay() { const opened = await openTopDisplay() setDisplayMessage(opened ? nativeDualScreen ? 'Android placed the game on the larger display and controls on the smaller display.' : 'Companion display opened. Move it to the Thor screen you want and select Fullscreen.' : 'No usable second display was found. Check the Thor display mode and try again.') await refreshNativeDisplays() } return (
{settingsTab === 'display' && (

Display

AYN Thor Dual-Screen Mode

The upper display shows enemy and party health. The lower display keeps targeting, resources, skills, and cooldowns.

{displayMessage || ( topDisplayConnected ? 'The companion display is connected and receiving live combat data.' : 'Open the companion display before starting combat.' )} {nativeDualScreen && androidDisplays.length > 0 && (
{androidDisplays.map((display) => ( {display.isCurrent ? 'Current' : 'Secondary'} #{display.id} {display.width}x{display.height} at {Math.round(display.refreshRate)} Hz {display.isPresentation ? ' - Presentation' : ''} ))}
)}
)} {settingsTab === 'input' && (

Targeting

Direct Party Keybinds

Assign party slots directly. In raids, use the group-switch binding to alternate between members 1-6, 7-12, and 13-18.

Controller Icons {(['xbox', 'playstation', 'nintendo'] as const).map((style) => ( ))}
)} {settingsTab === 'bindings' && (

Input

Keybindings

Select an action, then press the new key or controller control.

{visibleActions.map((action) => ( ))}
Bindings are saved automatically on this device.
)} {capture && (

Remapping

{ACTION_LABELS[capture.action]}

Press any {capture.device === 'pc' ? 'keyboard key' : 'controller button or move a stick'}.

)}
) }