summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/CollapsibleItemGroup.tsx103
-rw-r--r--src/components/ConfigFileTab.tsx123
-rw-r--r--src/components/ConfigurationSection.tsx312
-rw-r--r--src/components/ConfigurationTab.tsx204
-rw-r--r--src/components/Content.tsx422
-rw-r--r--src/components/FgmodClipboardButton.tsx110
-rw-r--r--src/components/FlatpakNowPlayingTab.tsx38
-rw-r--r--src/components/FlatpakTab.tsx247
-rw-r--r--src/components/FlatpakWorkaroundsSection.tsx144
-rw-r--r--src/components/FlatpaksModal.tsx433
-rw-r--r--src/components/FpsMultiplierControl.tsx104
-rw-r--r--src/components/GameConfigurationControls.tsx44
-rw-r--r--src/components/GameConfigurationSelector.tsx140
-rw-r--r--src/components/InstallationButton.tsx65
-rw-r--r--src/components/NerdStuffModal.tsx187
-rw-r--r--src/components/NowPlayingSummary.tsx16
-rw-r--r--src/components/NowPlayingTab.tsx39
-rw-r--r--src/components/ProfileDetails.tsx37
-rw-r--r--src/components/ProfileManagement.tsx477
-rw-r--r--src/components/SettingsTab.tsx100
-rw-r--r--src/components/SmartClipboardButton.tsx91
-rw-r--r--src/components/StatusDisplay.tsx51
-rw-r--r--src/components/UsageInstructions.tsx69
-rw-r--r--src/components/WorkaroundsSection.tsx212
-rw-r--r--src/components/index.ts15
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";