diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/CollapsibleItemGroup.tsx | 103 | ||||
| -rw-r--r-- | src/components/ConfigFileTab.tsx | 121 | ||||
| -rw-r--r-- | src/components/ConfigurationSection.tsx | 5 | ||||
| -rw-r--r-- | src/components/ConfigurationTab.tsx | 136 | ||||
| -rw-r--r-- | src/components/Content.tsx | 220 | ||||
| -rw-r--r-- | src/components/FlatpakNowPlayingTab.tsx | 38 | ||||
| -rw-r--r-- | src/components/FlatpakTab.tsx | 247 | ||||
| -rw-r--r-- | src/components/FlatpakWorkaroundsSection.tsx | 144 | ||||
| -rw-r--r-- | src/components/FpsMultiplierControl.tsx | 81 | ||||
| -rw-r--r-- | src/components/GameConfigurationControls.tsx | 3 | ||||
| -rw-r--r-- | src/components/GameConfigurationSelector.tsx | 155 | ||||
| -rw-r--r-- | src/components/NowPlayingSummary.tsx | 16 | ||||
| -rw-r--r-- | src/components/NowPlayingTab.tsx | 51 | ||||
| -rw-r--r-- | src/components/SettingsTab.tsx | 100 | ||||
| -rw-r--r-- | src/components/SetupTab.tsx | 68 | ||||
| -rw-r--r-- | src/components/WorkaroundsSection.tsx | 10 | ||||
| -rw-r--r-- | src/components/index.ts | 2 |
17 files changed, 1079 insertions, 421 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 index e3cc09d..fd6d716 100644 --- a/src/components/ConfigFileTab.tsx +++ b/src/components/ConfigFileTab.tsx @@ -1,20 +1,86 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; import { useEffect, useState } from "react"; -import { Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; -import { getConfigFileContent, FileContentResult } from "../api/lsfgApi"; +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<FileContentResult | null>(null); + const [result, setResult] = useState<DebugFileContentsResult | null>(null); useEffect(() => { - getConfigFileContent().then(setResult).catch((error) => { + getDebugFileContents().then(setResult).catch((error) => { setResult({ success: false, error: String(error) }); }); }, []); if (!result) { return ( - <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}> + <PanelSection title={t("NERD_CONFIG_FILE", "Config / Debug")}> <PanelSectionRow> <Spinner /> </PanelSectionRow> @@ -23,24 +89,35 @@ export function ConfigFileTab() { } return ( - <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}> - {result.error && ( - <PanelSectionRow> - <Field label="Error" description={result.error} /> - </PanelSectionRow> - )} - {result.success && result.content && ( - <> - <PanelSectionRow> - <Field label="Config file" description={result.path} /> - </PanelSectionRow> + <> + <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> - <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}> - {result.content} - </pre> + <Field label="Error" description={result.error} /> </PanelSectionRow> - </> - )} - </PanelSection> + )} + {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 1f17264..6996bcd 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,6 +1,6 @@ import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT, NO_FP16 } from "../config/configSchema"; +import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from "../config/configSchema"; interface ConfigurationSectionProps { config: ConfigurationData; @@ -13,9 +13,6 @@ export function ConfigurationSection({ config, onConfigChange }: ConfigurationSe <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="FP16 Acceleration" checked={!config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, !value)} /> - </PanelSectionRow> - <PanelSectionRow> <ToggleField label="Performance Mode" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} /> </PanelSectionRow> <PanelSectionRow> diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 5ae4a87..37555e0 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,26 +1,36 @@ -import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +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 { GameTarget } from "../hooks/useGameConfiguration"; +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[]) => Promise<void>; + onConfigChange: ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + cleanupLaunchOptions?: boolean, + ) => Promise<void>; onEnable: (appid: string) => Promise<boolean>; - onEnableAll: () => Promise<void>; + onEnableAll: (source: KnownGameSource) => Promise<void>; + bulkOperationBusy: boolean; onRepair: (appid: string) => Promise<boolean>; onReset: () => Promise<void>; - onResetAll: () => Promise<void>; + onResetAll: (source: KnownGameSource) => Promise<void>; } export function ConfigurationTab({ + title, + source, config, targets, runningGame, @@ -28,6 +38,7 @@ export function ConfigurationTab({ onConfigChange, onEnable, onEnableAll, + bulkOperationBusy, onRepair, onReset, onResetAll, @@ -63,33 +74,33 @@ export function ConfigurationTab({ if (detailAppId === null) { return ( - <PanelSection title="Games"> - <GameConfigurationSelector - targets={targets} - runningGame={runningGame} - 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> + <> + <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 - ? selectedTarget.transport.kind === "flatpak" - ? "Non-Steam · Flatpak" - : selectedTarget.nonSteam ? "Non-Steam" : "Steam" - : "Game"; + 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"}` + ? `${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; @@ -103,10 +114,8 @@ export function ConfigurationTab({ closeDetails(); } else if (detailAppId) { const isRunningUnconfigured = runningGame?.appid === detailAppId - && runningGame.nonSteam === false - && runningGame.transport.kind === "host" - && selectedTarget?.nonSteam === false - && selectedTarget?.transport.kind === "host" + && runningGame.source === "steam" + && selectedTarget?.source === "steam" && !runningGame.configured; if (isRunningUnconfigured) { showModal( @@ -131,22 +140,22 @@ export function ConfigurationTab({ <PanelSectionRow> <div style={{ display: "flex", alignItems: "center", width: "100%" }}> <Focusable noFocusRing style={{ flex: "none" }}> - <DialogButton - aria-label="Back to games" - onClick={closeDetails} - style={{ - width: "48px", - minWidth: "48px", - height: "24px", - minHeight: "24px", - padding: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <FaArrowLeft /> - </DialogButton> + <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} @@ -160,33 +169,28 @@ export function ConfigurationTab({ <PanelSection> {!selectedTarget?.configured && selectedTarget && ( <PanelSectionRow> - <Focusable ref={enableRef} noFocusRing> - <ButtonItem layout="below" onClick={handleProfileAction}>Enable for next launch</ButtonItem> - </Focusable> + {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 && selectedTarget.transport.kind === "flatpak" && selectedTarget.flatpakSupport?.support_status !== "ready" && ( - <PanelSection> - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={() => void onRepair(selectedTarget.appid)} - > - Repair Flatpak support - </ButtonItem> - </PanelSectionRow> - </PanelSection> - )} {selectedTarget?.configured && ( <GameConfigurationControls config={config} - onConfigChange={onConfigChange} + onConfigChange={(field, value) => onConfigChange(field, value, selectedTarget?.source !== "unknown")} autoFocusFpsMultiplier={focusFpsMultiplier} onFpsMultiplierFocused={clearFpsFocusRequest} - showWorkarounds - workaroundTarget={selectedTarget || undefined} - onRepairWorkaround={selectedTarget ? () => onRepair(selectedTarget.appid) : undefined} + showWorkarounds={selectedTarget.source !== "unknown"} + workaroundTarget={selectedTarget.source !== "unknown" ? selectedTarget : undefined} + onRepairWorkaround={selectedTarget.source !== "unknown" ? () => onRepair(selectedTarget.appid) : undefined} /> )} {selectedTarget?.configured && ( diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 578f984..470db95 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,34 +1,75 @@ import { Tabs } from "@decky/ui"; -import { useEffect, useRef, useState } from "react"; -import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; +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 { 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 { SetupTab } from "./SetupTab"; +import { SettingsTab } from "./SettingsTab"; const tabIcons = { nowPlaying: <FaGamepad size={18} />, - games: <FaList size={18} />, + steam: <FaSteam size={18} />, + nonSteam: <FaExternalLinkAlt size={18} />, + flatpak: <FaCube size={18} />, configFile: <FaFileAlt size={18} />, - setup: <FaTools 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]); + + return [value, setValue] as const; +} + export function Content() { const { config, + runningConfig, + globalConfig, targets, runningGame, setSelectedAppId, save, + saveFor, + updateGlobal, enable, enableAll, + bulkOperationBusy, repair, resetSelected, resetAll, + cleanupAllWorkarounds, reload, } = useGameConfiguration(); const { @@ -41,47 +82,72 @@ export function Content() { isUninstalling, install, uninstall, - } = useInstallation(reload); - const [tab, setTab] = useState("Setup"); - const previousRunningAppId = useRef<string | null>(null); + } = 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"); useEffect(() => { if (!setupComplete) { - setTab("Setup"); + setTab("Settings"); return; } - setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Games") : current); - }, [runningGame?.appid, runningGame?.configured, setupComplete]); + setTab((current) => current === "Settings" ? (hasNowPlaying ? "NowPlaying" : "Steam") : current); + }, [hasNowPlaying, setupComplete]); useEffect(() => { if (!setupComplete) return; - const appid = runningGame?.appid || null; - const previous = previousRunningAppId.current; - previousRunningAppId.current = appid; - if (appid && appid !== previous) setTab(runningGame?.configured ? "NowPlaying" : "Games"); - else if (!appid && previous) { - setTab((current) => current === "NowPlaying" ? "Games" : current); + const previous = previousRunningWorkload.current; + previousRunningWorkload.current = runningWorkload; + if (runningWorkload && runningWorkload !== previous) { + previousNowPlayingTab.current = tabForNowPlaying(nowPlayingTarget); + setTab("NowPlaying"); + } + else if (!runningWorkload && previous) { + setTab((current) => current === "NowPlaying" ? previousNowPlayingTab.current : current); + } + }, [runningWorkload, setupComplete]); + + useEffect(() => { + if (isInstalled) { + void reload(); + void flatpak.reload(); } - }, [runningGame?.appid, runningGame?.configured, setupComplete]); + }, [isInstalled, reload, flatpak.reload]); useEffect(() => { - if (isInstalled) void reload(); - }, [isInstalled, reload]); + if (!showDebugTab && tab === "ConfigFile") setTab(setupComplete ? "Steam" : "Settings"); + }, [setupComplete, showDebugTab, tab]); const handleConfigChange = async ( fieldName: keyof ConfigurationData, value: boolean | number | string | string[], cleanupLaunchOptions = false, - ) => save({ ...config, [fieldName]: value }, cleanupLaunchOptions); + ) => { + await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); + }; - const setup = ( - <SetupTab + const settings = ( + <SettingsTab isInstalled={isInstalled} installationStatus={installationStatus} losslessScalingInstalled={losslessScalingInstalled} @@ -89,55 +155,125 @@ export function Content() { 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 ? [ - ...(runningGame?.configured ? [{ - id: "NowPlaying", - title: tabIcons.nowPlaying, - content: ( - <NowPlayingTab - game={runningGame} + ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: tabContent(nowPlaying) }] : []), + { + id: "Steam", + title: tabIcons.steam, + content: tabContent( + <ConfigurationTab + title="Steam games" + source="steam" config={config} - onConfigChange={(field, value) => handleConfigChange(field, value)} + targets={steamTargets} + runningGame={runningGame} + onSelect={setSelectedAppId} + onConfigChange={handleConfigChange} + onEnable={enable} + onEnableAll={enableAll} + bulkOperationBusy={bulkOperationBusy} onRepair={repair} - /> + onReset={resetSelected} + onResetAll={resetAll} + />, ), - }] : []), + }, { - id: "Games", - title: tabIcons.games, - content: ( + id: "NonSteam", + title: tabIcons.nonSteam, + content: tabContent( <ConfigurationTab + title="Non-Steam games" + source="nonSteam" config={config} - targets={targets} + targets={nonSteamTargets} runningGame={runningGame} onSelect={setSelectedAppId} - onConfigChange={(field, value) => handleConfigChange(field, value, true)} + onConfigChange={handleConfigChange} onEnable={enable} onEnableAll={enableAll} + bulkOperationBusy={bulkOperationBusy} onRepair={repair} onReset={resetSelected} onResetAll={resetAll} /> ), }, - { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }, - { id: "Setup", title: tabIcons.setup, content: setup }, + { + 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: "Setup", title: tabIcons.setup, content: setup }]; + : [{ 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 ( <div - className="lsfg-vk-tabs" + 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={tab} onShowTab={setTab} tabs={tabs} /> + <Tabs + activeTab={activeTab} + onShowTab={(nextTab: string) => { + if (availableTabIds.has(nextTab)) setTab(nextTab); + }} + tabs={tabs} + /> </div> ); } 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/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 2c50be1..523e8b3 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,4 +1,4 @@ -import { DialogButton, Focusable, PanelSectionRow } 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"; @@ -31,63 +31,32 @@ export function FpsMultiplierControl({ return () => cancelAnimationFrame(frame); }, [autoFocus, onAutoFocus]); + const multiplierLabel = config.multiplier === 1 + ? t("MULTIPLIER_OFF", "Off") + : `${config.multiplier}x`; + return ( <PanelSectionRow> - <Focusable - ref={focusableRef} - noFocusRing - 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={() => void 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={() => void 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 index 6bea2e0..7025f78 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -10,7 +10,7 @@ interface Props { autoFocusFpsMultiplier?: boolean; onFpsMultiplierFocused?: () => void; showWorkarounds?: boolean; - workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam" | "transport">; + workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">; onRepairWorkaround?: () => Promise<boolean>; } @@ -36,7 +36,6 @@ export function GameConfigurationControls({ <WorkaroundsSection appId={workaroundTarget.appid} nonSteam={workaroundTarget.nonSteam} - transport={workaroundTarget.transport} onRepair={onRepairWorkaround} /> )} diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 92eacba..91dc3df 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -1,14 +1,17 @@ import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui"; -import { useEffect, useRef, useState, type RefObject } from "react"; -import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; -import { GameTarget } from "../hooks/useGameConfiguration"; +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: () => Promise<void>; - onResetAll: () => Promise<void>; + onEnableAll: (source: KnownGameSource) => Promise<void>; + onResetAll: (source: KnownGameSource) => Promise<void>; focusConfiguredToggle?: boolean; onConfiguredToggleFocused?: () => void; } @@ -16,85 +19,18 @@ interface Props { const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4"; const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3"; -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 targetDescription(game: GameTarget): string { - if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; - return game.nonSteam ? "Non-Steam" : "Steam"; -} - -function GameGroup({ - title, - games, - collapsed, - onToggle, - onSelect, - toggleRef, -}: { - title: string; - games: GameTarget[]; - collapsed: boolean; - onToggle: () => void; - onSelect: (appid: string) => void; - toggleRef?: RefObject<HTMLDivElement>; -}) { - if (games.length === 0) return null; - - return ( - <> - <PanelSectionRow> - <Field label={title + " (" + games.length + ")"} bottomSeparator="none" /> - </PanelSectionRow> - <PanelSectionRow> - <div - ref={toggleRef} - className="LSFG_GameGroupCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={collapsed ? "standard" : "none"} - onClick={onToggle} - > - {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} - </ButtonItem> - </div> - </PanelSectionRow> - {!collapsed && games.map((game) => ( - <PanelSectionRow key={game.appid}> - <Field - label={game.name} - description={targetDescription(game)} - onActivate={() => onSelect(game.appid)} - highlightOnFocus - /> - </PanelSectionRow> - ))} - </> - ); + 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, @@ -108,8 +44,20 @@ export function GameConfigurationSelector({ }); const enabledGames = sortGames(targets.filter((game) => game.configured)); const availableGames = sortGames(targets.filter((game) => !game.configured)); - const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); - const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); + 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(() => { @@ -124,10 +72,10 @@ export function GameConfigurationSelector({ const confirmResetAll = () => { showModal( <ConfirmModal - strTitle="Remove all profiles?" + strTitle={`Remove all ${sourceName} profiles?`} strOKButtonText="Remove all" strCancelButtonText="Cancel" - onOK={() => void onResetAll()} + onOK={() => void onResetAll(source)} onCancel={() => {}} />, ); @@ -136,11 +84,11 @@ export function GameConfigurationSelector({ const confirmEnableAll = () => { showModal( <ConfirmModal - strTitle="Enable all available games?" - strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults. Flatpak targets will be provisioned as needed." + 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()} + onOK={() => void onEnableAll(source)} onCancel={() => {}} />, ); @@ -149,47 +97,32 @@ export function GameConfigurationSelector({ return ( <> <style> - {` - .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; - } - `} + {collapsibleItemGroupStyles} </style> {targets.length === 0 && ( <PanelSectionRow> - <Field label="No installed games" description="Steam has not reported any eligible games" /> + <Field label={`No ${sourceName} found`} description={emptyDescription} /> </PanelSectionRow> )} - <GameGroup + <CollapsibleItemGroup title="Enabled" - games={enabledGames} + items={enabledGames.map(toItem)} collapsed={enabledCollapsed} onToggle={toggleEnabled} onSelect={onSelect} toggleRef={enabledToggleRef} /> - <GameGroup + <CollapsibleItemGroup title="Available" - games={availableGames} + items={availableGames.map(toItem)} collapsed={availableCollapsed} onToggle={toggleAvailable} onSelect={onSelect} /> - {availableGames.length > 0 && ( + {enableableGames.length > 0 && ( <PanelSectionRow> - <ButtonItem layout="below" onClick={confirmEnableAll}> - Enable all available games + <ButtonItem layout="below" onClick={confirmEnableAll} disabled={bulkOperationBusy}> + {`Enable all ${sourceName}`} </ButtonItem> </PanelSectionRow> )} @@ -197,9 +130,9 @@ export function GameConfigurationSelector({ <ButtonItem layout="below" onClick={confirmResetAll} - disabled={!targets.some((target) => target.configured)} + disabled={bulkOperationBusy || removableGames.length === 0} > - Remove all profiles + {`Remove all ${sourceLabel(source)} profiles`} </ButtonItem> </PanelSectionRow> </> 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 index 57858db..69e72d7 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -1,8 +1,9 @@ -import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; -import { useState } from "react"; +import { Focusable } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { GameTarget } from "../hooks/useGameConfiguration"; +import type { GameTarget } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; import { GameConfigurationControls } from "./GameConfigurationControls"; +import { NowPlayingSummary } from "./NowPlayingSummary"; interface Props { game: GameTarget; @@ -11,57 +12,23 @@ interface Props { fieldName: keyof ConfigurationData, value: boolean | number | string | string[], ) => Promise<void>; - onRepair: (appid: string) => Promise<boolean>; } function targetDescription(game: GameTarget): string { - if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; - return game.nonSteam ? "Non-Steam" : "Steam"; + return sourceLabel(game.source); } export function NowPlayingTab({ game, config, onConfigChange, - onRepair, }: Props) { - const [busy, setBusy] = useState(false); - const supportNeedsRepair = - game.transport.kind === "flatpak" && - game.flatpakSupport?.support_status !== "ready"; - - const handleRepair = async () => { - if (busy) return; - setBusy(true); - try { - await onRepair(game.appid); - } finally { - setBusy(false); - } - }; - return ( <Focusable> - <PanelSection title="Now Playing"> - <PanelSectionRow> - <Field label={game.name} description={targetDescription(game)} /> - </PanelSectionRow> - </PanelSection> - {supportNeedsRepair && ( - <PanelSection> - <PanelSectionRow> - <Field - label="Flatpak support needs repair" - description={game.flatpakSupport?.error || "The target runtime extension is not ready."} - /> - </PanelSectionRow> - <PanelSectionRow> - <ButtonItem layout="below" disabled={busy} onClick={() => void handleRepair()}> - {busy ? "Repairing..." : "Repair Flatpak support"} - </ButtonItem> - </PanelSectionRow> - </PanelSection> - )} + <NowPlayingSummary + title={game.name} + details={[targetDescription(game), `Controls: ${game.name} profile`]} + /> <GameConfigurationControls config={config} onConfigChange={onConfigChange} 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/SetupTab.tsx b/src/components/SetupTab.tsx deleted file mode 100644 index d769200..0000000 --- a/src/components/SetupTab.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui"; -import { type SteamBranchStatus } from "../api/lsfgApi"; -import t from "../i18n/i18n"; - -interface SetupTabProps { - isInstalled: boolean; - installationStatus: string; - losslessScalingInstalled: boolean; - losslessScalingStatus: string; - steamBranchStatus: SteamBranchStatus | null; - isInstalling: boolean; - isUninstalling: boolean; - onInstall: () => void; - onUninstall: () => void; -} - -export function SetupTab(props: SetupTabProps) { - const { - isInstalled, - installationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus, - isInstalling, - isUninstalling, - 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="Setup"> - <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> - ); -} diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index 5587392..3de5ec1 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -1,7 +1,6 @@ import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; import { useEffect, useState } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; -import type { TargetTransport } from "../api/lsfgApi"; import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; import t from "../i18n/i18n"; import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; @@ -9,7 +8,6 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; interface WorkaroundsSectionProps { appId: string; nonSteam: boolean; - transport: TargetTransport; onRepair?: () => Promise<boolean>; } @@ -73,17 +71,15 @@ function usePersistentCollapsed() { useEffect(() => { try { localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed)); - } catch { - // Persisting the view preference is optional. - } + } catch {} }, [collapsed]); return [collapsed, () => setCollapsed((value) => !value)] as const; } -export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) { +export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) { const [collapsed, toggleCollapsed] = usePersistentCollapsed(); - const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport); + 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; diff --git a/src/components/index.ts b/src/components/index.ts index bca6f6f..5459974 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,7 +3,7 @@ export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; export { ConfigurationTab } from "./ConfigurationTab"; export { ConfigFileTab } from "./ConfigFileTab"; -export { SetupTab } from "./SetupTab"; +export { SettingsTab } from "./SettingsTab"; export { GameConfigurationSelector } from "./GameConfigurationSelector"; export { GameConfigurationControls } from "./GameConfigurationControls"; export { NowPlayingTab } from "./NowPlayingTab"; |
