summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/api/lsfgApi.ts1
-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
-rw-r--r--src/hooks/useFlatpakConfiguration.ts61
-rw-r--r--src/styles.ts17
-rw-r--r--src/utils/nowPlaying.ts63
12 files changed, 359 insertions, 173 deletions
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index 3d4890e..f3b7fa1 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -118,6 +118,7 @@ export interface RunningFlatpakApp {
app_id: string;
active: boolean;
pid?: string;
+ start_time?: number | null;
}
export interface FlatpakAppsResult extends ApiResult {
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}
diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts
index d5e6f7e..6ff7a79 100644
--- a/src/hooks/useFlatpakConfiguration.ts
+++ b/src/hooks/useFlatpakConfiguration.ts
@@ -11,8 +11,16 @@ import {
type RunningFlatpakApp,
type WorkaroundState,
} from "../api/lsfgApi";
+import { selectMostRecentRunningFlatpak } from "../utils/nowPlaying";
import { showErrorToast } from "../utils/toastUtils";
+type FlatpakOperationResult = {
+ success: boolean;
+ error?: string | null;
+ config?: LsfgConfig | null;
+ state?: WorkaroundState | null;
+};
+
export function useFlatpakConfiguration(enabled: boolean) {
const [apps, setApps] = useState<FlatpakApp[]>([]);
const [runningApps, setRunningApps] = useState<RunningFlatpakApp[]>([]);
@@ -58,40 +66,61 @@ export function useFlatpakConfiguration(enabled: boolean) {
return () => window.clearInterval(interval);
}, [enabled, pollRunning]);
- const operate = useCallback(async (appId: string, operation: () => Promise<{ success: boolean; error?: string | null }>) => {
- if (busyAppId) return false;
+ const operate = useCallback(async (
+ appId: string,
+ operation: () => Promise<FlatpakOperationResult>,
+ refresh = true,
+ ): Promise<FlatpakOperationResult> => {
+ if (busyAppId) return { success: false };
setBusyAppId(appId);
try {
const result = await operation();
if (!result.success) throw new Error(result.error || "Flatpak operation failed");
- await reload();
- await pollRunning();
- return true;
+ if (refresh) {
+ await reload();
+ await pollRunning();
+ }
+ return result;
} catch (error) {
showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error));
- return false;
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
} finally {
setBusyAppId("");
}
}, [busyAppId, pollRunning, reload]);
- const enableApp = useCallback((appId: string) => operate(appId, () => enableFlatpakApp(appId)), [operate]);
- const removeApp = useCallback((appId: string) => operate(appId, () => removeFlatpakApp(appId)), [operate]);
+ const enableApp = useCallback(async (appId: string) => (
+ await operate(appId, () => enableFlatpakApp(appId))
+ ).success, [operate]);
+ const removeApp = useCallback(async (appId: string) => (
+ await operate(appId, () => removeFlatpakApp(appId))
+ ).success, [operate]);
const updateConfig = useCallback(
- (appId: string, config: LsfgConfig) => operate(appId, () => updateFlatpakConfig(appId, config)),
+ async (appId: string, config: LsfgConfig) => {
+ const result = await operate(appId, () => updateFlatpakConfig(appId, config), false);
+ if (result.success) {
+ setApps((current) => current.map((app) => (
+ app.app_id === appId ? { ...app, config: result.config || config } : app
+ )));
+ }
+ return result.success;
+ },
[operate],
);
const updateWorkarounds = useCallback(
- (appId: string, state: WorkaroundState) => operate(appId, () => setFlatpakWorkaroundState(appId, state)),
+ async (appId: string, state: WorkaroundState) => {
+ const result = await operate(appId, () => setFlatpakWorkaroundState(appId, state), false);
+ if (result.success) {
+ setApps((current) => current.map((app) => (
+ app.app_id === appId ? { ...app, workarounds: result.state || state } : app
+ )));
+ }
+ return result.success;
+ },
[operate],
);
- const runningApp = useMemo(() => {
- if (runningApps.length === 0) return null;
- const running = runningApps.find((app) => app.active) || (runningApps.length === 1 ? runningApps[0] : null);
- if (!running) return null;
- return apps.find((app) => app.app_id === running.app_id) || null;
- }, [apps, runningApps]);
+ const runningApp = useMemo(() => selectMostRecentRunningFlatpak(apps, runningApps), [apps, runningApps]);
return {
apps,
diff --git a/src/styles.ts b/src/styles.ts
index 1bc089b..58c6b01 100644
--- a/src/styles.ts
+++ b/src/styles.ts
@@ -10,6 +10,10 @@ export const tabStyles = `
padding-right: 8px !important;
}
+ .lsfg-vk-tabs .lsfg-vk-tab-content {
+ padding-bottom: 96px; // workaround for in-game bottom bar padding behaving differently than in launcher, remove later?
+ }
+
.lsfg-vk-tabs [role="tablist"] {
display: flex;
flex-wrap: nowrap;
@@ -31,4 +35,17 @@ export const tabStyles = `
display: block;
margin: 0;
}
+
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"],
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div,
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div,
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] [role="tab"] {
+ animation: none !important;
+ transition: none !important;
+ }
+
+ .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div {
+ scroll-behavior: auto !important;
+ scroll-snap-type: none !important;
+ }
`;
diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts
new file mode 100644
index 0000000..2e0a876
--- /dev/null
+++ b/src/utils/nowPlaying.ts
@@ -0,0 +1,63 @@
+import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi";
+import type { GameTarget } from "../hooks/useGameConfiguration";
+
+export type NowPlayingTarget =
+ | {
+ kind: "flatpak";
+ app: FlatpakApp;
+ launcher: GameTarget | null;
+ }
+ | {
+ kind: "steam";
+ game: GameTarget;
+ };
+
+function numericValue(value: number | null | undefined): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : -1;
+}
+
+function numericPid(value: string | undefined): number {
+ return value && /^\d+$/.test(value) ? Number(value) : -1;
+}
+
+export function selectMostRecentRunningFlatpak(
+ apps: FlatpakApp[],
+ runningApps: RunningFlatpakApp[],
+): FlatpakApp | null {
+ const candidates = runningApps
+ .map((running) => ({
+ running,
+ app: apps.find((app) => app.app_id === running.app_id) || null,
+ }))
+ .filter((candidate): candidate is { running: RunningFlatpakApp; app: FlatpakApp } => candidate.app !== null);
+ const activeCandidates = candidates.filter(({ running }) => running.active);
+ const eligibleCandidates = activeCandidates.length > 0
+ ? activeCandidates
+ : candidates.length === 1
+ ? candidates
+ : [];
+
+ eligibleCandidates.sort((a, b) => {
+ const startDifference = numericValue(b.running.start_time) - numericValue(a.running.start_time);
+ if (startDifference !== 0) return startDifference;
+ const pidDifference = numericPid(b.running.pid) - numericPid(a.running.pid);
+ if (pidDifference !== 0) return pidDifference;
+ return a.running.app_id.localeCompare(b.running.app_id);
+ });
+
+ return eligibleCandidates[0]?.app || null;
+}
+
+export function resolveNowPlayingTarget(
+ runningGame: GameTarget | null,
+ runningFlatpak: FlatpakApp | null,
+): NowPlayingTarget | null {
+ if (runningGame && !runningGame.nonSteam) {
+ return runningGame.configured ? { kind: "steam", game: runningGame } : null;
+ }
+ if (runningFlatpak) {
+ return { kind: "flatpak", app: runningFlatpak, launcher: runningGame?.nonSteam ? runningGame : null };
+ }
+ if (runningGame?.configured) return { kind: "steam", game: runningGame };
+ return null;
+}