summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2026-09-11 10:51:55 -0400
committerGitHub <noreply@github.com>2026-09-11 10:51:55 -0400
commitbc73d150230f92d614962f7384a7430cf64dcd14 (patch)
treeb6ec78ab8663c3da2e0745db8fb98bbb7303e6e2 /src/components
parent904e2e6131071c3b132d3148947b613c2830b1bb (diff)
parent6597df7865b4eb5fce574d5efda1e33680df4357 (diff)
downloaddecky-lsfg-vk-bc73d150230f92d614962f7384a7430cf64dcd14.tar.gz
decky-lsfg-vk-bc73d150230f92d614962f7384a7430cf64dcd14.zip
Merge pull request #263 from xXJSONDeruloXx/v2-now-playing
flatpak correctness, ui alignment, tests
Diffstat (limited to 'src/components')
-rw-r--r--src/components/CollapsibleItemGroup.tsx101
-rw-r--r--src/components/ConfigurationTab.tsx1
-rw-r--r--src/components/Content.tsx66
-rw-r--r--src/components/FlatpakNowPlayingTab.tsx31
-rw-r--r--src/components/FlatpakTab.tsx60
-rw-r--r--src/components/GameConfigurationSelector.tsx103
-rw-r--r--src/components/NowPlayingSummary.tsx16
-rw-r--r--src/components/NowPlayingTab.tsx12
8 files changed, 233 insertions, 157 deletions
diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx
new file mode 100644
index 0000000..29fe945
--- /dev/null
+++ b/src/components/CollapsibleItemGroup.tsx
@@ -0,0 +1,101 @@
+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;
+}
+
+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}
+ onActivate={() => onSelect(item.id)}
+ highlightOnFocus
+ />
+ </PanelSectionRow>
+ ))}
+ </>
+ );
+}
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index a6d4c2a..12f36a5 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -88,7 +88,6 @@ export function ConfigurationTab({
<PanelSectionRow>
<ToggleField
label="Show debug tab"
- description="Show the raw configuration and generated files tab for troubleshooting."
checked={showDebugTab}
onChange={onShowDebugTabChange}
/>
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index ce3c018..32e9bf8 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,11 +1,12 @@
import { Tabs } from "@decky/ui";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react";
import { FaCube, FaFileAlt, FaGamepad, FaList, 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 { resolveNowPlayingTarget } from "../utils/nowPlaying";
import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab";
@@ -78,12 +79,16 @@ export function Content() {
const flatpak = useFlatpakConfiguration(setupComplete);
const [tab, setTab] = useState("Setup");
const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true);
+ const [contentFocused, setContentFocused] = useState(false);
const previousRunningWorkload = useRef<string | null>(null);
const runningFlatpak = flatpak.runningApp;
- const hasNowPlaying = Boolean(runningGame?.configured || runningFlatpak);
- const runningWorkload = runningGame?.configured
- ? `steam:${runningGame.appid}`
- : runningFlatpak ? `flatpak:${runningFlatpak.app_id}` : null;
+ const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak);
+ const hasNowPlaying = Boolean(nowPlayingTarget);
+ const runningWorkload = nowPlayingTarget
+ ? nowPlayingTarget.kind === "flatpak"
+ ? `flatpak:${nowPlayingTarget.app.app_id}:${nowPlayingTarget.launcher?.appid ?? ""}`
+ : `steam:${nowPlayingTarget.game.appid}`
+ : null;
useEffect(() => {
if (!setupComplete) {
@@ -136,30 +141,33 @@ export function Content() {
/>
);
- const nowPlaying = runningGame?.configured ? (
+ const tabContent = (content: ReactNode) => (
+ <div className="lsfg-vk-tab-content">{content}</div>
+ );
+
+ const nowPlaying = nowPlayingTarget?.kind === "steam" ? (
<NowPlayingTab
- game={runningGame}
+ game={nowPlayingTarget.game}
config={runningConfig}
onConfigChange={async (field, value) => {
- await saveFor(runningGame.appid, { ...runningConfig, [field]: value }, true);
+ await saveFor(nowPlayingTarget.game.appid, { ...runningConfig, [field]: value }, true);
}}
/>
- ) : runningFlatpak ? (
+ ) : nowPlayingTarget?.kind === "flatpak" ? (
<FlatpakNowPlayingTab
- app={runningFlatpak}
- busy={flatpak.busyAppId === runningFlatpak.app_id}
+ app={nowPlayingTarget.app}
+ launcher={nowPlayingTarget.launcher}
onConfigChange={flatpak.updateConfig}
- onWorkaroundChange={flatpak.updateWorkarounds}
/>
) : null;
const tabs = setupComplete
? [
- ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }] : []),
+ ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: tabContent(nowPlaying) }] : []),
{
id: "Games",
title: tabIcons.games,
- content: (
+ content: tabContent(
<ConfigurationTab
config={config}
targets={targets}
@@ -173,13 +181,13 @@ export function Content() {
onRepair={repair}
onReset={resetSelected}
onResetAll={resetAll}
- />
+ />,
),
},
{
id: "Flatpak",
title: tabIcons.flatpak,
- content: (
+ content: tabContent(
<FlatpakTab
apps={flatpak.apps}
runningApp={runningFlatpak}
@@ -190,21 +198,35 @@ export function Content() {
onRemove={flatpak.removeApp}
onConfigChange={flatpak.updateConfig}
onWorkaroundChange={flatpak.updateWorkarounds}
- />
+ />,
),
},
- ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }] : []),
- { id: "Setup", title: tabIcons.setup, content: setup },
+ ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: tabContent(<ConfigFileTab />) }] : []),
+ { id: "Setup", title: tabIcons.setup, content: tabContent(setup) },
]
- : [{ id: "Setup", title: tabIcons.setup, content: setup }];
+ : [{ id: "Setup", title: tabIcons.setup, content: tabContent(setup) }];
+
+ const availableTabIds = new Set(tabs.map(({ id }) => id));
+ const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup";
+ 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={!showDebugTab && tab === "ConfigFile" ? "Games" : 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
index 9e5a0db..89f7f49 100644
--- a/src/components/FlatpakNowPlayingTab.tsx
+++ b/src/components/FlatpakNowPlayingTab.tsx
@@ -1,17 +1,17 @@
-import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
-import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import { Focusable } from "@decky/ui";
+import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi";
+import type { GameTarget } from "../hooks/useGameConfiguration";
import { ConfigurationSection } from "./ConfigurationSection";
-import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
import { FpsMultiplierControl } from "./FpsMultiplierControl";
+import { NowPlayingSummary } from "./NowPlayingSummary";
interface Props {
app: FlatpakApp;
- busy: boolean;
+ launcher: GameTarget | null;
onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>;
- onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
}
-export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundChange }: Props) {
+export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) {
if (!app.config) return null;
const changeConfig = async (
field: keyof LsfgConfig,
@@ -22,18 +22,17 @@ export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundCh
return (
<Focusable>
- <PanelSection title="Now Playing">
- <PanelSectionRow>
- <Field label={app.app_name} description={`Flatpak · ${app.app_id}`} />
- </PanelSectionRow>
- </PanelSection>
+ <NowPlayingSummary
+ title={launcher?.name || app.app_name}
+ details={[
+ launcher ? (launcher.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} />
- <FlatpakWorkaroundsSection
- state={app.workarounds}
- disabled={busy}
- onChange={(state) => onWorkaroundChange(app.app_id, state)}
- />
</Focusable>
);
}
diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx
index c5e27f5..0b20da0 100644
--- a/src/components/FlatpakTab.tsx
+++ b/src/components/FlatpakTab.tsx
@@ -2,6 +2,7 @@ import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionR
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";
@@ -19,6 +20,9 @@ interface Props {
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,
@@ -36,31 +40,45 @@ export function FlatpakTab({
[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 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">
- {/* <PanelSectionRow>
- <Field
- label="Flatpak applications"
- description="Enable LSFG-VK directly for a Flatpak. Steam shortcuts and launcher scripts are not modified."
- />
- </PanelSectionRow> */}
- {apps.map((app) => {
- const status = app.enabled
- ? app.app_id === runningApp?.app_id ? "Enabled · Running" : "Enabled"
- : app.prepared && !app.owned ? "Prepared externally" : "Available";
- return (
- <PanelSectionRow key={app.app_id}>
- <Field
- label={app.app_name}
- description={`${app.app_id} · ${status}`}
- onActivate={() => setSelectedAppId(app.app_id)}
- highlightOnFocus
- />
- </PanelSectionRow>
- );
- })}
+ <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}
+ />
{apps.length === 0 && !loading && (
<PanelSectionRow>
<Field label="No Flatpak applications found" />
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index 1f0bc73..2c683ae 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -1,7 +1,7 @@
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 { useEffect, useRef } from "react";
import { GameTarget } from "../hooks/useGameConfiguration";
+import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup";
interface Props {
targets: GameTarget[];
@@ -16,79 +16,10 @@ 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 {}
- }, [collapsed, key]);
-
- return [collapsed, () => setCollapsed((value) => !value)] as const;
-}
-
function targetDescription(game: GameTarget): string {
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>
- ))}
- </>
- );
-}
-
export function GameConfigurationSelector({
targets,
runningGame,
@@ -105,6 +36,11 @@ export function GameConfigurationSelector({
});
const enabledGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
+ const toItem = (game: GameTarget) => ({
+ id: game.appid,
+ label: game.name,
+ description: targetDescription(game),
+ });
const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY);
const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
const enabledToggleRef = useRef<HTMLDivElement>(null);
@@ -146,39 +82,24 @@ 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" />
</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}
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 c067dc0..4c22fb7 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -1,7 +1,8 @@
-import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { Focusable } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
import { GameTarget } from "../hooks/useGameConfiguration";
import { GameConfigurationControls } from "./GameConfigurationControls";
+import { NowPlayingSummary } from "./NowPlayingSummary";
interface Props {
game: GameTarget;
@@ -23,11 +24,10 @@ export function NowPlayingTab({
}: Props) {
return (
<Focusable>
- <PanelSection title="Now Playing">
- <PanelSectionRow>
- <Field label={game.name} description={targetDescription(game)} />
- </PanelSectionRow>
- </PanelSection>
+ <NowPlayingSummary
+ title={game.name}
+ details={[targetDescription(game), `Controls: ${game.name} profile`]}
+ />
<GameConfigurationControls
config={config}
onConfigChange={onConfigChange}