summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/components/ConfigurationTab.tsx28
-rw-r--r--src/components/FpsMultiplierControl.tsx45
-rw-r--r--src/components/GameConfigurationControls.tsx16
-rw-r--r--src/components/GameConfigurationSelector.tsx107
-rw-r--r--src/components/NowPlayingTab.tsx2
5 files changed, 152 insertions, 46 deletions
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index dab6f19..6c4a99a 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -1,5 +1,5 @@
import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { ConfigurationData } from "../config/configSchema";
import { GameTarget } from "../hooks/useGameConfiguration";
import { GameConfigurationControls } from "./GameConfigurationControls";
@@ -27,7 +27,13 @@ export function ConfigurationTab({
onResetAll,
}: ConfigurationTabProps) {
const [detailAppId, setDetailAppId] = useState<string | null>(null);
+ const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false);
const promptedRunningAppId = useRef<string | null>(null);
+ const closeDetails = useCallback(() => {
+ setFocusFpsMultiplier(false);
+ setDetailAppId(null);
+ }, []);
+ const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []);
useEffect(() => {
if (!runningGame || runningGame.configured) {
@@ -59,22 +65,28 @@ export function ConfigurationTab({
}
const profileLabel = selectedTarget?.name || "Game profile";
- const running = selectedTarget?.appid === runningGame?.appid;
const profileDescription = selectedTarget
- ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? running && !runningGame?.configured ? "Profile saved · applies next launch" : "Profile active" : "Not configured · changes apply next launch"}`
+ ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "Configured" : "Not configured"}`
: "Game is no longer available";
return (
- <Focusable onCancelButton={() => setDetailAppId(null)}>
+ <Focusable onCancelButton={closeDetails}>
<PanelSection title="Game Profile">
<PanelSectionRow>
<Field label={profileLabel} description={profileDescription} />
</PanelSectionRow>
<PanelSectionRow>
- <ButtonItem layout="below" onClick={() => setDetailAppId(null)}>Back to games</ButtonItem>
+ <ButtonItem layout="below" onClick={closeDetails}>Back to games</ButtonItem>
</PanelSectionRow>
</PanelSection>
- <GameConfigurationControls config={config} onConfigChange={onConfigChange} />
+ {selectedTarget?.configured && (
+ <GameConfigurationControls
+ config={config}
+ onConfigChange={onConfigChange}
+ autoFocusFpsMultiplier={focusFpsMultiplier}
+ onFpsMultiplierFocused={clearFpsFocusRequest}
+ />
+ )}
<PanelSectionRow>
<ButtonItem
layout="below"
@@ -82,9 +94,9 @@ export function ConfigurationTab({
if (selectedTarget?.configured) {
promptedRunningAppId.current = detailAppId;
await onReset();
- setDetailAppId(null);
+ closeDetails();
} else if (detailAppId) {
- await onEnable(detailAppId);
+ if (await onEnable(detailAppId)) setFocusFpsMultiplier(true);
}
}}
>
diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx
index e197dc3..1fd0e78 100644
--- a/src/components/FpsMultiplierControl.tsx
+++ b/src/components/FpsMultiplierControl.tsx
@@ -1,4 +1,5 @@
-import { PanelSectionRow, SliderField } 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";
@@ -6,28 +7,48 @@ import t from "../i18n/i18n";
interface FpsMultiplierControlProps {
config: ConfigurationData;
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>
- <SliderField
- label={`FPS multiplier · ${multiplierLabel}`}
- value={config.multiplier}
- min={1}
- max={4}
- step={1}
- notchCount={4}
- showValue={false}
- onChange={(value) => void onConfigChange(MULTIPLIER, value)}
- />
+ <Focusable ref={focusableRef} noFocusRing>
+ <SliderField
+ label={`FPS multiplier · ${multiplierLabel}`}
+ value={config.multiplier}
+ min={1}
+ max={4}
+ step={1}
+ notchCount={4}
+ showValue={false}
+ onChange={(value) => void onConfigChange(MULTIPLIER, value)}
+ />
+ </Focusable>
</PanelSectionRow>
);
}
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
index bdf9985..34b82c5 100644
--- a/src/components/GameConfigurationControls.tsx
+++ b/src/components/GameConfigurationControls.tsx
@@ -5,12 +5,24 @@ import { FpsMultiplierControl } from "./FpsMultiplierControl";
interface Props {
config: ConfigurationData;
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
+ autoFocusFpsMultiplier?: boolean;
+ onFpsMultiplierFocused?: () => void;
}
-export function GameConfigurationControls({ config, onConfigChange }: Props) {
+export function GameConfigurationControls({
+ config,
+ onConfigChange,
+ autoFocusFpsMultiplier,
+ onFpsMultiplierFocused,
+}: Props) {
return (
<>
- <FpsMultiplierControl config={config} onConfigChange={onConfigChange} />
+ <FpsMultiplierControl
+ config={config}
+ onConfigChange={onConfigChange}
+ autoFocus={autoFocusFpsMultiplier}
+ onAutoFocus={onFpsMultiplierFocused}
+ />
<ConfigurationSection config={config} onConfigChange={onConfigChange} />
</>
);
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index fcdd0aa..982fcef 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -1,4 +1,6 @@
import { ButtonItem, Field, PanelSectionRow } from "@decky/ui";
+import { useEffect, useState } from "react";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
import { GameTarget } from "../hooks/useGameConfiguration";
interface Props {
@@ -8,42 +10,101 @@ interface Props {
onResetAll: () => Promise<void>;
}
-const profileDescription = (target: GameTarget, running: boolean, active: boolean) => [
- running ? "Now playing" : "",
- target.nonSteam ? "Non-Steam" : "Steam",
- target.configured
- ? active ? "Profile active" : "Profile saved · applies next launch"
- : "Not configured · changes apply next launch",
-].filter(Boolean).join(" · ");
+const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed";
+const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed";
-export function GameConfigurationSelector({ targets, runningGame, onSelect, onResetAll }: Props) {
- const games = [...targets].sort((a, b) => {
- if (a.appid === runningGame?.appid) return -1;
- if (b.appid === runningGame?.appid) return 1;
- return a.name.localeCompare(b.name);
+function usePersistentCollapsed(key: string) {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(key) === "true";
+ } catch {
+ return false;
+ }
});
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, String(collapsed));
+ } catch {
+ // Persisting the view preference is optional.
+ }
+ }, [collapsed, key]);
+
+ return [collapsed, () => setCollapsed((value) => !value)] as const;
+}
+
+function GameGroup({
+ title,
+ games,
+ collapsed,
+ onToggle,
+ onSelect,
+}: {
+ title: string;
+ games: GameTarget[];
+ collapsed: boolean;
+ onToggle: () => void;
+ onSelect: (appid: string) => void;
+}) {
+ if (games.length === 0) return null;
+
return (
<>
- {games.length === 0 && (
- <PanelSectionRow>
- <Field label="No installed games" description="Steam has not reported any eligible games" />
- </PanelSectionRow>
- )}
- {games.map((game) => (
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={onToggle}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} {title} ({games.length})
+ </ButtonItem>
+ </PanelSectionRow>
+ {!collapsed && games.map((game) => (
<PanelSectionRow key={game.appid}>
<Field
label={game.name}
- description={profileDescription(
- game,
- game.appid === runningGame?.appid,
- game.appid === runningGame?.appid ? runningGame.configured : game.configured,
- )}
+ description={game.nonSteam ? "Non-Steam" : "Steam"}
onActivate={() => onSelect(game.appid)}
highlightOnFocus
/>
</PanelSectionRow>
))}
+ </>
+ );
+}
+
+export function GameConfigurationSelector({ targets, runningGame, onSelect, onResetAll }: 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 configuredGames = sortGames(targets.filter((game) => game.configured));
+ const availableGames = sortGames(targets.filter((game) => !game.configured));
+ const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY);
+ const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
+
+ return (
+ <>
+ {targets.length === 0 && (
+ <PanelSectionRow>
+ <Field label="No installed games" description="Steam has not reported any eligible games" />
+ </PanelSectionRow>
+ )}
+ <GameGroup
+ title="Configured"
+ games={configuredGames}
+ collapsed={configuredCollapsed}
+ onToggle={toggleConfigured}
+ onSelect={onSelect}
+ />
+ <GameGroup
+ title="Available games"
+ games={availableGames}
+ collapsed={availableCollapsed}
+ onToggle={toggleAvailable}
+ onSelect={onSelect}
+ />
<PanelSectionRow>
<ButtonItem
layout="below"
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index 956db4a..b4f0557 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -17,7 +17,7 @@ export function NowPlayingTab({ game, config, onConfigChange, onRemove }: Props)
<PanelSectionRow>
<Field
label={game.name}
- description={`${game.nonSteam ? "Non-Steam" : "Steam"} · App ID ${game.appid} · Profile active`}
+ description={`${game.nonSteam ? "Non-Steam" : "Steam"} · App ID ${game.appid} · Configured`}
/>
</PanelSectionRow>
</PanelSection>