diff options
| author | Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> | 2026-09-12 21:29:08 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-12 21:29:08 -0400 |
| commit | e580c6bd60c92af36f92d4c29b5d9a44f73d47d7 (patch) | |
| tree | 7fc6799626519e5b01fb137f5440c99fda5faca7 /src/components | |
| parent | e997a3fb74fa70f5e60b60807fb0897120e98313 (diff) | |
| parent | 81feb03288166755545401b9df2ff2c9bc3ae7d7 (diff) | |
| download | decky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.tar.gz decky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.zip | |
Merge pull request #264 from xXJSONDeruloXx/chore/migrate
the big one
Diffstat (limited to 'src/components')
25 files changed, 1776 insertions, 2007 deletions
diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx new file mode 100644 index 0000000..a4e4092 --- /dev/null +++ b/src/components/CollapsibleItemGroup.tsx @@ -0,0 +1,103 @@ +import { ButtonItem, Field, PanelSectionRow } from "@decky/ui"; +import { useEffect, useState, type RefObject } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; + +export interface CollapsibleItem { + id: string; + label: string; + description: string; + disabled?: boolean; +} + +export const collapsibleItemGroupStyles = ` + .LSFG_GameGroupCollapseButton_Container { + margin-top: -2px; + margin-bottom: 4px; + } + + .LSFG_GameGroupCollapseButton_Container > div > div > div > button, + .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + min-height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .LSFG_GameGroupCollapseButton_Container svg { + display: block; + margin: 0; + } +`; + +export function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) !== "false"; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch {} + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +interface Props { + title: string; + items: CollapsibleItem[]; + collapsed: boolean; + onToggle: () => void; + onSelect: (id: string) => void; + toggleRef?: RefObject<HTMLDivElement>; +} + +export function CollapsibleItemGroup({ + title, + items, + collapsed, + onToggle, + onSelect, + toggleRef, +}: Props) { + if (items.length === 0) return null; + + return ( + <> + <PanelSectionRow> + <Field label={`${title} (${items.length})`} bottomSeparator="none" /> + </PanelSectionRow> + <PanelSectionRow> + <div + ref={toggleRef} + className="LSFG_GameGroupCollapseButton_Container" + > + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={onToggle} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </div> + </PanelSectionRow> + {!collapsed && items.map((item) => ( + <PanelSectionRow key={item.id}> + <Field + label={item.label} + description={item.description} + disabled={item.disabled} + onActivate={item.disabled ? undefined : () => onSelect(item.id)} + highlightOnFocus + /> + </PanelSectionRow> + ))} + </> + ); +} diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx new file mode 100644 index 0000000..fd6d716 --- /dev/null +++ b/src/components/ConfigFileTab.tsx @@ -0,0 +1,123 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; +import { useEffect, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { getDebugFileContents, type DebugFileContent, type DebugFileContentsResult } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) !== "false"; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch { + // Persisting the view preference is optional. + } + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +function DebugFileSection({ file }: { file: DebugFileContent }) { + const [collapsed, toggleCollapsed] = usePersistentCollapsed(`lsfg-debug-file-${file.id}-collapsed-v1`); + const status = file.exists ? "Present" : "Not present"; + + return ( + <> + <PanelSectionRow> + <Field + label={file.label} + description={`${file.path} · ${status}`} + bottomSeparator="none" + /> + </PanelSectionRow> + <PanelSectionRow> + <div + className="LSFG_DebugFileCollapseButton_Container" + style={{ marginTop: "-2px", marginBottom: "4px" }} + > + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={toggleCollapsed} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </div> + </PanelSectionRow> + {!collapsed && ( + <PanelSectionRow> + {file.exists && file.content !== null && file.content !== undefined ? ( + <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}> + {file.content} + </pre> + ) : ( + <Field + label="File unavailable" + description={file.error || "The file has not been created yet."} + /> + )} + </PanelSectionRow> + )} + </> + ); +} + +export function ConfigFileTab() { + const [result, setResult] = useState<DebugFileContentsResult | null>(null); + + useEffect(() => { + getDebugFileContents().then(setResult).catch((error) => { + setResult({ success: false, error: String(error) }); + }); + }, []); + + if (!result) { + return ( + <PanelSection title={t("NERD_CONFIG_FILE", "Config / Debug")}> + <PanelSectionRow> + <Spinner /> + </PanelSectionRow> + </PanelSection> + ); + } + + return ( + <> + <style> + {` + .LSFG_DebugFileCollapseButton_Container > div > div > div > button, + .LSFG_DebugFileCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + min-height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .LSFG_DebugFileCollapseButton_Container svg { + display: block; + margin: 0; + } + `} + </style> + <PanelSection title={t("NERD_CONFIG_FILE", "Config / Debug")}> + {result.error && ( + <PanelSectionRow> + <Field label="Error" description={result.error} /> + </PanelSectionRow> + )} + {result.success && result.files?.map((file) => ( + <DebugFileSection key={file.id} file={file} /> + ))} + </PanelSection> + </> + ); +} diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index a10aab2..6996bcd 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,301 +1,25 @@ -import { PanelSectionRow, ToggleField, SliderField, ButtonItem } from "@decky/ui"; -import { useState, useEffect } from "react"; -import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { - FLOW_SCALE, NO_FP16, PERFORMANCE_MODE, HDR_MODE, - EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, - MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK -} from "../config/generatedConfigSchema"; -import t from '../i18n/i18n'; +import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from "../config/configSchema"; interface ConfigurationSectionProps { config: ConfigurationData; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise<void>; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; } -const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed"; -const CONFIG_COLLAPSED_KEY = "lsfg-config-collapsed"; - -export function ConfigurationSection({ - config, - onConfigChange -}: ConfigurationSectionProps) { - // Initialize with localStorage value, fallback to true if not found - const [configCollapsed, setConfigCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(CONFIG_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : false; - } catch { - return false; - } - }); - - const [workaroundsCollapsed, setWorkaroundsCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : true; - } catch { - return true; - } - }); - - // Persist workarounds collapse state to localStorage - useEffect(() => { - try { - localStorage.setItem(CONFIG_COLLAPSED_KEY, JSON.stringify(configCollapsed)); - } catch (error) { - console.warn("Failed to save config collapse state:", error); - } - }, [configCollapsed]); - - useEffect(() => { - try { - localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(workaroundsCollapsed)); - } catch (error) { - console.warn("Failed to save workarounds collapse state:", error); - } - }, [workaroundsCollapsed]); - - return ( - <> - <style> - {` - .LSFG_ConfigCollapseButton_Container > div > div > div > button, - .LSFG_ConfigCollapseButton_Container > div > div > div > div > button, - .LSFG_WorkaroundsCollapseButton_Container > div > div > div > button { - height: 10px !important; - } - .LSFG_WorkaroundsCollapseButton_Container > div > div > div > div > button { - height: 10px !important; - } - `} - </style> - - {/* Config Section */} - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('CONFIG_SECTION_TITLE', 'Config')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - className="LSFG_ConfigCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={configCollapsed ? "standard" : "none"} - onClick={() => setConfigCollapsed(!configCollapsed)} - > - {configCollapsed ? ( - <RiArrowDownSFill - style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }} - /> - ) : ( - <RiArrowUpSFill - style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }} - /> - )} - </ButtonItem> - </div> - </PanelSectionRow> - - {!configCollapsed && ( - <> - <PanelSectionRow> - <SliderField - label={`${t('CONFIG_FLOW_SCALE', 'Flow Scale')} (${Math.round(config.flow_scale * 100)}%)`} - description={t('CONFIG_FLOW_SCALE_DESC', 'Lowers internal motion estimation resolution, improving performance slightly')} - value={config.flow_scale} - min={0.25} - max={1.0} - step={0.01} - onChange={(value) => onConfigChange(FLOW_SCALE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label="FP16 Acceleration" - description="Use FP16 shaders when supported" - checked={!config.no_fp16} - onChange={(value) => onConfigChange(NO_FP16, !value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <SliderField - label={`${t('CONFIG_BASE_FPS_CAP', 'Base FPS Cap')}${config.dxvk_frame_rate > 0 ? ` (${config.dxvk_frame_rate} FPS)` : ` (${t('CONFIG_BASE_FPS_CAP_OFF', 'Off')})`}`} - description={t('CONFIG_BASE_FPS_CAP_DESC', 'Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)')} - value={config.dxvk_frame_rate} - min={0} - max={60} - step={1} - onChange={(value) => onConfigChange(DXVK_FRAME_RATE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={`${t('CONFIG_PRESENT_MODE', 'Present Mode')} (${(config.experimental_present_mode || "fifo") === "fifo" ? t('CONFIG_PRESENT_MODE_FIFO', 'FIFO - VSync') : t('CONFIG_PRESENT_MODE_MAILBOX', 'Mailbox')})`} - description={t('CONFIG_PRESENT_MODE_DESC', 'Toggle between FIFO - VSync (default) and Mailbox presentation modes for better performance or compatibility')} - checked={(config.experimental_present_mode || "fifo") === "fifo"} - onChange={(value) => onConfigChange(EXPERIMENTAL_PRESENT_MODE, value ? "fifo" : "mailbox")} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_PERFORMANCE_MODE', 'Performance Mode')} - description={t('CONFIG_PERFORMANCE_MODE_DESC', 'Uses a lighter model for FG (Recommended for most games)')} - checked={config.performance_mode} - onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_HDR_MODE', 'HDR Mode')} - description={t('CONFIG_HDR_MODE_DESC', 'Enables HDR mode (only for games that support HDR)')} - checked={config.hdr_mode} - onChange={(value) => onConfigChange(HDR_MODE, value)} - /> - </PanelSectionRow> - </> - )} - - {/* Workarounds Section */} - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('CONFIG_WORKAROUNDS_TITLE', 'Workarounds')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - className="LSFG_WorkaroundsCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={workaroundsCollapsed ? "standard" : "none"} - onClick={() => setWorkaroundsCollapsed(!workaroundsCollapsed)} - > - {workaroundsCollapsed ? ( - <RiArrowDownSFill - style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }} - /> - ) : ( - <RiArrowUpSFill - style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }} - /> - )} - </ButtonItem> - </div> - </PanelSectionRow> - - {!workaroundsCollapsed && ( - <> - <PanelSectionRow> - <ToggleField - label={t('CONFIG_ENABLE_WSI', 'Enable WSI')} - description={t('CONFIG_ENABLE_WSI_DESC', 'Re-Enable Gamescope WSI Layer. Requires game restart to apply.')} - checked={config.enable_wsi} - onChange={(value) => onConfigChange(ENABLE_WSI, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_ENABLE_WOW64', 'Enable WOW64 for 32-bit games')} - description={t('CONFIG_ENABLE_WOW64_DESC', 'Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)')} - checked={config.enable_wow64} - onChange={(value) => onConfigChange('enable_wow64', value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_DISABLE_STEAMDECK_MODE', 'Disable Steam Deck Mode')} - description={t('CONFIG_DISABLE_STEAMDECK_MODE_DESC', 'Disables Steam Deck mode (Unlocks hidden settings in some games)')} - checked={config.disable_steamdeck_mode} - onChange={(value) => onConfigChange(DISABLE_STEAMDECK_MODE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_MANGOHUD_WORKAROUND', 'MangoHud Workaround')} - description={t('CONFIG_MANGOHUD_WORKAROUND_DESC', 'Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode')} - checked={config.mangohud_workaround} - onChange={(value) => onConfigChange(MANGOHUD_WORKAROUND, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_DISABLE_VKBASALT', 'Disable vkBasalt')} - description={t('CONFIG_DISABLE_VKBASALT_DESC', 'Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)')} - checked={config.disable_vkbasalt} - disabled={config.force_enable_vkbasalt} - onChange={(value) => { - if (value && config.force_enable_vkbasalt) { - // Turn off force enable when enabling disable - onConfigChange(FORCE_ENABLE_VKBASALT, false); - } - onConfigChange(DISABLE_VKBASALT, value); - }} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_FORCE_ENABLE_VKBASALT', 'Force Enable vkBasalt')} - description={t('CONFIG_FORCE_ENABLE_VKBASALT_DESC', 'Force vkBasalt to engage to fix framepacing issues in gamemode')} - checked={config.force_enable_vkbasalt} - disabled={config.disable_vkbasalt} - onChange={(value) => { - if (value && config.disable_vkbasalt) { - // Turn off disable when enabling force enable - onConfigChange(DISABLE_VKBASALT, false); - } - onConfigChange(FORCE_ENABLE_VKBASALT, value); - }} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_ENABLE_ZINK', 'Enable Zink for OpenGL Games')} - description={t('CONFIG_ENABLE_ZINK_DESC', 'Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)')} - checked={config.enable_zink} - onChange={(value) => onConfigChange(ENABLE_ZINK, value)} - /> - </PanelSectionRow> - </> - )} - </> - ); +export function ConfigurationSection({ config, onConfigChange }: ConfigurationSectionProps) { + return <> + <PanelSectionRow> + <SliderField label={`Flow Scale (${Math.round(config.flow_scale * 100)}%)`} value={config.flow_scale} min={0.25} max={1} step={0.01} onChange={(value) => onConfigChange(FLOW_SCALE, value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="Performance Mode" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="Present Mode Override" checked={config.override_present_mode} onChange={(value) => onConfigChange(OVERRIDE_PRESENT_MODE, value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="Preserve Swapchain Image Count" checked={config.preserve_swapchain_image_count} onChange={(value) => onConfigChange(PRESERVE_SWAPCHAIN_IMAGE_COUNT, value)} /> + </PanelSectionRow> + </>; } diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx new file mode 100644 index 0000000..37555e0 --- /dev/null +++ b/src/components/ConfigurationTab.tsx @@ -0,0 +1,204 @@ +import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FaArrowLeft } from "react-icons/fa"; +import { ConfigurationData } from "../config/configSchema"; +import type { GameTarget, KnownGameSource } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; +import { GameConfigurationControls } from "./GameConfigurationControls"; +import { GameConfigurationSelector } from "./GameConfigurationSelector"; +import { ProfileDetails } from "./ProfileDetails"; + +interface ConfigurationTabProps { + title: string; + source: KnownGameSource; + config: ConfigurationData; + targets: GameTarget[]; + runningGame: GameTarget | null; + onSelect: (appid: string) => void; + onConfigChange: ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + cleanupLaunchOptions?: boolean, + ) => Promise<void>; + onEnable: (appid: string) => Promise<boolean>; + onEnableAll: (source: KnownGameSource) => Promise<void>; + bulkOperationBusy: boolean; + onRepair: (appid: string) => Promise<boolean>; + onReset: () => Promise<void>; + onResetAll: (source: KnownGameSource) => Promise<void>; +} + +export function ConfigurationTab({ + title, + source, + config, + targets, + runningGame, + onSelect, + onConfigChange, + onEnable, + onEnableAll, + bulkOperationBusy, + onRepair, + onReset, + onResetAll, +}: ConfigurationTabProps) { + const [detailAppId, setDetailAppId] = useState<string | null>(null); + const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false); + const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "fps" | null>(null); + const [focusConfiguredToggle, setFocusConfiguredToggle] = useState(false); + const enableRef = useRef<HTMLDivElement>(null); + const closeDetails = useCallback(() => { + setFocusFpsMultiplier(false); + setFocusDetailAction(null); + setDetailAppId(null); + }, []); + const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []); + const clearConfiguredToggleFocusRequest = useCallback(() => setFocusConfiguredToggle(false), []); + + useEffect(() => { + if (!focusDetailAction) return; + if (focusDetailAction === "fps") { + setFocusFpsMultiplier(true); + setFocusDetailAction(null); + return; + } + const frame = requestAnimationFrame(() => { + enableRef.current?.querySelector<HTMLElement>('[role="button"]')?.focus(); + setFocusDetailAction(null); + }); + return () => cancelAnimationFrame(frame); + }, [focusDetailAction]); + + const selectedTarget = detailAppId ? targets.find((target) => target.appid === detailAppId) : null; + + if (detailAppId === null) { + return ( + <> + <PanelSection title={title}> + <GameConfigurationSelector + targets={targets} + runningGame={runningGame} + source={source} + bulkOperationBusy={bulkOperationBusy} + onSelect={(appid) => { + setFocusConfiguredToggle(false); + setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); + onSelect(appid); + setDetailAppId(appid); + }} + onEnableAll={onEnableAll} + onResetAll={onResetAll} + focusConfiguredToggle={focusConfiguredToggle} + onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} + /> + </PanelSection> + </> + ); + } + + const profileLabel = selectedTarget?.name || "Game profile"; + const profileTransport = selectedTarget ? sourceLabel(selectedTarget.source) : sourceLabel(source); + const profileDescription = selectedTarget + ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}${selectedTarget.source === "unknown" ? " · Bulk actions exclude this profile" : ""}` + : "Game is no longer available"; + const enableProfile = async (appid: string, quitRunningGame = false) => { + if (!(await onEnable(appid))) return; + if (quitRunningGame) SteamClient.Apps.TerminateApp(appid, false); + setFocusFpsMultiplier(true); + }; + const handleProfileAction = async () => { + if (selectedTarget?.configured) { + await onReset(); + setFocusConfiguredToggle(true); + closeDetails(); + } else if (detailAppId) { + const isRunningUnconfigured = runningGame?.appid === detailAppId + && runningGame.source === "steam" + && selectedTarget?.source === "steam" + && !runningGame.configured; + if (isRunningUnconfigured) { + showModal( + <ConfirmModal + strTitle="Game is running" + strDescription="Quit the game now so LSFG-VK is used on its next launch?" + strOKButtonText="Quit and enable" + strCancelButtonText="Enable without quitting" + onOK={() => void enableProfile(detailAppId, true)} + onCancel={() => void enableProfile(detailAppId)} + />, + ); + } else { + await enableProfile(detailAppId); + } + } + }; + + return ( + <Focusable onCancelButton={closeDetails}> + <PanelSection> + <PanelSectionRow> + <div style={{ display: "flex", alignItems: "center", width: "100%" }}> + <Focusable noFocusRing style={{ flex: "none" }}> + <DialogButton + aria-label={`Back to ${title}`} + onClick={closeDetails} + style={{ + width: "48px", + minWidth: "48px", + height: "24px", + minHeight: "24px", + padding: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <FaArrowLeft /> + </DialogButton> + </Focusable> + <div + className={gamepadDialogClasses.FieldLabel} + style={{ flex: 1, minWidth: 0, marginLeft: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} + > + {profileLabel} + </div> + </div> + </PanelSectionRow> + </PanelSection> + <PanelSection> + {!selectedTarget?.configured && selectedTarget && ( + <PanelSectionRow> + {selectedTarget.source === "unknown" ? ( + <Field + label="Target source unavailable" + description="Refresh Steam and try again before enabling this target." + /> + ) : ( + <Focusable ref={enableRef} noFocusRing> + <ButtonItem layout="below" onClick={handleProfileAction}>Enable for next launch</ButtonItem> + </Focusable> + )} + </PanelSectionRow> + )} + </PanelSection> + {selectedTarget?.configured && ( + <GameConfigurationControls + config={config} + onConfigChange={(field, value) => onConfigChange(field, value, selectedTarget?.source !== "unknown")} + autoFocusFpsMultiplier={focusFpsMultiplier} + onFpsMultiplierFocused={clearFpsFocusRequest} + showWorkarounds={selectedTarget.source !== "unknown"} + workaroundTarget={selectedTarget.source !== "unknown" ? selectedTarget : undefined} + onRepairWorkaround={selectedTarget.source !== "unknown" ? () => onRepair(selectedTarget.appid) : undefined} + /> + )} + {selectedTarget?.configured && ( + <PanelSectionRow> + <ButtonItem layout="below" onClick={handleProfileAction}>Remove profile</ButtonItem> + </PanelSectionRow> + )} + <ProfileDetails description={profileDescription} /> + </Focusable> + ); +} diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 5988e79..470db95 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,187 +1,279 @@ -import { useEffect } from "react"; -import { PanelSection, showModal, ButtonItem, PanelSectionRow } from "@decky/ui"; -import { useInstallationStatus, useDllDetection, useLsfgConfig } from "../hooks/useLsfgHooks"; -import { useProfileManagement } from "../hooks/useProfileManagement"; -import { useInstallationActions } from "../hooks/useInstallationActions"; -import { StatusDisplay } from "./StatusDisplay"; -import { InstallationButton } from "./InstallationButton"; -import { ConfigurationSection } from "./ConfigurationSection"; -import { ProfileManagement } from "./ProfileManagement"; -import { UsageInstructions } from "./UsageInstructions"; -import { SmartClipboardButton } from "./SmartClipboardButton"; -import { FgmodClipboardButton } from "./FgmodClipboardButton"; -import { FpsMultiplierControl } from "./FpsMultiplierControl"; -import { NerdStuffModal } from "./NerdStuffModal"; -import { FlatpaksModal } from "./FlatpaksModal"; +import { Tabs } from "@decky/ui"; +import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react"; +import { FaCube, FaExternalLinkAlt, FaFileAlt, FaGamepad, FaSteam, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; -import t from '../i18n/i18n'; +import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; +import { useGameConfiguration } from "../hooks/useGameConfiguration"; +import { useInstallation } from "../hooks/useLsfgHooks"; +import { tabStyles } from "../styles"; +import { targetsForSource } from "../utils/gameTargets"; +import { resolveNowPlayingTarget, type NowPlayingTarget } from "../utils/nowPlaying"; +import { ConfigFileTab } from "./ConfigFileTab"; +import { ConfigurationTab } from "./ConfigurationTab"; +import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab"; +import { FlatpakTab } from "./FlatpakTab"; +import { NowPlayingTab } from "./NowPlayingTab"; +import { SettingsTab } from "./SettingsTab"; -export function Content() { - const { - isInstalled, - installationStatus, - setIsInstalled, - setInstallationStatus - } = useInstallationStatus(); +const tabIcons = { + nowPlaying: <FaGamepad size={18} />, + steam: <FaSteam size={18} />, + nonSteam: <FaExternalLinkAlt size={18} />, + flatpak: <FaCube size={18} />, + configFile: <FaFileAlt size={18} />, + settings: <FaTools size={18} />, +}; + +const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1"; +type GameTabId = "Steam" | "NonSteam" | "Flatpak"; + +function tabForNowPlaying(target: NowPlayingTarget | null): GameTabId { + if (!target) return "Steam"; + if (target.kind === "flatpak") return target.launcher?.source === "nonSteam" ? "NonSteam" : "Flatpak"; + return target.game.source === "nonSteam" ? "NonSteam" : "Steam"; +} + +function usePersistentBoolean(key: string, defaultValue: boolean) { + const [value, setValue] = useState(() => { + try { + const stored = localStorage.getItem(key); + return stored === null ? defaultValue : stored === "true"; + } catch { + return defaultValue; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(value)); + } catch {} + }, [key, value]); - const { dllDetected, dllDetectionStatus } = useDllDetection(); + return [value, setValue] as const; +} +export function Content() { const { config, - loadLsfgConfig, - updateField - } = useLsfgConfig(); - + runningConfig, + globalConfig, + targets, + runningGame, + setSelectedAppId, + save, + saveFor, + updateGlobal, + enable, + enableAll, + bulkOperationBusy, + repair, + resetSelected, + resetAll, + cleanupAllWorkarounds, + reload, + } = useGameConfiguration(); const { - currentProfile, - updateProfileConfig, - loadProfiles - } = useProfileManagement(); + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + install, + uninstall, + } = useInstallation(reload, cleanupAllWorkarounds); + const setupComplete = + isInstalled && + losslessScalingInstalled && + steamBranchStatus?.success === true && + steamBranchStatus.installed && + !steamBranchStatus.needs_switch; + const flatpak = useFlatpakConfiguration(setupComplete); + const [tab, setTab] = useState("Settings"); + const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, false); + const [contentFocused, setContentFocused] = useState(false); + const previousRunningWorkload = useRef<string | null>(null); + const previousNowPlayingTab = useRef<GameTabId>("Steam"); + const runningFlatpak = flatpak.runningApp; + const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak); + const hasNowPlaying = Boolean(nowPlayingTarget); + const runningWorkload = nowPlayingTarget + ? nowPlayingTarget.kind === "flatpak" + ? `flatpak:${nowPlayingTarget.app.app_id}:${nowPlayingTarget.launcher?.appid ?? ""}` + : `${nowPlayingTarget.game.source}:${nowPlayingTarget.game.appid}` + : null; + const steamTargets = targetsForSource(targets, "steam"); + const nonSteamTargets = targetsForSource(targets, "nonSteam"); - const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions(); + useEffect(() => { + if (!setupComplete) { + setTab("Settings"); + return; + } + setTab((current) => current === "Settings" ? (hasNowPlaying ? "NowPlaying" : "Steam") : current); + }, [hasNowPlaying, setupComplete]); useEffect(() => { - if (isInstalled) { - loadLsfgConfig(); + if (!setupComplete) return; + const previous = previousRunningWorkload.current; + previousRunningWorkload.current = runningWorkload; + if (runningWorkload && runningWorkload !== previous) { + previousNowPlayingTab.current = tabForNowPlaying(nowPlayingTarget); + setTab("NowPlaying"); } - }, [isInstalled, loadLsfgConfig]); - - const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string) => { - if (currentProfile) { - const newConfig = { ...config, [fieldName]: value }; - const result = await updateProfileConfig(currentProfile, newConfig); - if (result.success) { - await loadLsfgConfig(); - } - } else { - await updateField(fieldName, value); + else if (!runningWorkload && previous) { + setTab((current) => current === "NowPlaying" ? previousNowPlayingTab.current : current); } - }; + }, [runningWorkload, setupComplete]); - const onInstall = () => { - handleInstall(setIsInstalled, setInstallationStatus, loadLsfgConfig); - }; + useEffect(() => { + if (isInstalled) { + void reload(); + void flatpak.reload(); + } + }, [isInstalled, reload, flatpak.reload]); - const onUninstall = () => { - handleUninstall(setIsInstalled, setInstallationStatus); - }; + useEffect(() => { + if (!showDebugTab && tab === "ConfigFile") setTab(setupComplete ? "Steam" : "Settings"); + }, [setupComplete, showDebugTab, tab]); - const handleShowNerdStuff = () => { - showModal(<NerdStuffModal />); + const handleConfigChange = async ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + cleanupLaunchOptions = false, + ) => { + await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); }; - const handleShowFlatpaks = () => { - showModal(<FlatpaksModal />); + const settings = ( + <SettingsTab + isInstalled={isInstalled} + installationStatus={installationStatus} + losslessScalingInstalled={losslessScalingInstalled} + losslessScalingStatus={losslessScalingStatus} + steamBranchStatus={steamBranchStatus} + isInstalling={isInstalling} + isUninstalling={isUninstalling} + globalConfig={globalConfig} + showDebugTab={showDebugTab} + onGlobalConfigChange={updateGlobal} + onShowDebugTabChange={setShowDebugTab} + onInstall={() => void install()} + onUninstall={() => void uninstall()} + /> + ); + + const tabContent = (content: ReactNode) => ( + <div className="lsfg-vk-tab-content">{content}</div> + ); + + const nowPlaying = nowPlayingTarget?.kind === "flatpak" ? ( + <FlatpakNowPlayingTab + app={nowPlayingTarget.app} + launcher={nowPlayingTarget.launcher} + onConfigChange={flatpak.updateConfig} + /> + ) : nowPlayingTarget ? ( + <NowPlayingTab + game={nowPlayingTarget.game} + config={runningConfig} + onConfigChange={async (field, value) => { + await saveFor(nowPlayingTarget.game.appid, { ...runningConfig, [field]: value }, true); + }} + /> + ) : null; + + const tabs = setupComplete + ? [ + ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: tabContent(nowPlaying) }] : []), + { + id: "Steam", + title: tabIcons.steam, + content: tabContent( + <ConfigurationTab + title="Steam games" + source="steam" + config={config} + targets={steamTargets} + runningGame={runningGame} + onSelect={setSelectedAppId} + onConfigChange={handleConfigChange} + onEnable={enable} + onEnableAll={enableAll} + bulkOperationBusy={bulkOperationBusy} + onRepair={repair} + onReset={resetSelected} + onResetAll={resetAll} + />, + ), + }, + { + id: "NonSteam", + title: tabIcons.nonSteam, + content: tabContent( + <ConfigurationTab + title="Non-Steam games" + source="nonSteam" + config={config} + targets={nonSteamTargets} + runningGame={runningGame} + onSelect={setSelectedAppId} + onConfigChange={handleConfigChange} + onEnable={enable} + onEnableAll={enableAll} + bulkOperationBusy={bulkOperationBusy} + onRepair={repair} + onReset={resetSelected} + onResetAll={resetAll} + /> + ), + }, + { + id: "Flatpak", + title: tabIcons.flatpak, + content: tabContent( + <FlatpakTab + apps={flatpak.apps} + runningApp={runningFlatpak} + loading={flatpak.loading} + busyAppId={flatpak.busyAppId} + onRefresh={flatpak.reload} + onEnable={flatpak.enableApp} + onEnableAll={flatpak.enableAll} + onRemove={flatpak.removeApp} + onRemoveAll={flatpak.removeAll} + onConfigChange={flatpak.updateConfig} + onWorkaroundChange={flatpak.updateWorkarounds} + />, + ), + }, + ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: tabContent(<ConfigFileTab />) }] : []), + { id: "Settings", title: tabIcons.settings, content: tabContent(settings) }, + ] + : [{ id: "Settings", title: tabIcons.settings, content: tabContent(settings) }]; + + const availableTabIds = new Set(tabs.map(({ id }) => id)); + const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Steam" : "Settings"; + const handleFocusCapture = (event: FocusEvent<HTMLDivElement>) => { + const focusedElement = event.target as HTMLElement | null; + setContentFocused(!focusedElement?.closest?.('[role="tab"]')); }; return ( - <PanelSection> - {!isInstalled && ( - <> - <InstallationButton - isInstalled={isInstalled} - isInstalling={isInstalling} - isUninstalling={isUninstalling} - onInstall={onInstall} - onUninstall={onUninstall} - /> - - <StatusDisplay - dllDetected={dllDetected} - dllDetectionStatus={dllDetectionStatus} - isInstalled={isInstalled} - installationStatus={installationStatus} - /> - </> - )} - - {isInstalled && ( - <> - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('CONTENT_FPS_MULTIPLIER', 'FPS Multiplier')} - </div> - </PanelSectionRow> - - <FpsMultiplierControl - config={config} - onConfigChange={handleConfigChange} - /> - </> - )} - - {isInstalled && ( - <ProfileManagement - currentProfile={currentProfile} - onProfileChange={async () => { - await loadProfiles(); - await loadLsfgConfig(); - }} - /> - )} - - {isInstalled && ( - <ConfigurationSection - config={config} - onConfigChange={handleConfigChange} - /> - )} - - {isInstalled && ( - <> - <SmartClipboardButton /> - <FgmodClipboardButton /> - </> - )} - - <UsageInstructions /> - - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={handleShowNerdStuff} - > - {t('CONTENT_NERD_STUFF', 'Nerd Stuff')} - </ButtonItem> - </PanelSectionRow> - - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={handleShowFlatpaks} - > - {t('CONTENT_FLATPAK_SETUP', 'Flatpak Setup')} - </ButtonItem> - </PanelSectionRow> - - {isInstalled && ( - <> - <StatusDisplay - dllDetected={dllDetected} - dllDetectionStatus={dllDetectionStatus} - isInstalled={isInstalled} - installationStatus={installationStatus} - /> - - <InstallationButton - isInstalled={isInstalled} - isInstalling={isInstalling} - isUninstalling={isUninstalling} - onInstall={onInstall} - onUninstall={onUninstall} - /> - </> - )} - </PanelSection> + <div + className={`lsfg-vk-tabs${contentFocused ? " lsfg-vk-tabs--content-focused" : ""}`} + style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }} + onFocusCapture={handleFocusCapture} + > + <style>{tabStyles}</style> + <Tabs + activeTab={activeTab} + onShowTab={(nextTab: string) => { + if (availableTabIds.has(nextTab)) setTab(nextTab); + }} + tabs={tabs} + /> + </div> ); } diff --git a/src/components/FgmodClipboardButton.tsx b/src/components/FgmodClipboardButton.tsx deleted file mode 100644 index efc5490..0000000 --- a/src/components/FgmodClipboardButton.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useState, useEffect } from "react"; -import { PanelSectionRow, ButtonItem } from "@decky/ui"; -import { FaClipboard, FaCheck } from "react-icons/fa"; -import { checkFgmodDirectory } from "../api/lsfgApi"; -import { showClipboardErrorToast } from "../utils/toastUtils"; -import { copyWithVerification } from "../utils/clipboardUtils"; -import t from '../i18n/i18n'; - -export function FgmodClipboardButton() { - const [isLoading, setIsLoading] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); - const [fgmodExists, setFgmodExists] = useState(false); - const [checkingFgmod, setCheckingFgmod] = useState(true); - - // Check for fgmod directory on component mount - useEffect(() => { - const checkFgmod = async () => { - try { - const result = await checkFgmodDirectory(); - setFgmodExists(result.exists); - } catch (error) { - console.error("Error checking fgmod directory:", error); - setFgmodExists(false); - } finally { - setCheckingFgmod(false); - } - }; - - checkFgmod(); - }, []); - - // Reset success state after 3 seconds - useEffect(() => { - if (showSuccess) { - const timer = setTimeout(() => { - setShowSuccess(false); - }, 3000); - return () => clearTimeout(timer); - } - return undefined; - }, [showSuccess]); - - const copyToClipboard = async () => { - if (isLoading || showSuccess) return; - - setIsLoading(true); - try { - const text = "~/fgmod/fgmod ~/lsfg %command%"; - const { success, verified } = await copyWithVerification(text); - - if (success) { - // Show success feedback in the button instead of toast - setShowSuccess(true); - if (!verified) { - // Copy worked but verification failed - still show success - console.log('Copy verification failed but copy likely worked'); - } - } else { - showClipboardErrorToast(); - } - } catch (error) { - showClipboardErrorToast(); - } finally { - setIsLoading(false); - } - }; - - // Don't render if fgmod directory doesn't exist or we're still checking - if (checkingFgmod || !fgmodExists) { - return null; - } - - return ( - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={copyToClipboard} - disabled={isLoading || showSuccess} - > - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - {showSuccess ? ( - <FaCheck style={{ - color: "#4CAF50" // Green color for success - }} /> - ) : isLoading ? ( - <FaClipboard style={{ - animation: "pulse 1s ease-in-out infinite", - opacity: 0.7 - }} /> - ) : ( - <FaClipboard /> - )} - <div style={{ - color: showSuccess ? "#4CAF50" : "inherit", - fontWeight: showSuccess ? "bold" : "normal" - }}> - {showSuccess ? t('CLIPBOARD_COPIED', 'Copied to clipboard') : isLoading ? t('CLIPBOARD_COPYING', 'Copying...') : t('CLIPBOARD_LSFG_FGMOD', 'LSFG + DeckyFG')} - </div> - </div> - </ButtonItem> - <style>{` - @keyframes pulse { - 0% { opacity: 0.7; } - 50% { opacity: 1; } - 100% { opacity: 0.7; } - } - `}</style> - </PanelSectionRow> - ); -} diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx new file mode 100644 index 0000000..449e4b5 --- /dev/null +++ b/src/components/FlatpakNowPlayingTab.tsx @@ -0,0 +1,38 @@ +import { Focusable } from "@decky/ui"; +import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi"; +import type { GameTarget } from "../utils/gameTargets"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { NowPlayingSummary } from "./NowPlayingSummary"; + +interface Props { + app: FlatpakApp; + launcher: GameTarget | null; + onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>; +} + +export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) { + if (!app.config) return null; + const changeConfig = async ( + field: keyof LsfgConfig, + value: boolean | number | string | string[], + ) => { + await onConfigChange(app.app_id, { ...app.config!, [field]: value }); + }; + + return ( + <Focusable> + <NowPlayingSummary + title={launcher?.name || app.app_name} + details={[ + launcher ? (launcher.source === "nonSteam" ? "Steam shortcut" : "Steam") : "Flatpak", + launcher && launcher.name !== app.app_name ? `Running in ${app.app_name}` : null, + launcher ? "Flatpak" : null, + `Controls: ${app.app_name} profile`, + ].filter((detail): detail is string => detail !== null)} + /> + <FpsMultiplierControl config={app.config} onConfigChange={changeConfig} /> + <ConfigurationSection config={app.config} onConfigChange={changeConfig} /> + </Focusable> + ); +} diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx new file mode 100644 index 0000000..35721f6 --- /dev/null +++ b/src/components/FlatpakTab.tsx @@ -0,0 +1,247 @@ +import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +import { useCallback, useMemo, useState } from "react"; +import { FaArrowLeft } from "react-icons/fa"; +import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { ProfileDetails } from "./ProfileDetails"; + +interface Props { + apps: FlatpakApp[]; + runningApp: FlatpakApp | null; + loading: boolean; + busyAppId: string; + onRefresh: () => Promise<void>; + onEnable: (appId: string) => Promise<boolean>; + onEnableAll: () => Promise<void>; + onRemove: (appId: string) => Promise<boolean>; + onRemoveAll: () => Promise<void>; + onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>; + onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>; +} + +const ENABLED_COLLAPSED_KEY = "lsfg-flatpak-enabled-collapsed-v2"; +const AVAILABLE_COLLAPSED_KEY = "lsfg-flatpak-available-collapsed-v2"; + +export function FlatpakTab({ + apps, + runningApp, + loading, + busyAppId, + onRefresh, + onEnable, + onEnableAll, + onRemove, + onRemoveAll, + onConfigChange, + onWorkaroundChange, +}: Props) { + const [selectedAppId, setSelectedAppId] = useState<string | null>(null); + const selected = useMemo( + () => selectedAppId ? apps.find((app) => app.app_id === selectedAppId) || null : null, + [apps, selectedAppId], + ); + const close = useCallback(() => setSelectedAppId(null), []); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); + + const enabledApps = useMemo( + () => apps.filter((app) => app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), + [apps], + ); + const availableApps = useMemo( + () => apps.filter((app) => !app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), + [apps], + ); + const enableableApps = useMemo( + () => availableApps.filter((app) => !(app.prepared && !app.owned) && !app.error), + [availableApps], + ); + const confirmEnableAll = () => { + showModal( + <ConfirmModal + strTitle="Enable all available Flatpaks?" + strDescription="Create individual LSFG-VK profiles for every Flatpak app this plugin can manage. Apps prepared externally or unavailable will be skipped." + strOKButtonText="Enable all" + strCancelButtonText="Cancel" + onOK={() => void onEnableAll()} + onCancel={() => {}} + />, + ); + }; + const confirmRemoveAll = () => { + showModal( + <ConfirmModal + strTitle="Remove all Flatpak profiles?" + strOKButtonText="Remove all" + strCancelButtonText="Cancel" + onOK={() => void onRemoveAll()} + onCancel={() => {}} + />, + ); + }; + const itemFor = (app: FlatpakApp) => ({ + id: app.app_id, + label: app.app_name, + description: `${app.app_id} · ${app.prepared && !app.owned ? "Prepared externally" : "Available"}`, + }); + + if (!selectedAppId) { + return ( + <PanelSection title="Flatpak"> + <style>{collapsibleItemGroupStyles}</style> + <CollapsibleItemGroup + title="Enabled" + items={enabledApps.map((app) => ({ + id: app.app_id, + label: app.app_name, + description: `${app.app_id}${app.app_id === runningApp?.app_id ? " · Running" : ""}`, + }))} + collapsed={enabledCollapsed} + onToggle={toggleEnabled} + onSelect={setSelectedAppId} + /> + <CollapsibleItemGroup + title="Available" + items={availableApps.map(itemFor)} + collapsed={availableCollapsed} + onToggle={toggleAvailable} + onSelect={setSelectedAppId} + /> + {enableableApps.length > 0 && ( + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={loading || Boolean(busyAppId)} + onClick={confirmEnableAll} + > + Enable all available Flatpaks + </ButtonItem> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={loading || Boolean(busyAppId) || enabledApps.length === 0} + onClick={confirmRemoveAll} + > + Remove all profiles + </ButtonItem> + </PanelSectionRow> + {apps.length === 0 && !loading && ( + <PanelSectionRow> + <Field label="No Flatpak applications found" /> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem layout="below" disabled={loading || Boolean(busyAppId)} onClick={() => void onRefresh()}> + {loading ? "Refreshing..." : "Refresh Flatpaks"} + </ButtonItem> + </PanelSectionRow> + </PanelSection> + ); + } + + if (!selected) { + return ( + <PanelSection title="Flatpak"> + <PanelSectionRow> + <ButtonItem layout="below" onClick={close}>Back</ButtonItem> + </PanelSectionRow> + <PanelSectionRow> + <Field label="Flatpak application is no longer installed" /> + </PanelSectionRow> + </PanelSection> + ); + } + + const busy = busyAppId === selected.app_id; + const config = selected.config; + const external = selected.prepared && !selected.owned; + const profileDescription = [ + selected.app_id, + selected.runtime_branch ? `runtime ${selected.runtime_branch}` : null, + selected.enabled ? `profile ${selected.profile}` : null, + selected.app_id === runningApp?.app_id ? "Running" : null, + ].filter(Boolean).join(" · "); + + const changeConfig = async ( + field: keyof LsfgConfig, + value: boolean | number | string | string[], + ) => { + if (!config) return; + await onConfigChange(selected.app_id, { ...config, [field]: value }); + }; + + return ( + <Focusable onCancelButton={close}> + <PanelSection> + <PanelSectionRow> + <div style={{ display: "flex", alignItems: "center", width: "100%" }}> + <Focusable noFocusRing style={{ flex: "none" }}> + <DialogButton + aria-label="Back to Flatpaks" + onClick={close} + style={{ + width: "48px", + minWidth: "48px", + height: "24px", + minHeight: "24px", + padding: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <FaArrowLeft /> + </DialogButton> + </Focusable> + <div + className={gamepadDialogClasses.FieldLabel} + style={{ flex: 1, minWidth: 0, marginLeft: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} + > + {selected.app_name} + </div> + </div> + </PanelSectionRow> + </PanelSection> + {!selected.enabled && ( + <PanelSection> + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={busy || external || Boolean(selected.error)} + onClick={() => void onEnable(selected.app_id)} + > + {busy ? "Enabling..." : external ? "Prepared externally" : "Enable LSFG-VK"} + </ButtonItem> + </PanelSectionRow> + {selected.error && ( + <PanelSectionRow> + <Field label="Unavailable" description={selected.error} /> + </PanelSectionRow> + )} + </PanelSection> + )} + {selected.enabled && config && ( + <> + <FpsMultiplierControl config={config} onConfigChange={changeConfig} /> + <ConfigurationSection config={config} onConfigChange={changeConfig} /> + <FlatpakWorkaroundsSection + state={selected.workarounds} + disabled={busy} + onChange={(state) => onWorkaroundChange(selected.app_id, state)} + /> + <PanelSectionRow> + <ButtonItem layout="below" disabled={busy} onClick={() => void onRemove(selected.app_id)}> + {busy ? "Removing..." : "Remove Flatpak profile"} + </ButtonItem> + </PanelSectionRow> + </> + )} + <ProfileDetails description={profileDescription} /> + </Focusable> + ); +} diff --git a/src/components/FlatpakWorkaroundsSection.tsx b/src/components/FlatpakWorkaroundsSection.tsx new file mode 100644 index 0000000..7d9d880 --- /dev/null +++ b/src/components/FlatpakWorkaroundsSection.tsx @@ -0,0 +1,144 @@ +import { ButtonItem, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; +import { useEffect, useRef, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import type { WorkaroundState } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +interface Props { + state: WorkaroundState; + disabled?: boolean; + onChange: (state: WorkaroundState) => Promise<boolean>; +} + +const WORKAROUNDS_COLLAPSED_KEY = "lsfg-flatpak-workarounds-collapsed-v1"; + +export function FlatpakWorkaroundsSection({ state, disabled = false, onChange }: Props) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY) !== "false"; + } catch { + return true; + } + }); + const [fpsValue, setFpsValue] = useState(state.dxvkFrameRate); + const timer = useRef<number | null>(null); + + useEffect(() => { + try { + localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, String(collapsed)); + } catch {} + }, [collapsed]); + + useEffect(() => { + setFpsValue(state.dxvkFrameRate); + }, [state.dxvkFrameRate]); + + useEffect(() => () => { + if (timer.current !== null) window.clearTimeout(timer.current); + }, []); + + const update = (field: keyof WorkaroundState, value: boolean | number) => { + void onChange({ ...state, [field]: value }); + }; + + const updateFps = (value: number) => { + setFpsValue(value); + if (timer.current !== null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => { + timer.current = null; + void onChange({ ...state, dxvkFrameRate: value }); + }, 250); + }; + + const fpsLabel = fpsValue > 0 ? `${fpsValue} FPS` : t("CONFIG_BASE_FPS_CAP_OFF", "Off"); + + return ( + <> + <PanelSectionRow> + <div + style={{ + fontSize: "14px", + fontWeight: "bold", + marginTop: "8px", + marginBottom: "6px", + borderBottom: "1px solid rgba(255, 255, 255, 0.2)", + paddingBottom: "3px", + color: "white", + }} + > + {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")} + </div> + </PanelSectionRow> + <PanelSectionRow> + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={() => setCollapsed((value) => !value)} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </PanelSectionRow> + {!collapsed && ( + <> + <PanelSectionRow> + <SliderField + label={`${t("CONFIG_BASE_FPS_CAP", "Base FPS Cap")} (${fpsLabel})`} + description={t("CONFIG_BASE_FPS_CAP_DESC", "Base cap for DXVK-backed games before frame generation; 0 disables. Requires app restart to apply.")} + value={fpsValue} + min={0} + max={60} + step={1} + onChange={updateFps} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_STEAMDECK_MODE", "Disable Steam Deck Mode")} + description={t("CONFIG_DISABLE_STEAMDECK_MODE_DESC", "Disables a game-specific Steam Deck compatibility switch. Requires app restart to apply.")} + checked={state.disableSteamdeckMode} + onChange={(value) => update("disableSteamdeckMode", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_GAMESCOPE_WSI", "Disable Gamescope WSI")} + description={t("CONFIG_DISABLE_GAMESCOPE_WSI_DESC", "Adds ENABLE_GAMESCOPE_WSI=0. Requires app restart to apply.")} + checked={state.disableGamescopeWsi} + onChange={(value) => update("disableGamescopeWsi", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_HDR", "Disable HDR")} + description={t("CONFIG_DISABLE_HDR_DESC", "Prevents DXVK from exposing HDR to the app. Requires app restart to apply.")} + checked={state.disableHdr} + onChange={(value) => update("disableHdr", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_VKBASALT", "Disable vkBasalt")} + description={t("CONFIG_DISABLE_VKBASALT_DESC", "Disables vkBasalt which can conflict with LSFG-VK.")} + checked={state.disableVkbasalt} + onChange={(value) => update("disableVkbasalt", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_ENABLE_ZINK", "Force Zink for OpenGL Games")} + description={t("CONFIG_ENABLE_ZINK_DESC", "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes. Requires app restart to apply.")} + checked={state.enableZink} + onChange={(value) => update("enableZink", value)} + disabled={disabled} + /> + </PanelSectionRow> + </> + )} + </> + ); +} diff --git a/src/components/FlatpaksModal.tsx b/src/components/FlatpaksModal.tsx deleted file mode 100644 index 16d0369..0000000 --- a/src/components/FlatpaksModal.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import { FC, useState, useEffect, CSSProperties } from 'react'; -import { - ModalRoot, - DialogBody, - DialogHeader, - DialogControlsSection, - DialogControlsSectionHeader, - ButtonItem, - PanelSectionRow, - Field, - Toggle, - Spinner, - Focusable, - showModal, - ConfirmModal -} from '@decky/ui'; -import { FaCheck, FaTimes, FaDownload, FaTrash, FaCog } from 'react-icons/fa'; -import flatpakTargetImage from '../../assets/flatpak-target.png'; -import { - checkFlatpakExtensionStatus, - installFlatpakExtension, - uninstallFlatpakExtension, - getFlatpakApps, - setFlatpakAppOverride, - removeFlatpakAppOverride, - FlatpakExtensionStatus, - FlatpakApp, - FlatpakAppInfo -} from '../api/lsfgApi'; -import t from '../i18n/i18n'; - -interface FlatpaksModalProps { - closeModal?: () => void; -} - -export const FlatpaksModal: FC<FlatpaksModalProps> = ({ closeModal }) => { - const [extensionStatus, setExtensionStatus] = useState<FlatpakExtensionStatus | null>(null); - const [flatpakApps, setFlatpakApps] = useState<FlatpakAppInfo | null>(null); - const [loading, setLoading] = useState(true); - const [operationInProgress, setOperationInProgress] = useState<string | null>(null); - - const loadData = async () => { - setLoading(true); - try { - const [statusResult, appsResult] = await Promise.all([ - checkFlatpakExtensionStatus(), - getFlatpakApps() - ]); - - setExtensionStatus(statusResult); - setFlatpakApps(appsResult); - } catch (error) { - console.error('Error loading Flatpak data:', error); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadData(); - }, []); - - const handleExtensionOperation = async (operation: 'install' | 'uninstall', version: string) => { - const operationId = `${operation}-${version}`; - setOperationInProgress(operationId); - - try { - const result = operation === 'install' - ? await installFlatpakExtension(version) - : await uninstallFlatpakExtension(version); - - if (result.success) { - // Reload status after operation - const newStatus = await checkFlatpakExtensionStatus(); - setExtensionStatus(newStatus); - } - } catch (error) { - console.error(`Error ${operation}ing extension:`, error); - } finally { - setOperationInProgress(null); - } - }; - - const handleAppOverrideToggle = async (app: FlatpakApp) => { - const hasOverrides = app.has_filesystem_override && app.has_env_override; - const operationId = `app-${app.app_id}`; - setOperationInProgress(operationId); - - try { - const result = hasOverrides - ? await removeFlatpakAppOverride(app.app_id) - : await setFlatpakAppOverride(app.app_id); - - if (result.success) { - // Reload apps data after operation - const newApps = await getFlatpakApps(); - setFlatpakApps(newApps); - } - } catch (error) { - console.error('Error toggling app override:', error); - } finally { - setOperationInProgress(null); - } - }; - - const confirmOperation = (operation: () => void, title: string, description: string) => { - showModal( - <ConfirmModal - strTitle={title} - strDescription={description} - onOK={operation} - onCancel={() => {}} - /> - ); - }; - - if (loading) { - return ( - <ModalRoot closeModal={closeModal}> - <DialogHeader>{t('FLATPAK_MODAL_TITLE', 'Flatpak Extensions')}</DialogHeader> - <DialogBody> - <div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}> - <Spinner /> - </div> - </DialogBody> - </ModalRoot> - ); - } - - const instructionSteps = [ - { - id: 'try-first', - title: t('FLATPAK_STEP_TRY_FIRST', 'Try first:'), - command: '~/lsfg' - }, - { - id: 'try-full-path', - title: t('FLATPAK_STEP_TRY_FULL_PATH', "If that doesn't work, try full path:"), - command: '/home/(username)/lsfg' - }, - { - id: 'final-result', - title: t('FLATPAK_STEP_FINAL', 'Final result should look like:'), - command: '~/lsfg "usr/bin/flatpak"' - } - ]; - - const focusableInstructionStyle: CSSProperties = { - padding: '10px', - background: 'rgba(0, 0, 0, 0.3)', - borderRadius: '6px', - marginBottom: '12px' - }; - - const commandStyle: CSSProperties = { - fontFamily: 'monospace', - fontSize: '0.85em', - background: 'rgba(0, 0, 0, 0.45)', - padding: '8px', - borderRadius: '4px', - marginTop: '6px' - }; - - return ( - <ModalRoot closeModal={closeModal}> - <DialogHeader>{t('FLATPAK_MODAL_TITLE', 'Flatpak Extensions')}</DialogHeader> - <DialogBody> - <Focusable> - {/* Extension Status Section */} - <DialogControlsSection> - <DialogControlsSectionHeader>{t('FLATPAK_RUNTIME_INSTALLER', 'Runtime Extension Installer')}</DialogControlsSectionHeader> - - {extensionStatus && extensionStatus.success ? ( - <> - {/* 23.08 Runtime */} - <PanelSectionRow> - <Field - label={t('FLATPAK_RUNTIME_23', 'Runtime 23.08')} - description={extensionStatus.installed_23_08 ? t('FLATPAK_INSTALLED', 'Installed') : t('FLATPAK_NOT_INSTALLED', 'Not installed')} - icon={extensionStatus.installed_23_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} - > - <ButtonItem - layout="below" - onClick={() => { - const operation = extensionStatus.installed_23_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '23.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 23.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08'} - > - {operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08' ? ( - <Spinner /> - ) : extensionStatus.installed_23_08 ? ( - <> - <FaTrash /> {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - </> - ) : ( - <> - <FaDownload /> {t('FLATPAK_INSTALL_BTN', 'Install')} - </> - )} - </ButtonItem> - </Field> - </PanelSectionRow> - - {/* 24.08 Runtime */} - <PanelSectionRow> - <Field - label={t('FLATPAK_RUNTIME_24', 'Runtime 24.08')} - description={extensionStatus.installed_24_08 ? t('FLATPAK_INSTALLED', 'Installed') : t('FLATPAK_NOT_INSTALLED', 'Not installed')} - icon={extensionStatus.installed_24_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} - > - <ButtonItem - layout="below" - onClick={() => { - const operation = extensionStatus.installed_24_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '24.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 24.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-24.08' || operationInProgress === 'uninstall-24.08'} - > - {operationInProgress === 'install-24.08' || operationInProgress === 'uninstall-24.08' ? ( - <Spinner /> - ) : extensionStatus.installed_24_08 ? ( - <> - <FaTrash /> {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - </> - ) : ( - <> - <FaDownload /> {t('FLATPAK_INSTALL_BTN', 'Install')} - </> - )} - </ButtonItem> - </Field> - </PanelSectionRow> - - {/* 25.08 Runtime */} - <PanelSectionRow> - <Field - label={t('FLATPAK_RUNTIME_25', 'Runtime 25.08')} - description={extensionStatus.installed_25_08 ? t('FLATPAK_INSTALLED', 'Installed') : t('FLATPAK_NOT_INSTALLED', 'Not installed')} - icon={extensionStatus.installed_25_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} - > - <ButtonItem - layout="below" - onClick={() => { - const operation = extensionStatus.installed_25_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '25.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 25.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-25.08' || operationInProgress === 'uninstall-25.08'} - > - {operationInProgress === 'install-25.08' || operationInProgress === 'uninstall-25.08' ? ( - <Spinner /> - ) : extensionStatus.installed_25_08 ? ( - <> - <FaTrash /> {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - </> - ) : ( - <> - <FaDownload /> {t('FLATPAK_INSTALL_BTN', 'Install')} - </> - )} - </ButtonItem> - </Field> - </PanelSectionRow> - </> - ) : ( - <PanelSectionRow> - <Field - label={t('FLATPAK_ERROR', 'Error')} - description={extensionStatus?.error || t('FLATPAK_ERROR_STATUS', 'Failed to check extension status')} - icon={<FaTimes style={{color: 'red'}} />} - /> - </PanelSectionRow> - )} - </DialogControlsSection> - - {/* Flatpak Apps Section */} - <DialogControlsSection> - <DialogControlsSectionHeader>{t('FLATPAK_APPS_TITLE', 'Flatpak Applications')}</DialogControlsSectionHeader> - - {flatpakApps && flatpakApps.success ? ( - flatpakApps.apps.length > 0 ? ( - flatpakApps.apps.map((app) => { - const hasOverrides = app.has_filesystem_override && app.has_env_override; - const partialOverrides = app.has_filesystem_override || app.has_env_override; - - let statusColor = 'red'; - let statusText = t('FLATPAK_STATUS_NO_OVERRIDES', 'No overrides'); - - if (hasOverrides) { - statusColor = 'green'; - statusText = t('FLATPAK_STATUS_CONFIGURED', 'Configured'); - } else if (partialOverrides) { - statusColor = 'orange'; - statusText = t('FLATPAK_STATUS_PARTIAL', 'Partial'); - } - - return ( - <PanelSectionRow key={app.app_id}> - <Field - label={app.app_name || app.app_id} - description={`${app.app_id} - ${statusText}`} - icon={<FaCog style={{color: statusColor}} />} - > - <Toggle - value={hasOverrides} - onChange={() => handleAppOverrideToggle(app)} - disabled={operationInProgress === `app-${app.app_id}`} - /> - </Field> - </PanelSectionRow> - ); - }) - ) : ( - <PanelSectionRow> - <Field - label={t('FLATPAK_NO_APPS', 'No Flatpak Apps Found')} - description={t('FLATPAK_NO_APPS_DESC', 'No Flatpak applications are currently installed')} - /> - </PanelSectionRow> - ) - ) : ( - <PanelSectionRow> - <Field - label={t('FLATPAK_ERROR', 'Error')} - description={flatpakApps?.error || t('FLATPAK_ERROR_APPS', 'Failed to load Flatpak applications')} - icon={<FaTimes style={{color: 'red'}} />} - /> - </PanelSectionRow> - )} - </DialogControlsSection> - - {/* Steam Configuration Instructions */} - <DialogControlsSection> - <DialogControlsSectionHeader>{t('FLATPAK_STEAM_CONFIG_TITLE', 'Steam Configuration')}</DialogControlsSectionHeader> - <div - style={{ - padding: '12px', - background: 'rgba(255, 255, 255, 0.1)', - borderRadius: '8px', - margin: '8px 0', - display: 'flex', - flexDirection: 'column' - }} - > - <div style={{ fontWeight: 'bold', marginBottom: '8px', color: '#fff' }}> - {t('FLATPAK_STEAM_CONFIG_HEADER', 'Configure Steam Flatpak Shortcuts')} - </div> - <div style={{ fontSize: '0.9em', lineHeight: '1.4', marginBottom: '8px' }}> - {t('FLATPAK_STEAM_CONFIG_DESC', 'In Steam, open your flatpak game and click the cog wheel.')} - </div> - <div style={{ fontSize: '0.9em', lineHeight: '1.4', marginBottom: '12px', color: '#ffa500' }}> - <strong>IMPORTANT:</strong> {t('FLATPAK_STEAM_CONFIG_IMPORTANT', 'Set this in TARGET (NOT LAUNCH OPTIONS)')} - </div> - - {instructionSteps.map((step) => ( - <Focusable - key={step.id} - focusWithinClassName="gpfocuswithin" - onActivate={() => {}} - style={focusableInstructionStyle} - > - <div style={{ fontWeight: 'bold' }}>{step.title}</div> - <div style={commandStyle}>{step.command}</div> - </Focusable> - ))} - - <Focusable - focusWithinClassName="gpfocuswithin" - onActivate={() => {}} - style={{ marginTop: '4px' }} - > - <div style={{ textAlign: 'center' }}> - <img - src={flatpakTargetImage.replace(/ /g, '%20')} - alt="Steam Properties Target Field Example" - style={{ - maxWidth: '100%', - height: 'auto', - border: '1px solid rgba(255, 255, 255, 0.2)', - borderRadius: '4px' - }} - /> - </div> - </Focusable> - </div> - </DialogControlsSection> - - {/* Close Button */} - <DialogControlsSection> - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={closeModal} - > - {t('FLATPAK_CLOSE', 'Close')} - </ButtonItem> - </PanelSectionRow> - </DialogControlsSection> - </Focusable> - </DialogBody> - </ModalRoot> - ); -}; diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 206643e..523e8b3 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,72 +1,62 @@ -import { PanelSectionRow, DialogButton, Focusable } from "@decky/ui"; +import { Focusable, PanelSectionRow, SliderField } from "@decky/ui"; +import { useEffect, useRef } from "react"; import { ConfigurationData } from "../config/configSchema"; import { MULTIPLIER } from "../config/generatedConfigSchema"; -import t from '../i18n/i18n'; +import t from "../i18n/i18n"; interface FpsMultiplierControlProps { config: ConfigurationData; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise<void>; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; + autoFocus?: boolean; + onAutoFocus?: () => void; } export function FpsMultiplierControl({ config, - onConfigChange + onConfigChange, + autoFocus = false, + onAutoFocus, }: FpsMultiplierControlProps) { + const focusableRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!autoFocus) return; + const frame = requestAnimationFrame(() => { + const target = focusableRef.current?.querySelector<HTMLElement>( + '[role="button"], [role="slider"]', + ); + target?.focus(); + onAutoFocus?.(); + }); + return () => cancelAnimationFrame(frame); + }, [autoFocus, onAutoFocus]); + + const multiplierLabel = config.multiplier === 1 + ? t("MULTIPLIER_OFF", "Off") + : `${config.multiplier}x`; + return ( <PanelSectionRow> - <Focusable - style={{ - marginTop: "6px", - marginBottom: "6px", - display: "flex", - justifyContent: "center", - alignItems: "center" - }} - flow-children="horizontal" - > - <DialogButton - style={{ - marginLeft: "0px", - height: "30px", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "5px 0px 0px 0px", - minWidth: "40px", - }} - onClick={() => onConfigChange(MULTIPLIER, Math.max(1, config.multiplier - 1))} - disabled={config.multiplier <= 1} - > - − - </DialogButton> - <div - style={{ - marginLeft: "20px", - marginRight: "20px", - fontSize: "16px", - fontWeight: "bold", - color: config.multiplier > 4 ? "red" : "white", - minWidth: "60px", - textAlign: "center" - }} - > - {config.multiplier < 2 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`} - </div> - <DialogButton - style={{ - marginLeft: "0px", - height: "30px", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "5px 0px 0px 0px", - minWidth: "40px", - }} - onClick={() => onConfigChange(MULTIPLIER, Math.min(4, config.multiplier + 1))} - disabled={config.multiplier >= 4} - > - + - </DialogButton> + <Focusable ref={focusableRef} noFocusRing> + <SliderField + label={`FPS multiplier · ${multiplierLabel}`} + value={config.multiplier} + min={1} + max={6} + step={1} + notchCount={6} + notchLabels={[ + { notchIndex: 0, label: "OFF", value: 1 }, + { notchIndex: 1, label: "2X", value: 2 }, + { notchIndex: 2, label: "3X", value: 3 }, + { notchIndex: 3, label: "4X", value: 4 }, + { notchIndex: 4, label: "5X", value: 5 }, + { notchIndex: 5, label: "6X", value: 6 }, + ]} + notchTicksVisible={true} + showValue={false} + onChange={(value) => void onConfigChange(MULTIPLIER, value)} + /> </Focusable> </PanelSectionRow> ); diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx new file mode 100644 index 0000000..7025f78 --- /dev/null +++ b/src/components/GameConfigurationControls.tsx @@ -0,0 +1,44 @@ +import { ConfigurationData } from "../config/configSchema"; +import type { GameTarget } from "../hooks/useGameConfiguration"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { WorkaroundsSection } from "./WorkaroundsSection"; + +interface Props { + config: ConfigurationData; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; + autoFocusFpsMultiplier?: boolean; + onFpsMultiplierFocused?: () => void; + showWorkarounds?: boolean; + workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">; + onRepairWorkaround?: () => Promise<boolean>; +} + +export function GameConfigurationControls({ + config, + onConfigChange, + autoFocusFpsMultiplier, + onFpsMultiplierFocused, + showWorkarounds = false, + workaroundTarget, + onRepairWorkaround, +}: Props) { + return ( + <> + <FpsMultiplierControl + config={config} + onConfigChange={onConfigChange} + autoFocus={autoFocusFpsMultiplier} + onAutoFocus={onFpsMultiplierFocused} + /> + <ConfigurationSection config={config} onConfigChange={onConfigChange} /> + {showWorkarounds && workaroundTarget && ( + <WorkaroundsSection + appId={workaroundTarget.appid} + nonSteam={workaroundTarget.nonSteam} + onRepair={onRepairWorkaround} + /> + )} + </> + ); +} diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx new file mode 100644 index 0000000..91dc3df --- /dev/null +++ b/src/components/GameConfigurationSelector.tsx @@ -0,0 +1,140 @@ +import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui"; +import { useEffect, useRef } from "react"; +import type { GameTarget, KnownGameSource } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup"; + +interface Props { + targets: GameTarget[]; + runningGame: GameTarget | null; + source: KnownGameSource; + bulkOperationBusy: boolean; + onSelect: (appid: string) => void; + onEnableAll: (source: KnownGameSource) => Promise<void>; + onResetAll: (source: KnownGameSource) => Promise<void>; + focusConfiguredToggle?: boolean; + onConfiguredToggleFocused?: () => void; +} + +const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4"; +const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3"; + +function targetDescription(game: GameTarget): string { + if (game.isFlatpakShortcut) return "Non-Steam | Use Flatpak Tab"; + return game.source === "unknown" + ? "Unknown source · excluded from bulk actions" + : sourceLabel(game.source); +} + +export function GameConfigurationSelector({ + targets, + runningGame, + source, + bulkOperationBusy, + onSelect, + onEnableAll, + onResetAll, + focusConfiguredToggle = false, + onConfiguredToggleFocused, +}: Props) { + const sortGames = (games: GameTarget[]) => [...games].sort((a, b) => { + if (a.appid === runningGame?.appid) return -1; + if (b.appid === runningGame?.appid) return 1; + return a.name.localeCompare(b.name); + }); + const enabledGames = sortGames(targets.filter((game) => game.configured)); + const availableGames = sortGames(targets.filter((game) => !game.configured)); + const enableableGames = availableGames.filter((game) => game.source === source && !game.isFlatpakShortcut); + const removableGames = enabledGames.filter((game) => game.source === source && !game.isFlatpakShortcut); + const sourceName = source === "nonSteam" ? "non-Steam shortcuts" : "Steam games"; + const emptyDescription = source === "nonSteam" + ? "Steam has not reported any eligible non-Steam shortcuts" + : "Steam has not reported any eligible installed games"; + const toItem = (game: GameTarget) => ({ + id: game.appid, + label: game.name, + description: targetDescription(game), + disabled: game.isFlatpakShortcut, + }); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(`${ENABLED_COLLAPSED_KEY}-${source}`); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-${source}`); + const enabledToggleRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!focusConfiguredToggle) return; + const frame = requestAnimationFrame(() => { + enabledToggleRef.current?.querySelector<HTMLElement>('[role="button"], button')?.focus(); + onConfiguredToggleFocused?.(); + }); + return () => cancelAnimationFrame(frame); + }, [enabledGames.length, focusConfiguredToggle, onConfiguredToggleFocused]); + + const confirmResetAll = () => { + showModal( + <ConfirmModal + strTitle={`Remove all ${sourceName} profiles?`} + strOKButtonText="Remove all" + strCancelButtonText="Cancel" + onOK={() => void onResetAll(source)} + onCancel={() => {}} + />, + ); + }; + + const confirmEnableAll = () => { + showModal( + <ConfirmModal + strTitle={`Enable all available ${sourceName}?`} + strDescription={`Create individual LSFG-VK profiles for every available ${sourceName}. Unknown-source profiles are excluded. Flatpak profiles are managed separately in the Flatpak tab.`} + strOKButtonText="Enable all" + strCancelButtonText="Cancel" + onOK={() => void onEnableAll(source)} + onCancel={() => {}} + />, + ); + }; + + return ( + <> + <style> + {collapsibleItemGroupStyles} + </style> + {targets.length === 0 && ( + <PanelSectionRow> + <Field label={`No ${sourceName} found`} description={emptyDescription} /> + </PanelSectionRow> + )} + <CollapsibleItemGroup + title="Enabled" + items={enabledGames.map(toItem)} + collapsed={enabledCollapsed} + onToggle={toggleEnabled} + onSelect={onSelect} + toggleRef={enabledToggleRef} + /> + <CollapsibleItemGroup + title="Available" + items={availableGames.map(toItem)} + collapsed={availableCollapsed} + onToggle={toggleAvailable} + onSelect={onSelect} + /> + {enableableGames.length > 0 && ( + <PanelSectionRow> + <ButtonItem layout="below" onClick={confirmEnableAll} disabled={bulkOperationBusy}> + {`Enable all ${sourceName}`} + </ButtonItem> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={confirmResetAll} + disabled={bulkOperationBusy || removableGames.length === 0} + > + {`Remove all ${sourceLabel(source)} profiles`} + </ButtonItem> + </PanelSectionRow> + </> + ); +} diff --git a/src/components/InstallationButton.tsx b/src/components/InstallationButton.tsx deleted file mode 100644 index 7f0ac96..0000000 --- a/src/components/InstallationButton.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { ButtonItem, PanelSectionRow } from "@decky/ui"; -import { FaDownload, FaTrash } from "react-icons/fa"; -import t from '../i18n/i18n'; - -interface InstallationButtonProps { - isInstalled: boolean; - isInstalling: boolean; - isUninstalling: boolean; - onInstall: () => void; - onUninstall: () => void; -} - -export function InstallationButton({ - isInstalled, - isInstalling, - isUninstalling, - onInstall, - onUninstall -}: InstallationButtonProps) { - const renderButtonContent = () => { - if (isInstalling) { - return ( - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <div>{t('INSTALL_INSTALLING', 'Installing...')}</div> - </div> - ); - } - - if (isUninstalling) { - return ( - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <div>{t('INSTALL_UNINSTALLING', 'Uninstalling...')}</div> - </div> - ); - } - - if (isInstalled) { - return ( - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <FaTrash /> - <div>{t('INSTALL_UNINSTALL_BTN', 'Uninstall LSFG-VK')}</div> - </div> - ); - } - - return ( - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <FaDownload /> - <div>{t('INSTALL_INSTALL_BTN', 'Install LSFG-VK')}</div> - </div> - ); - }; - - return ( - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={isInstalled ? onUninstall : onInstall} - disabled={isInstalling || isUninstalling} - > - {renderButtonContent()} - </ButtonItem> - </PanelSectionRow> - ); -} diff --git a/src/components/NerdStuffModal.tsx b/src/components/NerdStuffModal.tsx deleted file mode 100644 index 8a79b67..0000000 --- a/src/components/NerdStuffModal.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useState, useEffect } from "react"; -import { - ModalRoot, - Field, - Focusable, - DialogControlsSection, - PanelSectionRow, - ButtonItem -} from "@decky/ui"; -import { getDllStats, DllStatsResult, getConfigFileContent, getLaunchScriptContent, FileContentResult } from "../api/lsfgApi"; -import t from '../i18n/i18n'; - -interface NerdStuffModalProps { - closeModal?: () => void; -} - -export function NerdStuffModal({ closeModal }: NerdStuffModalProps) { - const [dllStats, setDllStats] = useState<DllStatsResult | null>(null); - const [configContent, setConfigContent] = useState<FileContentResult | null>(null); - const [scriptContent, setScriptContent] = useState<FileContentResult | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - useEffect(() => { - const loadData = async () => { - try { - setLoading(true); - setError(null); - - // Load all data in parallel - const [dllResult, configResult, scriptResult] = await Promise.all([ - getDllStats(), - getConfigFileContent(), - getLaunchScriptContent() - ]); - - setDllStats(dllResult); - setConfigContent(configResult); - setScriptContent(scriptResult); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load data"); - } finally { - setLoading(false); - } - }; - - loadData(); - }, []); - - const formatSHA256 = (hash: string) => { - // Format SHA256 hash for better readability (add spaces every 8 characters) - return hash.replace(/(.{8})/g, '$1 ').trim(); - }; - - const copyToClipboard = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - // Could add a toast notification here if desired - } catch (err) { - console.error("Failed to copy to clipboard:", err); - } - }; - - return ( - <ModalRoot onCancel={closeModal} onOK={closeModal}> - {loading && ( - <div>{t('NERD_LOADING', 'Loading information...')}</div> - )} - - {error && ( - <div>Error: {error}</div> - )} - - {!loading && !error && ( - <> - {/* DLL Stats Section */} - {dllStats && ( - <> - {!dllStats.success ? ( - <div>{dllStats.error || "Failed to get DLL stats"}</div> - ) : ( - <div> - <Field label={t('NERD_DLL_PATH', 'DLL Path')}> - <Focusable - onClick={() => dllStats.dll_path && copyToClipboard(dllStats.dll_path)} - onActivate={() => dllStats.dll_path && copyToClipboard(dllStats.dll_path)} - > - {dllStats.dll_path || t('NERD_NOT_AVAILABLE', 'Not available')} - </Focusable> - </Field> - - <Field label={t('NERD_DLL_HASH', 'DLL SHA256 Hash')}> - <Focusable - onClick={() => dllStats.dll_sha256 && copyToClipboard(dllStats.dll_sha256)} - onActivate={() => dllStats.dll_sha256 && copyToClipboard(dllStats.dll_sha256)} - > - {dllStats.dll_sha256 ? formatSHA256(dllStats.dll_sha256) : t('NERD_NOT_AVAILABLE', 'Not available')} - </Focusable> - </Field> - - {dllStats.dll_source && ( - <Field label={t('NERD_DETECTION_SOURCE', 'Detection Source')}> - <div>{dllStats.dll_source}</div> - </Field> - )} - </div> - )} - </> - )} - - {/* Launch Script Section */} - {scriptContent && ( - <Field label={t('NERD_LAUNCH_SCRIPT', 'Launch Script')}> - {!scriptContent.success ? ( - <div>{t('NERD_SCRIPT_NOT_FOUND_PREFIX', 'Script not found:')} {scriptContent.error}</div> - ) : ( - <div> - <div style={{ marginBottom: "8px", fontSize: "0.9em", opacity: 0.8 }}> - {t('NERD_PATH_PREFIX', 'Path:')} {scriptContent.path} - </div> - <Focusable - onClick={() => scriptContent.content && copyToClipboard(scriptContent.content)} - onActivate={() => scriptContent.content && copyToClipboard(scriptContent.content)} - > - <pre style={{ - background: "rgba(255, 255, 255, 0.1)", - padding: "8px", - borderRadius: "4px", - fontSize: "0.8em", - whiteSpace: "pre-wrap", - overflow: "auto", - maxHeight: "150px" - }}> - {scriptContent.content || t('NERD_NO_CONTENT', 'No content')} - </pre> - </Focusable> - </div> - )} - </Field> - )} - - {/* Config File Section */} - {configContent && ( - <Field label={t('NERD_CONFIG_FILE', 'Configuration File')}> - {!configContent.success ? ( - <div>{t('NERD_CONFIG_NOT_FOUND_PREFIX', 'Config not found:')} {configContent.error}</div> - ) : ( - <div> - <div style={{ marginBottom: "8px", fontSize: "0.9em", opacity: 0.8 }}> - {t('NERD_PATH_PREFIX', 'Path:')} {configContent.path} - </div> - <Focusable - onClick={() => configContent.content && copyToClipboard(configContent.content)} - onActivate={() => configContent.content && copyToClipboard(configContent.content)} - > - <pre style={{ - background: "rgba(255, 255, 255, 0.1)", - padding: "8px", - borderRadius: "4px", - fontSize: "0.8em", - whiteSpace: "pre-wrap", - overflow: "auto" - }}> - {configContent.content || t('NERD_NO_CONTENT', 'No content')} - </pre> - </Focusable> - </div> - )} - </Field> - )} - - {/* Close Button */} - <DialogControlsSection> - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={closeModal} - > - {t('NERD_CLOSE', 'Close')} - </ButtonItem> - </PanelSectionRow> - </DialogControlsSection> - </> - )} - </ModalRoot> - ); -} diff --git a/src/components/NowPlayingSummary.tsx b/src/components/NowPlayingSummary.tsx new file mode 100644 index 0000000..adc5d10 --- /dev/null +++ b/src/components/NowPlayingSummary.tsx @@ -0,0 +1,16 @@ +import { Field, PanelSection, PanelSectionRow } from "@decky/ui"; + +interface Props { + title: string; + details: string[]; +} + +export function NowPlayingSummary({ title, details }: Props) { + return ( + <PanelSection> + <PanelSectionRow> + <Field label={title} description={details.filter(Boolean).join(" | ")} /> + </PanelSectionRow> + </PanelSection> + ); +} diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx new file mode 100644 index 0000000..69e72d7 --- /dev/null +++ b/src/components/NowPlayingTab.tsx @@ -0,0 +1,39 @@ +import { Focusable } from "@decky/ui"; +import { ConfigurationData } from "../config/configSchema"; +import type { GameTarget } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; +import { GameConfigurationControls } from "./GameConfigurationControls"; +import { NowPlayingSummary } from "./NowPlayingSummary"; + +interface Props { + game: GameTarget; + config: ConfigurationData; + onConfigChange: ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + ) => Promise<void>; +} + +function targetDescription(game: GameTarget): string { + return sourceLabel(game.source); +} + +export function NowPlayingTab({ + game, + config, + onConfigChange, +}: Props) { + return ( + <Focusable> + <NowPlayingSummary + title={game.name} + details={[targetDescription(game), `Controls: ${game.name} profile`]} + /> + <GameConfigurationControls + config={config} + onConfigChange={onConfigChange} + showWorkarounds={false} + /> + </Focusable> + ); +} diff --git a/src/components/ProfileDetails.tsx b/src/components/ProfileDetails.tsx new file mode 100644 index 0000000..4dd8891 --- /dev/null +++ b/src/components/ProfileDetails.tsx @@ -0,0 +1,37 @@ +import { ButtonItem, Field, Focusable, PanelSectionRow } from "@decky/ui"; +import { useEffect, useRef, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; + +interface ProfileDetailsProps { + description: string; +} + +export function ProfileDetails({ description }: ProfileDetailsProps) { + const [expanded, setExpanded] = useState(false); + const detailsRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!expanded) return; + const frame = requestAnimationFrame(() => detailsRef.current?.scrollIntoView({ block: "nearest" })); + return () => cancelAnimationFrame(frame); + }, [expanded]); + + return ( + <Focusable ref={detailsRef} noFocusRing> + <PanelSectionRow> + <ButtonItem + layout="below" + bottomSeparator={expanded ? "none" : "standard"} + onClick={() => setExpanded((value) => !value)} + > + {expanded ? <RiArrowUpSFill /> : <RiArrowDownSFill />} Game Details + </ButtonItem> + </PanelSectionRow> + {expanded && ( + <PanelSectionRow> + <Field label="Game Details" description={description} /> + </PanelSectionRow> + )} + </Focusable> + ); +} diff --git a/src/components/ProfileManagement.tsx b/src/components/ProfileManagement.tsx deleted file mode 100644 index 626d54c..0000000 --- a/src/components/ProfileManagement.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import { useState, useEffect } from "react"; -import { - PanelSectionRow, - Dropdown, - DropdownOption, - showModal, - ConfirmModal, - Field, - DialogButton, - ButtonItem, - ModalRoot, - TextField, - Focusable, - AppOverview, - Router -} from "@decky/ui"; -import { RiArrowDownSFill, RiArrowUpSFill, RiEditLine, RiDeleteBinLine } from "react-icons/ri"; -import { - getProfiles, - createProfile, - deleteProfile, - renameProfile, - setCurrentProfile, - ProfilesResult, - ProfileResult -} from "../api/lsfgApi"; -import { showSuccessToast, showErrorToast } from "../utils/toastUtils"; -import t from '../i18n/i18n'; - -const PROFILES_COLLAPSED_KEY = 'lsfg-profiles-collapsed'; - -interface TextInputModalProps { - title: string; - description: string; - defaultValue?: string; - okText?: string; - cancelText?: string; - onOK: (value: string) => void; - closeModal?: () => void; -} - -function TextInputModal({ - title, - description, - defaultValue = "", - okText = "OK", - cancelText = "Cancel", - onOK, - closeModal -}: TextInputModalProps) { - const [value, setValue] = useState(defaultValue); - - const handleOK = () => { - if (value.trim()) { - onOK(value); - closeModal?.(); - } - }; - - return ( - <ModalRoot> - <div style={{ padding: "16px", minWidth: "400px" }}> - <h2 style={{ marginBottom: "16px" }}>{title}</h2> - <p style={{ marginBottom: "24px" }}>{description}</p> - - <div style={{ marginBottom: "24px" }}> - <Field - label={t('PROFILE_NAME_LABEL', 'Name')} - childrenLayout="below" - childrenContainerWidth="max" - > - <TextField - value={value} - onChange={(e) => setValue(e?.target?.value || "")} - style={{ width: "100%" }} - /> - </Field> - </div> - - <Focusable - style={{ - display: "flex", - justifyContent: "flex-end", - gap: "8px", - marginTop: "16px" - }} - flow-children="horizontal" - > - <DialogButton onClick={closeModal}> - {cancelText} - </DialogButton> - <DialogButton - onClick={handleOK} - disabled={!value.trim()} - > - {okText} - </DialogButton> - </Focusable> - </div> - </ModalRoot> - ); -} - -interface ProfileManagementProps { - currentProfile?: string; - onProfileChange?: (profileName: string) => void; -} - -export function ProfileManagement({ currentProfile, onProfileChange }: ProfileManagementProps) { - const [profiles, setProfiles] = useState<string[]>([]); - const [selectedProfile, setSelectedProfile] = useState<string>(currentProfile || "decky-lsfg-vk"); - const [isLoading, setIsLoading] = useState(false); - const [mainRunningApp, setMainRunningApp] = useState<AppOverview | undefined>(undefined); - - // Initialize with localStorage value, fallback to false (expanded) if not found - const [profilesCollapsed, setProfilesCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(PROFILES_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : false; - } catch { - return false; - } - }); - - // Persist profiles collapse state to localStorage - useEffect(() => { - try { - localStorage.setItem(PROFILES_COLLAPSED_KEY, JSON.stringify(profilesCollapsed)); - } catch (error) { - console.warn('Failed to save profiles collapse state:', error); - } - }, [profilesCollapsed]); - - // Load profiles on component mount - useEffect(() => { - loadProfiles(); - }, []); - - // Update selected profile when prop changes - useEffect(() => { - if (currentProfile) { - setSelectedProfile(currentProfile); - } - }, [currentProfile]); - - // Poll for running app every 2 seconds - useEffect(() => { - const checkRunningApp = () => { - setMainRunningApp(Router.MainRunningApp); - }; - - // Check immediately - checkRunningApp(); - - // Set up polling interval - const interval = setInterval(checkRunningApp, 2000); - - // Cleanup interval on unmount - return () => clearInterval(interval); - }, []); - - const loadProfiles = async () => { - try { - const result: ProfilesResult = await getProfiles(); - if (result.success && result.profiles) { - setProfiles(result.profiles); - if (result.current_profile) { - setSelectedProfile(result.current_profile); - } - } else { - console.error("Failed to load profiles:", result.error); - showErrorToast("Failed to load profiles", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error loading profiles:", error); - showErrorToast("Error loading profiles", String(error)); - } - }; - - const handleProfileChange = async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await setCurrentProfile(profileName); - if (result.success) { - setSelectedProfile(profileName); - showSuccessToast("Profile switched", `Switched to profile: ${profileName}`); - onProfileChange?.(profileName); - } else { - console.error("Failed to switch profile:", result.error); - showErrorToast("Failed to switch profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error switching profile:", error); - showErrorToast("Error switching profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleCreateProfile = () => { - showModal( - <TextInputModal - title={t('PROFILE_CREATE_TITLE', 'Create New Profile')} - description={t('PROFILE_CREATE_DESC', "Enter a name for the new profile. The current profile's settings will be copied.")} - okText={t('PROFILE_CREATE_BTN', 'Create')} - cancelText={t('PROFILE_CANCEL_BTN', 'Cancel')} - onOK={(name: string) => { - if (name.trim()) { - createNewProfile(name.trim()); - } - }} - /> - ); - }; - - const createNewProfile = async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await createProfile(profileName, selectedProfile); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualProfileName = result.profile_name || profileName; - showSuccessToast("Profile created", `Created profile: ${actualProfileName}`); - await loadProfiles(); - // Automatically switch to the newly created profile using the normalized name - await handleProfileChange(actualProfileName); - } else { - console.error("Failed to create profile:", result.error); - showErrorToast("Failed to create profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error creating profile:", error); - showErrorToast("Error creating profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleDeleteProfile = () => { - if (selectedProfile === "decky-lsfg-vk") { - showErrorToast(t('PROFILE_CANNOT_DELETE_TITLE', 'Cannot delete default profile'), t('PROFILE_CANNOT_DELETE_MSG', 'The default profile cannot be deleted')); - return; - } - - showModal( - <ConfirmModal - strTitle={t('PROFILE_DELETE_TITLE', 'Delete Profile')} - strDescription={`${t('PROFILE_DELETE_DESC_PREFIX', 'Are you sure you want to delete the profile')} "${selectedProfile}"${t('PROFILE_DELETE_DESC_SUFFIX', '? This action cannot be undone.')}`} - strOKButtonText={t('PROFILE_DELETE_BTN', 'Delete')} - strCancelButtonText={t('PROFILE_CANCEL_BTN', 'Cancel')} - onOK={() => deleteSelectedProfile()} - /> - ); - }; - - const deleteSelectedProfile = async () => { - setIsLoading(true); - try { - const result: ProfileResult = await deleteProfile(selectedProfile); - if (result.success) { - showSuccessToast("Profile deleted", `Deleted profile: ${selectedProfile}`); - await loadProfiles(); - // If we deleted the current profile, it should have switched to default - setSelectedProfile("decky-lsfg-vk"); - onProfileChange?.("decky-lsfg-vk"); - } else { - console.error("Failed to delete profile:", result.error); - showErrorToast("Failed to delete profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error deleting profile:", error); - showErrorToast("Error deleting profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleDropdownChange = (option: DropdownOption) => { - if (option.data === "__NEW_PROFILE__") { - handleCreateProfile(); - } else { - handleProfileChange(option.data); - } - }; - - const handleRenameProfile = () => { - if (selectedProfile === "decky-lsfg-vk") { - showErrorToast(t('PROFILE_CANNOT_RENAME_TITLE', 'Cannot rename default profile'), t('PROFILE_CANNOT_RENAME_MSG', 'The default profile cannot be renamed')); - return; - } - - showModal( - <TextInputModal - title={t('PROFILE_RENAME_TITLE', 'Rename Profile')} - description={`${t('PROFILE_RENAME_DESC_PREFIX', 'Enter a new name for the profile')} "${selectedProfile}".`} - defaultValue={selectedProfile} - okText={t('PROFILE_RENAME_BTN', 'Rename')} - cancelText={t('PROFILE_CANCEL_BTN', 'Cancel')} - onOK={(newName: string) => { - if (newName.trim() && newName.trim() !== selectedProfile) { - renameSelectedProfile(newName.trim()); - } - }} - /> - ); - }; - - const renameSelectedProfile = async (newName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await renameProfile(selectedProfile, newName); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualNewName = result.profile_name || newName; - showSuccessToast("Profile renamed", `Renamed profile to: ${actualNewName}`); - await loadProfiles(); - setSelectedProfile(actualNewName); - onProfileChange?.(actualNewName); - } else { - console.error("Failed to rename profile:", result.error); - showErrorToast("Failed to rename profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error renaming profile:", error); - showErrorToast("Error renaming profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const profileOptions: DropdownOption[] = [ - ...profiles.map((profile: string) => ({ - data: profile, - label: profile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : profile - })), - { - data: "__NEW_PROFILE__", - label: t('PROFILE_NEW', 'New Profile') - } - ]; - - return ( - <> - <style> - {` - .LSFG_ProfilesCollapseButton_Container > div > div > div > button { - height: 10px !important; - } - .LSFG_ProfilesCollapseButton_Container > div > div > div > div > button { - height: 10px !important; - } - `} - </style> - - {/* Display currently running game info - always visible */} - {mainRunningApp && ( - <PanelSectionRow> - <div style={{ - padding: "8px 12px", - backgroundColor: "rgba(0, 255, 0, 0.1)", - borderRadius: "4px", - border: "1px solid rgba(0, 255, 0, 0.3)", - fontSize: "13px" - }}> - <strong>{mainRunningApp.display_name}</strong> running. {t('PROFILE_CLOSE_GAME', 'Close game to change profile.')} - </div> - </PanelSectionRow> - )} - - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('PROFILE_SECTION_TITLE', 'Profile:')} {selectedProfile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : selectedProfile} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - className="LSFG_ProfilesCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={profilesCollapsed ? "standard" : "none"} - onClick={() => setProfilesCollapsed(!profilesCollapsed)} - > - {profilesCollapsed ? ( - <RiArrowDownSFill - style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }} - /> - ) : ( - <RiArrowUpSFill - style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }} - /> - )} - </ButtonItem> - </div> - </PanelSectionRow> - - {!profilesCollapsed && ( - <> - <PanelSectionRow> - <Field - label="" - childrenLayout="below" - childrenContainerWidth="max" - bottomSeparator="none" - > - <Dropdown - rgOptions={profileOptions} - selectedOption={selectedProfile} - onChange={handleDropdownChange} - disabled={isLoading || !!mainRunningApp} - /> - </Field> - </PanelSectionRow> - - <PanelSectionRow> - <Focusable - style={{ - display: "flex", - alignItems: "center", - gap: "8px", - width: "100%", - padding: "0", - margin: "0", - marginTop: "8px" - }} - flow-children="horizontal" - > - <DialogButton - style={{ - height: "40px", - flex: 1, - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "10px", - minWidth: "0", - }} - onClick={handleRenameProfile} - disabled={isLoading || selectedProfile === "decky-lsfg-vk" || !!mainRunningApp} - > - <RiEditLine size={20} /> - </DialogButton> - - <DialogButton - style={{ - height: "40px", - flex: 1, - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "10px", - minWidth: "0", - }} - onClick={handleDeleteProfile} - disabled={isLoading || selectedProfile === "decky-lsfg-vk" || !!mainRunningApp} - > - <RiDeleteBinLine size={20} /> - </DialogButton> - </Focusable> - </PanelSectionRow> - </> - )} - </> - ); -} diff --git a/src/components/SettingsTab.tsx b/src/components/SettingsTab.tsx new file mode 100644 index 0000000..35a74cc --- /dev/null +++ b/src/components/SettingsTab.tsx @@ -0,0 +1,100 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; +import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +interface SettingsTabProps { + isInstalled: boolean; + installationStatus: string; + losslessScalingInstalled: boolean; + losslessScalingStatus: string; + steamBranchStatus: SteamBranchStatus | null; + isInstalling: boolean; + isUninstalling: boolean; + globalConfig: GlobalConfig; + showDebugTab: boolean; + onGlobalConfigChange: (config: GlobalConfig) => Promise<boolean>; + onShowDebugTabChange: (value: boolean) => void; + onInstall: () => void; + onUninstall: () => void; +} + +export function SettingsTab(props: SettingsTabProps) { + const { + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + globalConfig, + showDebugTab, + onGlobalConfigChange, + onShowDebugTabChange, + onInstall, + onUninstall, + } = props; + const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; + const buttonLabel = isInstalling + ? t("INSTALL_INSTALLING", "Installing...") + : isUninstalling + ? t("INSTALL_UNINSTALLING", "Uninstalling...") + : isInstalled + ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK") + : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); + + return ( + <> + <PanelSection title="Settings"> + <PanelSectionRow> + <Field + label="Lossless Scaling" + description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"} + /> + </PanelSectionRow> + <PanelSectionRow> + <Field label="LSFG-VK" description={installationStatus} /> + </PanelSectionRow> + {steamBranchStatus?.installed && ( + <PanelSectionRow> + <Field + label="Steam branch" + description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`} + /> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={isInstalled ? onUninstall : onInstall} + disabled={isInstalling || isUninstalling} + > + {buttonLabel} + </ButtonItem> + </PanelSectionRow> + </PanelSection> + {isInstalled && ( + <> + <PanelSection title="Global settings"> + <PanelSectionRow> + <ToggleField + label="FP16 Acceleration" + checked={!globalConfig.no_fp16} + onChange={(value) => void onGlobalConfigChange({ ...globalConfig, no_fp16: !value })} + /> + </PanelSectionRow> + </PanelSection> + <PanelSection title="Advanced"> + <PanelSectionRow> + <ToggleField + label="Show config file tab" + checked={showDebugTab} + onChange={onShowDebugTabChange} + /> + </PanelSectionRow> + </PanelSection> + </> + )} + </> + ); +} diff --git a/src/components/SmartClipboardButton.tsx b/src/components/SmartClipboardButton.tsx deleted file mode 100644 index 8da239a..0000000 --- a/src/components/SmartClipboardButton.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useState, useEffect } from "react"; -import { PanelSectionRow, ButtonItem } from "@decky/ui"; -import { FaClipboard, FaCheck } from "react-icons/fa"; -import { getLaunchOption } from "../api/lsfgApi"; -import { showClipboardErrorToast } from "../utils/toastUtils"; -import { copyWithVerification } from "../utils/clipboardUtils"; -import t from '../i18n/i18n'; - -export function SmartClipboardButton() { - const [isLoading, setIsLoading] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); - - useEffect(() => { - if (showSuccess) { - const timer = setTimeout(() => { - setShowSuccess(false); - }, 3000); - return () => clearTimeout(timer); - } - return undefined; - }, [showSuccess]); - - const getLaunchOptionText = async (): Promise<string> => { - try { - const result = await getLaunchOption(); - return result.launch_option || "~/lsfg %command%"; - } catch (error) { - return "~/lsfg %command%"; - } - }; - - const copyToClipboard = async () => { - if (isLoading || showSuccess) return; - - setIsLoading(true); - try { - const text = await getLaunchOptionText(); - const { success, verified } = await copyWithVerification(text); - - if (success) { - setShowSuccess(true); - if (!verified) { - console.log('Copy verification failed but copy likely worked'); - } - } else { - showClipboardErrorToast(); - } - - } catch (error) { - showClipboardErrorToast(); - } finally { - setIsLoading(false); - } - }; - - return ( - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={copyToClipboard} - disabled={isLoading || showSuccess} - > - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - {showSuccess ? ( - <FaCheck style={{ color: "#4CAF50" }} /> - ) : isLoading ? ( - <FaClipboard style={{ - animation: "pulse 1s ease-in-out infinite", - opacity: 0.7 - }} /> - ) : ( - <FaClipboard /> - )} - <div style={{ - color: showSuccess ? "#4CAF50" : "inherit", - fontWeight: showSuccess ? "bold" : "normal" - }}> - {showSuccess ? t('CLIPBOARD_COPIED', 'Copied to clipboard') : isLoading ? t('CLIPBOARD_COPYING', 'Copying...') : t('CLIPBOARD_COPY_LAUNCH', 'Copy Launch Option')} - </div> - </div> - </ButtonItem> - <style>{` - @keyframes pulse { - 0% { opacity: 0.7; } - 50% { opacity: 1; } - 100% { opacity: 0.7; } - } - `}</style> - </PanelSectionRow> - ); -} diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx deleted file mode 100644 index 3a48a15..0000000 --- a/src/components/StatusDisplay.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { PanelSectionRow } from "@decky/ui"; - -interface StatusDisplayProps { - dllDetected: boolean; - dllDetectionStatus: string; - isInstalled: boolean; - installationStatus: string; -} - -export function StatusDisplay({ - dllDetected, - dllDetectionStatus, - isInstalled, - installationStatus -}: StatusDisplayProps) { - return ( - <PanelSectionRow> - <div style={{ marginBottom: "8px", fontSize: "14px" }}> - <div - style={{ - color: dllDetected ? "#4CAF50" : "#F44336", - fontWeight: "600", - marginBottom: "6px", - display: "flex", - alignItems: "center", - gap: "6px" - }} - > - <span style={{ fontSize: "16px" }}> - {dllDetected ? "✅" : "❌"} - </span> - {dllDetectionStatus} - </div> - <div - style={{ - color: isInstalled ? "#4CAF50" : "#FF9800", - fontWeight: "600", - display: "flex", - alignItems: "center", - gap: "6px" - }} - > - <span style={{ fontSize: "16px" }}> - {isInstalled ? "✅" : "❌"} - </span> - {installationStatus} - </div> - </div> - </PanelSectionRow> - ); -} diff --git a/src/components/UsageInstructions.tsx b/src/components/UsageInstructions.tsx deleted file mode 100644 index 36e31cf..0000000 --- a/src/components/UsageInstructions.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { PanelSectionRow } from "@decky/ui"; -import t from '../i18n/i18n'; - -export function UsageInstructions() { - return ( - <> - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "16px", - marginBottom: "8px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "4px", - color: "white" - }} - > - {t('USAGE_TITLE', 'Usage Instructions')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - style={{ - fontSize: "12px", - lineHeight: "1.4", - opacity: "0.8", - whiteSpace: "pre-wrap" - }} - > - {t('USAGE_DESC', 'Click "Copy Launch Option" button, then paste it into your Steam game\'s launch options to enable frame generation.')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - style={{ - fontSize: "12px", - lineHeight: "1.4", - opacity: "0.8", - backgroundColor: "rgba(255, 255, 255, 0.1)", - padding: "8px", - borderRadius: "4px", - fontFamily: "monospace", - marginTop: "8px", - marginBottom: "8px", - textAlign: "center" - }} - > - <strong>~/lsfg %command%</strong> - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - style={{ - fontSize: "11px", - lineHeight: "1.3", - opacity: "0.6", - marginTop: "8px" - }} - > - {t('USAGE_CONFIG_NOTE', 'The configuration is stored in ~/.config/lsfg-vk/conf.toml and hot-reloads while games are running.')} - </div> - </PanelSectionRow> - </> - ); -} diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx new file mode 100644 index 0000000..3de5ec1 --- /dev/null +++ b/src/components/WorkaroundsSection.tsx @@ -0,0 +1,212 @@ +import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; +import { useEffect, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; +import t from "../i18n/i18n"; +import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; + +interface WorkaroundsSectionProps { + appId: string; + nonSteam: boolean; + onRepair?: () => Promise<boolean>; +} + +const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed-v2"; +type ToggleWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">; + +const TOGGLE_ROWS: readonly { + field: ToggleWorkaroundField; + labelKey: string; + label: string; + descriptionKey: string; + description: string; +}[] = [ + { + field: "disableSteamdeckMode", + labelKey: "CONFIG_DISABLE_STEAMDECK_MODE", + label: "Disable Steam Deck Mode", + descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC", + description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", + }, + { + field: "disableGamescopeWsi", + labelKey: "CONFIG_DISABLE_GAMESCOPE_WSI", + label: "Disable Gamescope WSI", + descriptionKey: "CONFIG_DISABLE_GAMESCOPE_WSI_DESC", + description: "Adds ENABLE_GAMESCOPE_WSI=0. Requires game restart to apply.", + }, + { + field: "disableHdr", + labelKey: "CONFIG_DISABLE_HDR", + label: "Disable HDR", + descriptionKey: "CONFIG_DISABLE_HDR_DESC", + description: "Prevents DXVK from exposing HDR to the game. Requires game restart to apply.", + }, + { + field: "disableVkbasalt", + labelKey: "CONFIG_DISABLE_VKBASALT", + label: "Disable vkBasalt", + descriptionKey: "CONFIG_DISABLE_VKBASALT_DESC", + description: "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", + }, + { + field: "enableZink", + labelKey: "CONFIG_ENABLE_ZINK", + label: "Force Zink for OpenGL Games", + descriptionKey: "CONFIG_ENABLE_ZINK_DESC", + description: "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.", + }, +]; + +function usePersistentCollapsed() { + const [collapsed, setCollapsed] = useState(() => { + try { + const saved = localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY); + return saved !== null ? JSON.parse(saved) === true : true; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed)); + } catch {} + }, [collapsed]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) { + const [collapsed, toggleCollapsed] = usePersistentCollapsed(); + const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam); + const [repairing, setRepairing] = useState(false); + const state = snapshot?.state; + const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true; + const [fpsValue, setFpsValue] = useState<number | null>(null); + const effectiveFpsValue = fpsValue ?? state?.dxvkFrameRate ?? 0; + const fpsLabel = effectiveFpsValue > 0 + ? `${effectiveFpsValue} FPS` + : t("CONFIG_BASE_FPS_CAP_OFF", "Off"); + + const handleRepair = async () => { + if (!onRepair || repairing) return; + setRepairing(true); + try { + if (await onRepair()) await refresh(); + } finally { + setRepairing(false); + } + }; + + useEffect(() => { + setFpsValue(state?.dxvkFrameRate ?? null); + }, [state?.dxvkFrameRate, status]); + + return ( + <> + <style> + {` + .LSFG_WorkaroundsCollapseButton_Container > div > div > div > button, + .LSFG_WorkaroundsCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + `} + </style> + <PanelSectionRow> + <div + style={{ + fontSize: "14px", + fontWeight: "bold", + marginTop: "8px", + marginBottom: "6px", + borderBottom: "1px solid rgba(255, 255, 255, 0.2)", + paddingBottom: "3px", + color: "white", + }} + > + {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")} + </div> + </PanelSectionRow> + <PanelSectionRow> + <div className="LSFG_WorkaroundsCollapseButton_Container" style={{ marginTop: "-2px", marginBottom: "4px" }}> + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={toggleCollapsed} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </div> + </PanelSectionRow> + + {!collapsed && ( + <> + {status === "loading" && ( + <PanelSectionRow> + <Field label="Reading launch options..." /> + </PanelSectionRow> + )} + {status === "error" && ( + <> + <PanelSectionRow> + <Field + label="Launch options unavailable" + description={error || "Steam did not provide readable launch options."} + /> + </PanelSectionRow> + <PanelSectionRow> + <ButtonItem layout="below" onClick={() => void refresh()}>Retry</ButtonItem> + </PanelSectionRow> + </> + )} + {status === "ready" && snapshot && (!snapshot.wrapperOwned || !snapshot.integrationInstalled) && ( + <> + <PanelSectionRow> + <Field label="Wrapper needs to be reinstalled" /> + </PanelSectionRow> + {onRepair && ( + <PanelSectionRow> + <ButtonItem layout="below" disabled={repairing} onClick={() => void handleRepair()}> + {repairing ? "Reinstalling..." : "Reinstall wrapper"} + </ButtonItem> + </PanelSectionRow> + )} + </> + )} + + <PanelSectionRow> + <SliderField + label={`${t("CONFIG_BASE_FPS_CAP", "Base FPS Cap")} (${fpsLabel})`} + description={t("CONFIG_BASE_FPS_CAP_DESC", "Base cap for DXVK-backed games before frame generation; 0 disables. Requires game restart to apply.")} + value={effectiveFpsValue} + min={0} + max={60} + step={1} + onChange={(value) => { + setFpsValue(value); + void update("dxvkFrameRate", value); + }} + disabled={controlsDisabled} + /> + </PanelSectionRow> + {TOGGLE_ROWS.map((row) => ( + <PanelSectionRow key={row.field}> + <ToggleField + label={t(row.labelKey, row.label)} + description={t(row.descriptionKey, row.description)} + checked={Boolean(state?.[row.field])} + onChange={(value) => void update(row.field, value)} + disabled={controlsDisabled} + /> + </PanelSectionRow> + ))} + </> + )} + </> + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index bec45ae..5459974 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,11 +1,10 @@ export { Content } from "./Content"; -export { StatusDisplay } from "./StatusDisplay"; -export { InstallationButton } from "./InstallationButton"; export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; -export { UsageInstructions } from "./UsageInstructions"; -export { SmartClipboardButton } from "./SmartClipboardButton"; -export { FgmodClipboardButton } from "./FgmodClipboardButton"; -export { NerdStuffModal } from "./NerdStuffModal"; -export { FlatpaksModal } from "./FlatpaksModal"; -export { ProfileManagement } from "./ProfileManagement"; +export { ConfigurationTab } from "./ConfigurationTab"; +export { ConfigFileTab } from "./ConfigFileTab"; +export { SettingsTab } from "./SettingsTab"; +export { GameConfigurationSelector } from "./GameConfigurationSelector"; +export { GameConfigurationControls } from "./GameConfigurationControls"; +export { NowPlayingTab } from "./NowPlayingTab"; +export { WorkaroundsSection } from "./WorkaroundsSection"; |
