summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md41
-rw-r--r--defaults/i18n/ja.json5
-rw-r--r--defaults/i18n/ko.json5
-rw-r--r--defaults/i18n/template.json5
-rw-r--r--py_modules/lsfg_vk/config_schema.py52
-rw-r--r--py_modules/lsfg_vk/configuration.py56
-rw-r--r--py_modules/lsfg_vk/installation.py6
-rw-r--r--py_modules/lsfg_vk/plugin.py128
-rw-r--r--py_modules/lsfg_vk/types.py31
-rw-r--r--src/api/lsfgApi.ts36
-rw-r--r--src/components/ConfigurationSection.tsx10
-rw-r--r--src/components/ConfigurationTab.tsx86
-rw-r--r--src/components/Content.tsx45
-rw-r--r--src/components/FgmodClipboardButton.tsx110
-rw-r--r--src/components/FpsMultiplierControl.tsx70
-rw-r--r--src/components/GameConfigurationControls.tsx17
-rw-r--r--src/components/GameConfigurationSelector.tsx67
-rw-r--r--src/components/NowPlayingTab.tsx32
-rw-r--r--src/components/index.ts3
-rw-r--r--src/hooks/useGameConfiguration.ts61
-rw-r--r--src/hooks/useLsfgHooks.ts61
-rw-r--r--src/i18n/languages.json15
-rw-r--r--src/styles.ts2
-rw-r--r--src/utils/clipboardUtils.ts64
-rw-r--r--src/utils/toastUtils.ts22
25 files changed, 302 insertions, 728 deletions
diff --git a/README.md b/README.md
index 4be9cca..13918fd 100644
--- a/README.md
+++ b/README.md
@@ -34,56 +34,19 @@ A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scali
2. **Open the plugin** from the Decky menu
3. **Click "Install lsfg-vk"** to automatically set up the lsfg-vk vulkan layer
4. **Configure settings** using the plugin's UI - select Default or a running/configured game and adjust the upstream lsfg-vk settings
-5. **Apply launch option** to games you want to use frame generation with:
- - Add `~/lsfg %command%` to your game's launch options in Steam Properties
- - Or use the "Launch Option Clipboard" button in the plugin to copy the command
6. **Launch your game** - frame generation activates when the game's Steam AppID matches an assigned upstream profile
-## Configuration Options
-
-The plugin edits upstream lsfg-vk v2 profiles directly. Unassigned games leave the layer unloaded.
-
### Core Settings
+
- **FPS Multiplier**: Choose 2x, 3x, or 4x frame generation
- **Flow Scale**: Adjust motion estimation quality (lower = better performance, higher = better quality)
-- **Performance Mode**: Uses a lighter processing model - recommended for most games
+- **Performance Mode**: Uses a lighter processing model (recommended for most games)
- **FP16 Acceleration**: Use half-precision acceleration when supported
## Feedback and Support
For per-game feedback and community support, please join the [decky-lsfg-vk Discord Channel](https://discord.gg/TwvHdVucC3)
-## Troubleshooting
-
-**Frame generation not working?**
-- Ensure you've added `~/lsfg %command%` to your game's launch options
-- Ensure the Lossless Scaling installation is visible to the upstream lsfg-vk search paths
-- Try enabling Performance Mode if you're experiencing crashes
-- Make sure your game is running in fullscreen mode for best results
-
-**Performance issues?**
-- Lower the Flow Scale setting for better performance
-- Enable Performance Mode (recommended for most games)
-- Try reducing the FPS multiplier from 4x to 2x or 3x
-- Consider using the experimental FPS limit feature for DirectX games
-
-## What it does
-
-The plugin:
-- Installs the pinned lsfg-vk 2.0.0 x86_64 and x86 Vulkan layers from the official upstream build
-- Configures the Vulkan layer in `~/.local/share/vulkan/implicit_layer.d/`
-- Creates and migrates the v2 TOML configuration in `~/.config/lsfg-vk/conf.toml`, preserving a one-time v1 backup during upgrade
-- Delegates Lossless Scaling DLL discovery to lsfg-vk's upstream runtime search
-- Provides an easy-to-use interface to configure frame generation settings:
- - **FPS Multiplier**: Use OFF/1x bypass or choose 2x, 3x, or 4x frame generation
- - **Flow Scale**: Adjust motion estimation quality vs performance
- - **Performance Mode**: Use lighter processing for better performance
- - **FP16 Acceleration**: Use half-precision acceleration when supported
-- **Present Mode**: Override FIFO/VSync behavior
-- **Swapchain Image Count**: Preserve the application's swapchain image count
-- **Hot-reloading**: Upstream reloads settings for the active profile; profile assignment itself applies on the next launch
-- Easy uninstallation that removes all installed files when no longer needed
-
## Credits
- **[PancakeTAS](https://lsfg-vk.dev/)** for creating the lsfg-vk Vulkan compatibility layer
diff --git a/defaults/i18n/ja.json b/defaults/i18n/ja.json
index 7b97bc5..92a8701 100644
--- a/defaults/i18n/ja.json
+++ b/defaults/i18n/ja.json
@@ -87,8 +87,5 @@
"PROFILE_DELETE_DESC_SUFFIX": "この操作は取り消せません。",
"PROFILE_DELETE_BTN": "削除",
"PROFILE_CANNOT_RENAME_TITLE": "デフォルトプロファイルの名前は変更できません",
- "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません",
- "CLIPBOARD_COPIED": "クリップボードにコピーしました",
- "CLIPBOARD_COPYING": "コピー中...",
- "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG"
+ "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません"
}
diff --git a/defaults/i18n/ko.json b/defaults/i18n/ko.json
index cbc3a8e..947640d 100644
--- a/defaults/i18n/ko.json
+++ b/defaults/i18n/ko.json
@@ -87,8 +87,5 @@
"PROFILE_DELETE_DESC_SUFFIX": "이 작업은 취소할 수 없습니다.",
"PROFILE_DELETE_BTN": "삭제",
"PROFILE_CANNOT_RENAME_TITLE": "기본 프로필 이름 변경 불가",
- "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다",
- "CLIPBOARD_COPIED": "클립보드에 복사됨",
- "CLIPBOARD_COPYING": "복사 중...",
- "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG"
+ "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다"
}
diff --git a/defaults/i18n/template.json b/defaults/i18n/template.json
index b9dfcc5..fce97b4 100644
--- a/defaults/i18n/template.json
+++ b/defaults/i18n/template.json
@@ -87,8 +87,5 @@
"PROFILE_DELETE_DESC_SUFFIX": "? This action cannot be undone.",
"PROFILE_DELETE_BTN": "Delete",
"PROFILE_CANNOT_RENAME_TITLE": "Cannot rename default profile",
- "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed",
- "CLIPBOARD_COPIED": "Copied to clipboard",
- "CLIPBOARD_COPYING": "Copying...",
- "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG"
+ "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed"
}
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py
index 4ae1f4c..675ab61 100644
--- a/py_modules/lsfg_vk/config_schema.py
+++ b/py_modules/lsfg_vk/config_schema.py
@@ -4,18 +4,14 @@ import json
import sys
import tomllib
from pathlib import Path
-from typing import Any, Dict, TypedDict, cast
+from typing import Any, Dict, TypedDict
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
-DEFAULT_PROFILE_NAME = "default"
ConfigurationData = Dict[str, Any]
class ProfileData(TypedDict):
- # Internal compatibility field; the public API no longer exposes a
- # currently selected profile.
- current_profile: str
profiles: Dict[str, Dict[str, Any]]
global_config: Dict[str, Any]
@@ -58,19 +54,6 @@ class ConfigurationManager:
return {**GLOBAL_DEFAULTS, **PROFILE_DEFAULTS}
@staticmethod
- def get_field_names() -> list[str]:
- return list(ConfigurationManager.get_defaults())
-
- @staticmethod
- def get_field_types() -> Dict[str, str]:
- return {
- "dll": "string", "no_fp16": "boolean", "active_in": "array",
- "pacing_mode": "string", "multiplier": "integer", "flow_scale": "float",
- "performance_mode": "boolean", "override_present_mode": "boolean",
- "preserve_swapchain_image_count": "boolean",
- }
-
- @staticmethod
def validate_config(config: Dict[str, Any]) -> Dict[str, Any]:
result = {**PROFILE_DEFAULTS, **GLOBAL_DEFAULTS}
result.update({key: value for key, value in config.items() if key in result})
@@ -111,15 +94,6 @@ class ConfigurationManager:
return ConfigurationManager.validate_config(raw)
@staticmethod
- def generate_toml_content(config: Dict[str, Any]) -> str:
- data: ProfileData = {
- "current_profile": DEFAULT_PROFILE_NAME,
- "profiles": {DEFAULT_PROFILE_NAME: dict(config)},
- "global_config": {"dll": config.get("dll", ""), "no_fp16": config.get("no_fp16", False)},
- }
- return ConfigurationManager.generate_toml_content_multi_profile(data)
-
- @staticmethod
def generate_toml_content_multi_profile(profile_data: ProfileData) -> str:
global_config = {**GLOBAL_DEFAULTS, **profile_data.get("global_config", {})}
lines = ["version = 2", "", "[global]"]
@@ -127,7 +101,9 @@ class ConfigurationManager:
if dll:
lines.append(f"dll = {_toml_value(dll)}")
lines.append(f"allow_fp16 = {_toml_value(not bool(global_config.get('no_fp16', False)))}")
- profiles = sorted(profile_data["profiles"].items(), key=lambda item: (item[0] != DEFAULT_PROFILE_NAME, item[0]))
+ profiles = sorted(profile_data["profiles"].items())
+ if not profiles:
+ profiles = [("", {})]
for name, raw in profiles:
config = ConfigurationManager.validate_config({**raw, **global_config})
lines.extend(["", "[[profile]]", f"name = {_toml_value(name)}"])
@@ -157,16 +133,11 @@ class ConfigurationManager:
profiles: Dict[str, Dict[str, Any]] = {}
source_profiles = data.get("game", []) if version == 1 else data.get("profile", [])
for profile in source_profiles:
- name = str(profile.get("exe" if version == 1 else "name", DEFAULT_PROFILE_NAME))
- profiles[name] = ConfigurationManager._config_from_profile(profile, global_config)
- if not profiles:
- profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.validate_config(global_config)
- elif DEFAULT_PROFILE_NAME not in profiles:
- source = profiles.get("decky-lsfg-vk", next(iter(profiles.values())))
- profiles[DEFAULT_PROFILE_NAME] = {**source, "active_in": []}
- if profiles.get("decky-lsfg-vk", {}).get("active_in", []) == []:
- profiles.pop("decky-lsfg-vk", None)
- return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": profiles, "global_config": global_config}
+ name = str(profile.get("exe" if version == 1 else "name", ""))
+ config = ConfigurationManager._config_from_profile(profile, global_config)
+ if config["active_in"]:
+ profiles[name] = config
+ return {"profiles": profiles, "global_config": global_config}
@staticmethod
def is_legacy_v1(content: str) -> bool:
@@ -174,8 +145,3 @@ class ConfigurationManager:
return tomllib.loads(content).get("version") == 1
except tomllib.TOMLDecodeError:
return False
-
- @staticmethod
- def parse_toml_content(content: str) -> Dict[str, Any]:
- data = ConfigurationManager.parse_toml_content_multi_profile(content)
- return cast(Dict[str, Any], data["profiles"][DEFAULT_PROFILE_NAME])
diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py
index a4ee2cc..bec3828 100644
--- a/py_modules/lsfg_vk/configuration.py
+++ b/py_modules/lsfg_vk/configuration.py
@@ -2,7 +2,7 @@ import re
from typing import Any, Dict
from .base_service import BaseService
-from .config_schema import ConfigurationManager, DEFAULT_PROFILE_NAME, ProfileData
+from .config_schema import ConfigurationManager, ProfileData
from .runtime_service import RuntimeService
@@ -14,8 +14,7 @@ class ConfigurationService(BaseService):
self.runtime_service = runtime_service or RuntimeService(logger=self.log)
def _default_data(self) -> ProfileData:
- defaults = ConfigurationManager.validate_config({})
- return {"current_profile": DEFAULT_PROFILE_NAME, "profiles": {DEFAULT_PROFILE_NAME: defaults}, "global_config": {"dll": "", "no_fp16": False}}
+ return {"profiles": {}, "global_config": {"dll": "", "no_fp16": False}}
def _get_profile_data(self) -> ProfileData:
if not self.config_file_path.exists():
@@ -32,8 +31,6 @@ class ConfigurationService(BaseService):
name = str(game_name).strip()
if not name:
raise ValueError("game name is required")
- if name == DEFAULT_PROFILE_NAME:
- name = f"{name} ({appid})"
existing = data["profiles"].get(name)
if existing is not None and str(appid) not in existing.get("active_in", []):
name = f"{name} ({appid})"
@@ -54,14 +51,6 @@ class ConfigurationService(BaseService):
def _public_config(config: Dict[str, Any]) -> Dict[str, Any]:
return ConfigurationManager.validate_config(config)
- def get_config(self) -> Dict[str, Any]:
- try:
- data = self._get_profile_data()
- return self._success_response(dict, config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]))
- except Exception as error:
- self.log.error(f"Error reading lsfg config: {error}")
- return self._error_response(dict, str(error), config=None)
-
def get_game_configs(self) -> Dict[str, Any]:
try:
data = self._get_profile_data()
@@ -71,26 +60,25 @@ class ConfigurationService(BaseService):
if len(active_in) != 1 or not re.fullmatch(r"-?[0-9]+", str(active_in[0])):
continue
games.append({"appid": str(active_in[0]), "profile": name, "config": self._public_config(raw)})
- return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=games)
+ return self._success_response(dict, global_config=dict(data["global_config"]), games=games)
except Exception as error:
self.log.error(f"Error reading game configs: {error}")
- return self._error_response(dict, str(error), default=None, games=[])
-
- def get_game_config(self, appid: str) -> Dict[str, Any]:
- try:
- data = self._get_profile_data()
- _, profile = self._profile_for_appid(data, appid)
- return self._success_response(dict, appid=str(appid), exists=profile is not None, config=self._public_config(profile or data["profiles"][DEFAULT_PROFILE_NAME]))
- except Exception as error:
- return self._error_response(dict, str(error), appid=str(appid), exists=False, config=None)
+ return self._error_response(dict, str(error), games=[])
def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
try:
data = self._get_profile_data()
old_name, _ = self._profile_for_appid(data, appid)
name = self._profile_name(data, appid, game_name)
- validated = self._public_config(config)
+ merged_config = {**data["global_config"], **config}
+ if not config.get("dll"):
+ merged_config["dll"] = data["global_config"].get("dll", "")
+ validated = self._public_config(merged_config)
validated["active_in"] = [str(appid)]
+ data["global_config"] = {
+ "dll": validated["dll"],
+ "no_fp16": validated["no_fp16"],
+ }
if old_name and old_name != name:
data["profiles"].pop(old_name, None)
data["profiles"][name] = validated
@@ -106,27 +94,15 @@ class ConfigurationService(BaseService):
if name:
data["profiles"].pop(name, None)
self._save_profile_data(data)
- return self._success_response(dict, appid=str(appid), config=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]))
+ return self._success_response(dict, appid=str(appid), exists=False)
except Exception as error:
return self._error_response(dict, str(error), appid=str(appid), config=None)
def reset_all_game_configs(self) -> Dict[str, Any]:
try:
data = self._get_profile_data()
- data["profiles"] = {DEFAULT_PROFILE_NAME: data["profiles"][DEFAULT_PROFILE_NAME]}
- self._save_profile_data(data)
- return self._success_response(dict, default=self._public_config(data["profiles"][DEFAULT_PROFILE_NAME]), games=[])
- except Exception as error:
- return self._error_response(dict, str(error), default=None, games=[])
-
- def update_config_from_dict(self, config: Dict[str, Any]) -> Dict[str, Any]:
- try:
- data = self._get_profile_data()
- validated = self._public_config(config)
- validated["active_in"] = []
- data["profiles"][DEFAULT_PROFILE_NAME] = validated
- data["global_config"] = {"dll": validated.get("dll", ""), "no_fp16": validated.get("no_fp16", False)}
+ data["profiles"] = {}
self._save_profile_data(data)
- return self._success_response(dict, config=validated)
+ return self._success_response(dict, global_config=dict(data["global_config"]), games=[])
except Exception as error:
- return self._error_response(dict, str(error), config=None)
+ return self._error_response(dict, str(error), games=[])
diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py
index 0a728c7..09bd3a3 100644
--- a/py_modules/lsfg_vk/installation.py
+++ b/py_modules/lsfg_vk/installation.py
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Dict
from .base_service import BaseService
-from .config_schema import ConfigurationManager, DEFAULT_PROFILE_NAME, ProfileData
+from .config_schema import ConfigurationManager, ProfileData
from .constants import (
ARCHIVE_FILENAME,
BIN_DIR,
@@ -141,8 +141,7 @@ class InstallationService(BaseService):
else:
default = dict(ConfigurationManager.get_defaults())
profile_data = ProfileData(
- current_profile=DEFAULT_PROFILE_NAME,
- profiles={DEFAULT_PROFILE_NAME: default},
+ profiles={},
global_config={
"dll": default.get("dll", ""),
"no_fp16": default.get("no_fp16", False),
@@ -155,7 +154,6 @@ class InstallationService(BaseService):
profile_data["profiles"][profile_name] = ConfigurationManager.validate_config(
{**defaults, **raw_profile, **profile_data["global_config"]}
)
- profile_data["current_profile"] = DEFAULT_PROFILE_NAME
return profile_data
def _resolve_dll_path(self, profile_data: ProfileData) -> bool:
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index 8c788a8..6977b9c 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -7,13 +7,11 @@ Vulkan layer for frame generation on Steam Deck.
import os
from typing import Dict, Any
-from pathlib import Path
import decky
from .installation import InstallationService
from .configuration import ConfigurationService
-from .config_schema import ConfigurationManager
from .flatpak_service import FlatpakService
from .runtime_service import RuntimeService
from .steam_service import SteamService
@@ -63,46 +61,12 @@ class Plugin:
"""
return self.installation_service.uninstall()
- async def get_lsfg_config(self) -> Dict[str, Any]:
- """Read current lsfg script configuration
-
- Returns:
- ConfigurationResponse dict with current configuration or error
- """
- return self.configuration_service.get_config()
-
- async def get_config_schema(self) -> Dict[str, Any]:
- """Get configuration schema information for frontend
-
- Returns:
- Dict with field names, types, defaults, and profile information
- """
- return {
- "field_names": ConfigurationManager.get_field_names(),
- "field_types": ConfigurationManager.get_field_types(),
- "defaults": ConfigurationManager.get_defaults(),
- }
-
- async def update_lsfg_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
- """Update lsfg TOML configuration using object-based API (single source of truth)
-
- Args:
- config: Configuration data dictionary containing all settings
-
- Returns:
- ConfigurationResponse dict with success status
- """
- return self.configuration_service.update_config_from_dict(config)
-
async def get_game_configs(self) -> Dict[str, Any]:
return self.configuration_service.get_game_configs()
async def get_installed_games(self) -> Dict[str, Any]:
return self.steam_service.get_installed_games()
- async def get_game_config(self, appid: str) -> Dict[str, Any]:
- return self.configuration_service.get_game_config(appid)
-
async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
return self.configuration_service.update_game_config(appid, game_name, config)
@@ -112,72 +76,6 @@ class Plugin:
async def reset_all_game_configs(self) -> Dict[str, Any]:
return self.configuration_service.reset_all_game_configs()
- async def _legacy_get_profiles(self) -> Dict[str, Any]:
- """Get list of all profiles and current profile
-
- Returns:
- ProfilesResponse dict with profile list and current profile
- """
- return self.configuration_service.get_game_configs()
-
- async def _legacy_create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]:
- """Create a new profile
-
- Args:
- profile_name: Name for the new profile
- source_profile: Optional source profile to copy from (default: current profile)
-
- Returns:
- ProfileResponse dict with success status
- """
- return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"}
-
- async def _legacy_delete_profile(self, profile_name: str) -> Dict[str, Any]:
- """Delete a profile
-
- Args:
- profile_name: Name of the profile to delete
-
- Returns:
- ProfileResponse dict with success status
- """
- return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"}
-
- async def _legacy_rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]:
- """Rename a profile
-
- Args:
- old_name: Current profile name
- new_name: New profile name
-
- Returns:
- ProfileResponse dict with success status
- """
- return {"success": False, "error": "Named profiles were replaced by per-game AppID profiles"}
-
- async def _legacy_set_current_profile(self, profile_name: str) -> Dict[str, Any]:
- """Set the current active profile
-
- Args:
- profile_name: Name of the profile to set as current
-
- Returns:
- ProfileResponse dict with success status
- """
- return {"success": False, "error": "There is no globally selected profile"}
-
- async def _legacy_update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
- """Update configuration for a specific profile
-
- Args:
- profile_name: Name of the profile to update
- config: Configuration data dictionary containing settings
-
- Returns:
- ConfigurationResponse dict with success status
- """
- return {"success": False, "error": "Use update_game_config with a Steam AppID"}
-
async def get_config_file_content(self) -> Dict[str, Any]:
"""Get the current config file content
@@ -209,32 +107,6 @@ class Plugin:
"error": f"Error reading config file: {str(e)}"
}
- async def check_fgmod_directory(self) -> Dict[str, Any]:
- """Check if the fgmod directory exists in the home directory
-
- Returns:
- Dict with exists status and directory path
- """
- try:
- home_path = Path(decky.DECKY_USER_HOME)
- fgmod_path = home_path / "fgmod"
-
- exists = fgmod_path.exists() and fgmod_path.is_dir()
-
- return {
- "success": True,
- "exists": exists,
- "path": str(fgmod_path)
- }
-
- except Exception as e:
- decky.logger.error(f"Error checking fgmod directory: {e}")
- return {
- "success": False,
- "exists": False,
- "error": str(e)
- }
-
async def check_flatpak_extension_status(self) -> Dict[str, Any]:
"""Check status of lsfg-vk Flatpak runtime extensions
diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py
index ef6bab7..7b85708 100644
--- a/py_modules/lsfg_vk/types.py
+++ b/py_modules/lsfg_vk/types.py
@@ -2,8 +2,7 @@
Type definitions for the lsfg-vk plugin responses.
"""
-from typing import TypedDict, Optional, List, Dict, Any
-from .config_schema import ConfigurationData
+from typing import TypedDict, Optional, List
class BaseResponse(TypedDict):
@@ -53,31 +52,3 @@ class SteamBranchStatusResponse(TypedDict):
target_branch: str
needs_switch: bool
restart_required: bool
-
-class ConfigurationResponse(BaseResponse):
- """Response for configuration operations"""
- config: Optional[ConfigurationData]
- message: Optional[str]
- error: Optional[str]
-
-
-class ProfileConfig(TypedDict):
- """Configuration for a single profile"""
- exe: str
- config: ConfigurationData
-
-
-class ProfilesResponse(BaseResponse):
- """Response for per-game upstream profiles"""
- default: Optional[ConfigurationData]
- games: Optional[List[Dict[str, Any]]]
- message: Optional[str]
- error: Optional[str]
-
-
-class ProfileResponse(BaseResponse):
- """Response for a per-game upstream profile"""
- appid: Optional[str]
- config: Optional[ConfigurationData]
- message: Optional[str]
- error: Optional[str]
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index 04d1309..8eaad98 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -32,12 +32,6 @@ export interface SteamBranchStatus {
// Use centralized configuration data type
export type LsfgConfig = ConfigurationData;
-export interface ConfigResult {
- success: boolean;
- config?: LsfgConfig;
- error?: string;
-}
-
export interface ConfigUpdateResult {
success: boolean;
message?: string;
@@ -51,10 +45,11 @@ export interface GameConfigEntry {
}
export interface InstalledGame { appid: string; name: string; nonSteam: boolean; }
export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; }
+export interface GlobalConfig { dll: string; no_fp16: boolean; }
export interface GameConfigsResult {
success: boolean;
- default?: LsfgConfig;
+ global_config?: GlobalConfig;
games?: GameConfigEntry[];
error?: string;
}
@@ -65,12 +60,6 @@ export interface GameConfigResult extends ConfigUpdateResult {
config?: LsfgConfig;
}
-export interface ConfigSchemaResult {
- field_names: string[];
- field_types: Record<string, string>;
- defaults: ConfigurationData;
-}
-
export interface FileContentResult {
success: boolean;
content?: string;
@@ -78,13 +67,6 @@ export interface FileContentResult {
error?: string;
}
-export interface FgmodCheckResult {
- success: boolean;
- exists: boolean;
- path?: string;
- error?: string;
-}
-
// Flatpak management interfaces
export interface FlatpakExtensionStatus {
success: boolean;
@@ -123,10 +105,7 @@ export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk")
export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk");
export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed");
export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status");
-export const getLsfgConfig = callable<[], ConfigResult>("get_lsfg_config");
-export const getConfigSchema = callable<[], ConfigSchemaResult>("get_config_schema");
export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content");
-export const checkFgmodDirectory = callable<[], FgmodCheckResult>("check_fgmod_directory");
// Flatpak management API functions
export const checkFlatpakExtensionStatus = callable<[], FlatpakExtensionStatus>("check_flatpak_extension_status");
@@ -136,19 +115,8 @@ export const getFlatpakApps = callable<[], FlatpakAppInfo>("get_flatpak_apps");
export const setFlatpakAppOverride = callable<[string], FlatpakOperationResult>("set_flatpak_app_override");
export const removeFlatpakAppOverride = callable<[string], FlatpakOperationResult>("remove_flatpak_app_override");
-// Updated config function using object-based configuration (single source of truth)
-export const updateLsfgConfig = callable<
- [ConfigurationData],
- ConfigUpdateResult
->("update_lsfg_config");
export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs");
export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games");
-export const getGameConfig = callable<[string], GameConfigResult>("get_game_config");
export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config");
export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config");
export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs");
-
-// Legacy helper function for backward compatibility
-export const updateLsfgConfigFromObject = async (config: ConfigurationData): Promise<ConfigUpdateResult> => {
- return updateLsfgConfig(config);
-};
diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx
index c2bba7e..1f17264 100644
--- a/src/components/ConfigurationSection.tsx
+++ b/src/components/ConfigurationSection.tsx
@@ -10,19 +10,19 @@ interface ConfigurationSectionProps {
export function ConfigurationSection({ config, onConfigChange }: ConfigurationSectionProps) {
return <>
<PanelSectionRow>
- <SliderField label={`Flow Scale (${Math.round(config.flow_scale * 100)}%)`} description="Motion estimation resolution scale" value={config.flow_scale} min={0.25} max={1} step={0.01} onChange={(value) => onConfigChange(FLOW_SCALE, value)} />
+ <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" description="Use FP16 shaders when supported" checked={!config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, !value)} />
+ <ToggleField label="FP16 Acceleration" checked={!config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, !value)} />
</PanelSectionRow>
<PanelSectionRow>
- <ToggleField label="Performance Mode" description="Use the lighter frame generation model" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} />
+ <ToggleField label="Performance Mode" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} />
</PanelSectionRow>
<PanelSectionRow>
- <ToggleField label="Present Mode Override" description="Force FIFO/VSync pacing" checked={config.override_present_mode} onChange={(value) => onConfigChange(OVERRIDE_PRESENT_MODE, value)} />
+ <ToggleField label="Present Mode Override" checked={config.override_present_mode} onChange={(value) => onConfigChange(OVERRIDE_PRESENT_MODE, value)} />
</PanelSectionRow>
<PanelSectionRow>
- <ToggleField label="Preserve Swapchain Image Count" description="Do not change the application's swapchain image count" checked={config.preserve_swapchain_image_count} onChange={(value) => onConfigChange(PRESERVE_SWAPCHAIN_IMAGE_COUNT, value)} />
+ <ToggleField label="Preserve Swapchain Image Count" checked={config.preserve_swapchain_image_count} onChange={(value) => onConfigChange(PRESERVE_SWAPCHAIN_IMAGE_COUNT, value)} />
</PanelSectionRow>
</>;
}
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 6f3f474..dab6f19 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -1,19 +1,17 @@
-import { PanelSection } from "@decky/ui";
+import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { useEffect, useRef, useState } from "react";
import { ConfigurationData } from "../config/configSchema";
import { GameTarget } from "../hooks/useGameConfiguration";
-import { ConfigurationSection } from "./ConfigurationSection";
-import { FgmodClipboardButton } from "./FgmodClipboardButton";
-import { FpsMultiplierControl } from "./FpsMultiplierControl";
+import { GameConfigurationControls } from "./GameConfigurationControls";
import { GameConfigurationSelector } from "./GameConfigurationSelector";
-import t from "../i18n/i18n";
interface ConfigurationTabProps {
config: ConfigurationData;
targets: GameTarget[];
runningGame: GameTarget | null;
- selectedAppId: string;
onSelect: (appid: string) => void;
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
+ onEnable: (appid: string) => Promise<boolean>;
onReset: () => Promise<void>;
onResetAll: () => Promise<void>;
}
@@ -22,33 +20,77 @@ export function ConfigurationTab({
config,
targets,
runningGame,
- selectedAppId,
onSelect,
onConfigChange,
+ onEnable,
onReset,
onResetAll,
}: ConfigurationTabProps) {
- return (
- <>
- <PanelSection title={t("CONTENT_FPS_MULTIPLIER", "FPS Multiplier")}>
- <FpsMultiplierControl config={config} onConfigChange={onConfigChange} />
- </PanelSection>
- <PanelSection title="Game Profile">
+ const [detailAppId, setDetailAppId] = useState<string | null>(null);
+ const promptedRunningAppId = useRef<string | null>(null);
+
+ useEffect(() => {
+ if (!runningGame || runningGame.configured) {
+ promptedRunningAppId.current = null;
+ return;
+ }
+ if (promptedRunningAppId.current !== runningGame.appid && detailAppId === null) {
+ promptedRunningAppId.current = runningGame.appid;
+ setDetailAppId(runningGame.appid);
+ }
+ }, [detailAppId, runningGame?.appid, runningGame?.configured]);
+
+ const selectedTarget = detailAppId ? targets.find((target) => target.appid === detailAppId) : null;
+
+ if (detailAppId === null) {
+ return (
+ <PanelSection title="Games">
<GameConfigurationSelector
targets={targets}
runningGame={runningGame}
- selectedAppId={selectedAppId}
- onSelect={onSelect}
- onReset={onReset}
+ onSelect={(appid) => {
+ onSelect(appid);
+ setDetailAppId(appid);
+ }}
onResetAll={onResetAll}
/>
</PanelSection>
- <PanelSection title="Options">
- <ConfigurationSection config={config} onConfigChange={onConfigChange} />
- </PanelSection>
- <PanelSection>
- <FgmodClipboardButton />
+ );
+ }
+
+ 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"}`
+ : "Game is no longer available";
+
+ return (
+ <Focusable onCancelButton={() => setDetailAppId(null)}>
+ <PanelSection title="Game Profile">
+ <PanelSectionRow>
+ <Field label={profileLabel} description={profileDescription} />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={() => setDetailAppId(null)}>Back to games</ButtonItem>
+ </PanelSectionRow>
</PanelSection>
- </>
+ <GameConfigurationControls config={config} onConfigChange={onConfigChange} />
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ onClick={async () => {
+ if (selectedTarget?.configured) {
+ promptedRunningAppId.current = detailAppId;
+ await onReset();
+ setDetailAppId(null);
+ } else if (detailAppId) {
+ await onEnable(detailAppId);
+ }
+ }}
+ >
+ {selectedTarget?.configured ? "Remove profile" : "Enable for next launch"}
+ </ButtonItem>
+ </PanelSectionRow>
+ </Focusable>
);
}
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index de8f996..9281d39 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,6 +1,6 @@
import { Tabs } from "@decky/ui";
-import { useEffect, useState } from "react";
-import { FaFileAlt, FaGamepad, FaLayerGroup, FaTools } from "react-icons/fa";
+import { useEffect, useRef, useState } from "react";
+import { FaFileAlt, FaGamepad, FaLayerGroup, FaList, FaTools } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
import { tabStyles } from "../styles";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
@@ -9,10 +9,12 @@ import { useInstallationStatus } from "../hooks/useLsfgHooks";
import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
import { FlatpaksTab } from "./FlatpaksTab";
+import { NowPlayingTab } from "./NowPlayingTab";
import { SetupTab } from "./SetupTab";
const tabIcons = {
- configuration: <FaGamepad size={18} />,
+ nowPlaying: <FaGamepad size={18} />,
+ configuration: <FaList size={18} />,
flatpak: <FaLayerGroup size={18} />,
configFile: <FaFileAlt size={18} />,
setup: <FaTools size={18} />,
@@ -33,9 +35,9 @@ export function Content() {
config,
targets,
runningGame,
- selectedAppId,
setSelectedAppId,
save,
+ enable,
resetSelected,
resetAll,
reload,
@@ -48,10 +50,27 @@ export function Content() {
steamBranchStatus?.success === true &&
steamBranchStatus.installed &&
!steamBranchStatus.needs_switch;
+ const previousRunningState = useRef<{ appid: string; configured: boolean } | null>(null);
useEffect(() => {
- setTab(setupComplete ? "Configuration" : "Setup");
- }, [setupComplete]);
+ if (!setupComplete) {
+ setTab("Setup");
+ return;
+ }
+ setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Configuration") : current);
+ }, [runningGame?.configured, setupComplete]);
+
+ useEffect(() => {
+ if (!setupComplete) return;
+ const current = runningGame ? { appid: runningGame.appid, configured: runningGame.configured } : null;
+ const previous = previousRunningState.current;
+ previousRunningState.current = current;
+ if (current?.appid && (current.appid !== previous?.appid || current.configured !== previous?.configured)) {
+ setTab(current.configured ? "NowPlaying" : "Configuration");
+ } else if (!current && previous) {
+ setTab((currentTab) => currentTab === "NowPlaying" ? "Configuration" : currentTab);
+ }
+ }, [runningGame?.appid, runningGame?.configured, setupComplete]);
useEffect(() => {
if (isInstalled) void reload();
@@ -88,6 +107,18 @@ export function Content() {
const tabs = setupComplete
? [
+ ...(runningGame?.configured ? [{
+ id: "NowPlaying",
+ title: tabIcons.nowPlaying,
+ content: (
+ <NowPlayingTab
+ game={runningGame}
+ config={config}
+ onConfigChange={handleConfigChange}
+ onRemove={resetSelected}
+ />
+ ),
+ }] : []),
{
id: "Configuration",
title: tabIcons.configuration,
@@ -96,9 +127,9 @@ export function Content() {
config={config}
targets={targets}
runningGame={runningGame}
- selectedAppId={selectedAppId}
onSelect={setSelectedAppId}
onConfigChange={handleConfigChange}
+ onEnable={enable}
onReset={resetSelected}
onResetAll={resetAll}
/>
diff --git a/src/components/FgmodClipboardButton.tsx b/src/components/FgmodClipboardButton.tsx
deleted file mode 100644
index efc5490..0000000
--- a/src/components/FgmodClipboardButton.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import { useState, useEffect } from "react";
-import { PanelSectionRow, ButtonItem } from "@decky/ui";
-import { FaClipboard, FaCheck } from "react-icons/fa";
-import { checkFgmodDirectory } from "../api/lsfgApi";
-import { showClipboardErrorToast } from "../utils/toastUtils";
-import { copyWithVerification } from "../utils/clipboardUtils";
-import t from '../i18n/i18n';
-
-export function FgmodClipboardButton() {
- const [isLoading, setIsLoading] = useState(false);
- const [showSuccess, setShowSuccess] = useState(false);
- const [fgmodExists, setFgmodExists] = useState(false);
- const [checkingFgmod, setCheckingFgmod] = useState(true);
-
- // Check for fgmod directory on component mount
- useEffect(() => {
- const checkFgmod = async () => {
- try {
- const result = await checkFgmodDirectory();
- setFgmodExists(result.exists);
- } catch (error) {
- console.error("Error checking fgmod directory:", error);
- setFgmodExists(false);
- } finally {
- setCheckingFgmod(false);
- }
- };
-
- checkFgmod();
- }, []);
-
- // Reset success state after 3 seconds
- useEffect(() => {
- if (showSuccess) {
- const timer = setTimeout(() => {
- setShowSuccess(false);
- }, 3000);
- return () => clearTimeout(timer);
- }
- return undefined;
- }, [showSuccess]);
-
- const copyToClipboard = async () => {
- if (isLoading || showSuccess) return;
-
- setIsLoading(true);
- try {
- const text = "~/fgmod/fgmod ~/lsfg %command%";
- const { success, verified } = await copyWithVerification(text);
-
- if (success) {
- // Show success feedback in the button instead of toast
- setShowSuccess(true);
- if (!verified) {
- // Copy worked but verification failed - still show success
- console.log('Copy verification failed but copy likely worked');
- }
- } else {
- showClipboardErrorToast();
- }
- } catch (error) {
- showClipboardErrorToast();
- } finally {
- setIsLoading(false);
- }
- };
-
- // Don't render if fgmod directory doesn't exist or we're still checking
- if (checkingFgmod || !fgmodExists) {
- return null;
- }
-
- return (
- <PanelSectionRow>
- <ButtonItem
- layout="below"
- onClick={copyToClipboard}
- disabled={isLoading || showSuccess}
- >
- <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
- {showSuccess ? (
- <FaCheck style={{
- color: "#4CAF50" // Green color for success
- }} />
- ) : isLoading ? (
- <FaClipboard style={{
- animation: "pulse 1s ease-in-out infinite",
- opacity: 0.7
- }} />
- ) : (
- <FaClipboard />
- )}
- <div style={{
- color: showSuccess ? "#4CAF50" : "inherit",
- fontWeight: showSuccess ? "bold" : "normal"
- }}>
- {showSuccess ? t('CLIPBOARD_COPIED', 'Copied to clipboard') : isLoading ? t('CLIPBOARD_COPYING', 'Copying...') : t('CLIPBOARD_LSFG_FGMOD', 'LSFG + DeckyFG')}
- </div>
- </div>
- </ButtonItem>
- <style>{`
- @keyframes pulse {
- 0% { opacity: 0.7; }
- 50% { opacity: 1; }
- 100% { opacity: 0.7; }
- }
- `}</style>
- </PanelSectionRow>
- );
-}
diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx
index 5069c9a..e197dc3 100644
--- a/src/components/FpsMultiplierControl.tsx
+++ b/src/components/FpsMultiplierControl.tsx
@@ -1,4 +1,4 @@
-import { PanelSectionRow, DialogButton, Focusable } from "@decky/ui";
+import { PanelSectionRow, SliderField } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
import { MULTIPLIER } from "../config/generatedConfigSchema";
import t from "../i18n/i18n";
@@ -12,62 +12,22 @@ export function FpsMultiplierControl({
config,
onConfigChange
}: FpsMultiplierControlProps) {
+ const multiplierLabel = config.multiplier === 1
+ ? t("MULTIPLIER_OFF", "Off")
+ : `${config.multiplier}x`;
+
return (
<PanelSectionRow>
- <Focusable
- style={{
- marginTop: "6px",
- marginBottom: "6px",
- display: "flex",
- justifyContent: "center",
- alignItems: "center"
- }}
- flow-children="horizontal"
- >
- <DialogButton
- style={{
- marginLeft: "0px",
- height: "30px",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- padding: "5px 0px 0px 0px",
- minWidth: "40px",
- }}
- onClick={() => onConfigChange(MULTIPLIER, Math.max(1, config.multiplier - 1))}
- disabled={config.multiplier <= 1}
- >
- −
- </DialogButton>
- <div
- style={{
- marginLeft: "20px",
- marginRight: "20px",
- fontSize: "16px",
- fontWeight: "bold",
- color: config.multiplier > 4 ? "red" : "white",
- minWidth: "60px",
- textAlign: "center"
- }}
- >
- {config.multiplier === 1 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`}
- </div>
- <DialogButton
- style={{
- marginLeft: "0px",
- height: "30px",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- padding: "5px 0px 0px 0px",
- minWidth: "40px",
- }}
- onClick={() => onConfigChange(MULTIPLIER, Math.min(4, config.multiplier + 1))}
- disabled={config.multiplier >= 4}
- >
- +
- </DialogButton>
- </Focusable>
+ <SliderField
+ label={`FPS multiplier · ${multiplierLabel}`}
+ value={config.multiplier}
+ min={1}
+ max={4}
+ step={1}
+ notchCount={4}
+ showValue={false}
+ onChange={(value) => void onConfigChange(MULTIPLIER, value)}
+ />
</PanelSectionRow>
);
}
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
new file mode 100644
index 0000000..bdf9985
--- /dev/null
+++ b/src/components/GameConfigurationControls.tsx
@@ -0,0 +1,17 @@
+import { ConfigurationData } from "../config/configSchema";
+import { ConfigurationSection } from "./ConfigurationSection";
+import { FpsMultiplierControl } from "./FpsMultiplierControl";
+
+interface Props {
+ config: ConfigurationData;
+ onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
+}
+
+export function GameConfigurationControls({ config, onConfigChange }: Props) {
+ return (
+ <>
+ <FpsMultiplierControl config={config} onConfigChange={onConfigChange} />
+ <ConfigurationSection config={config} onConfigChange={onConfigChange} />
+ </>
+ );
+}
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index a231477..fcdd0aa 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -1,29 +1,58 @@
-import { Dropdown, DropdownOption, PanelSectionRow, ButtonItem } from "@decky/ui";
+import { ButtonItem, Field, PanelSectionRow } from "@decky/ui";
import { GameTarget } from "../hooks/useGameConfiguration";
interface Props {
targets: GameTarget[];
runningGame: GameTarget | null;
- selectedAppId: string;
onSelect: (appid: string) => void;
- onReset: () => Promise<void>;
onResetAll: () => Promise<void>;
}
-export function GameConfigurationSelector({ targets, runningGame, selectedAppId, onSelect, onReset, onResetAll }: Props) {
- const options: DropdownOption[] = [
- { data: "", label: runningGame ? `Default (editing template) · ${runningGame.name}` : "Default" },
- ...targets.map((target) => ({ data: target.appid, label: `${target.nonSteam ? "Non-Steam · " : ""}${target.name} · ${target.appid}` })),
- ];
- return <>
- <PanelSectionRow>
- <Dropdown rgOptions={options} selectedOption={selectedAppId} onChange={(option) => onSelect(String(option.data))} />
- </PanelSectionRow>
- <PanelSectionRow>
- <ButtonItem layout="below" onClick={() => void onReset()} disabled={!selectedAppId}>Reset selected game</ButtonItem>
- </PanelSectionRow>
- <PanelSectionRow>
- <ButtonItem layout="below" onClick={() => void onResetAll()} disabled={!targets.some((target) => target.configured)}>Reset all game profiles</ButtonItem>
- </PanelSectionRow>
- </>;
+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(" · ");
+
+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);
+ });
+
+ return (
+ <>
+ {games.length === 0 && (
+ <PanelSectionRow>
+ <Field label="No installed games" description="Steam has not reported any eligible games" />
+ </PanelSectionRow>
+ )}
+ {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,
+ )}
+ onActivate={() => onSelect(game.appid)}
+ highlightOnFocus
+ />
+ </PanelSectionRow>
+ ))}
+ <PanelSectionRow>
+ <ButtonItem
+ layout="below"
+ onClick={() => void onResetAll()}
+ disabled={!targets.some((target) => target.configured)}
+ >
+ Remove all profiles
+ </ButtonItem>
+ </PanelSectionRow>
+ </>
+ );
}
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
new file mode 100644
index 0000000..956db4a
--- /dev/null
+++ b/src/components/NowPlayingTab.tsx
@@ -0,0 +1,32 @@
+import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { ConfigurationData } from "../config/configSchema";
+import { GameTarget } from "../hooks/useGameConfiguration";
+import { GameConfigurationControls } from "./GameConfigurationControls";
+
+interface Props {
+ game: GameTarget;
+ config: ConfigurationData;
+ onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
+ onRemove: () => Promise<void>;
+}
+
+export function NowPlayingTab({ game, config, onConfigChange, onRemove }: Props) {
+ return (
+ <Focusable>
+ <PanelSection title="Now Playing">
+ <PanelSectionRow>
+ <Field
+ label={game.name}
+ description={`${game.nonSteam ? "Non-Steam" : "Steam"} · App ID ${game.appid} · Profile active`}
+ />
+ </PanelSectionRow>
+ </PanelSection>
+ <GameConfigurationControls config={config} onConfigChange={onConfigChange} />
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={() => void onRemove()}>
+ Remove profile
+ </ButtonItem>
+ </PanelSectionRow>
+ </Focusable>
+ );
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index 5089b6f..424a360 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -3,9 +3,10 @@ export { StatusDisplay } from "./StatusDisplay";
export { InstallationButton } from "./InstallationButton";
export { ConfigurationSection } from "./ConfigurationSection";
export { FpsMultiplierControl } from "./FpsMultiplierControl";
-export { FgmodClipboardButton } from "./FgmodClipboardButton";
export { ConfigurationTab } from "./ConfigurationTab";
export { SetupTab } from "./SetupTab";
export { ConfigFileTab } from "./ConfigFileTab";
export { FlatpaksTab } from "./FlatpaksTab";
export { GameConfigurationSelector } from "./GameConfigurationSelector";
+export { GameConfigurationControls } from "./GameConfigurationControls";
+export { NowPlayingTab } from "./NowPlayingTab";
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index b177551..d279a8c 100644
--- a/src/hooks/useGameConfiguration.ts
+++ b/src/hooks/useGameConfiguration.ts
@@ -1,37 +1,40 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Router } from "@decky/ui";
-import { getGameConfigs, getInstalledGames, updateGameConfig, updateLsfgConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type InstalledGame } from "../api/lsfgApi";
+import { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
export interface GameTarget extends InstalledGame { configured: boolean; }
export function useGameConfiguration() {
- const [defaultConfig, setDefaultConfig] = useState<ConfigurationData>(getDefaults());
const [games, setGames] = useState<GameConfigEntry[]>([]);
+ const [globalConfig, setGlobalConfig] = useState<GlobalConfig>({ dll: "", no_fp16: false });
const [installedGames, setInstalledGames] = useState<InstalledGame[]>([]);
+ const [configsLoaded, setConfigsLoaded] = useState(false);
const [selectedAppId, setSelectedAppId] = useState("");
const [runningGame, setRunningGame] = useState<GameTarget | null>(null);
- const autoSelected = useRef(false);
+ const previousRunningAppId = useRef<string | null>(null);
const load = useCallback(async () => {
const [result, installed] = await Promise.all([getGameConfigs(), getInstalledGames()]);
if (result.success) {
- setDefaultConfig(result.default || getDefaults());
+ setGlobalConfig(result.global_config || { dll: "", no_fp16: false });
setGames(result.games || []);
}
if (installed.success) setInstalledGames(installed.games || []);
+ setConfigsLoaded(true);
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
const poll = () => {
+ if (!configsLoaded) return;
const app = Router.MainRunningApp as any;
if (!app?.appid) return setRunningGame(null);
const appid = String(app.appid);
const installed = installedGames.find((game) => game.appid === appid);
const name = app.display_name || installed?.name;
if (!name) return setRunningGame(null);
- setRunningGame({
+ setRunningGame((current) => current?.appid === appid ? current : {
...(installed || { appid, name, nonSteam: false }),
name,
configured: games.some((game) => game.appid === appid),
@@ -40,13 +43,14 @@ export function useGameConfiguration() {
poll();
const interval = window.setInterval(poll, 2000);
return () => window.clearInterval(interval);
- }, [games, installedGames]);
+ }, [configsLoaded, games, installedGames]);
useEffect(() => {
- if (!autoSelected.current && runningGame) {
- autoSelected.current = true;
- setSelectedAppId(runningGame.appid);
+ const appid = runningGame?.appid || null;
+ if (appid !== previousRunningAppId.current) {
+ previousRunningAppId.current = appid;
+ setSelectedAppId(appid || "");
}
- }, [runningGame]);
+ }, [runningGame?.appid]);
const targets = useMemo<GameTarget[]>(() => {
const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) }));
@@ -54,25 +58,42 @@ export function useGameConfiguration() {
if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame);
return configured;
}, [games, installedGames, runningGame]);
- const selected = selectedAppId ? games.find((game) => game.appid === selectedAppId)?.config : defaultConfig;
- const config = selected || defaultConfig;
+ const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]);
+ const config = games.find((game) => game.appid === selectedAppId)?.config || template;
const save = useCallback(async (next: ConfigurationData) => {
- if (!selectedAppId) {
- const result = await updateLsfgConfig(next);
- if (result.success) setDefaultConfig(next);
- return;
- }
const selectedTarget = targets.find((target) => target.appid === selectedAppId);
if (!selectedTarget?.name) return;
const result = await updateGameConfig(selectedAppId, selectedTarget.name, next);
if (result.success) await load();
}, [load, selectedAppId, targets]);
+ const enable = useCallback(async (appid: string) => {
+ const target = targets.find((item) => item.appid === appid);
+ if (!target?.name) return false;
+ const result = await updateGameConfig(appid, target.name, template);
+ if (result.success) await load();
+ return result.success;
+ }, [load, targets, template]);
+
const resetSelected = useCallback(async () => {
- if (selectedAppId) { await resetGameConfig(selectedAppId); setSelectedAppId(""); await load(); }
+ if (selectedAppId) {
+ const result = await resetGameConfig(selectedAppId);
+ if (result.success) {
+ setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current);
+ setSelectedAppId("");
+ await load();
+ }
+ }
}, [load, selectedAppId]);
- const resetAll = useCallback(async () => { await resetAllGameConfigs(); setSelectedAppId(""); await load(); }, [load]);
+ const resetAll = useCallback(async () => {
+ const result = await resetAllGameConfigs();
+ if (result.success) {
+ setRunningGame((current) => current ? { ...current, configured: false } : current);
+ setSelectedAppId("");
+ await load();
+ }
+ }, [load]);
- return { config, defaultConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, resetSelected, resetAll, reload: load };
+ return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, resetSelected, resetAll, reload: load };
}
diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts
index ea8b3d0..0b71ee9 100644
--- a/src/hooks/useLsfgHooks.ts
+++ b/src/hooks/useLsfgHooks.ts
@@ -1,14 +1,9 @@
-import { useState, useEffect, useCallback } from "react";
+import { useState, useEffect } from "react";
import {
checkLsfgVkInstalled,
- getLsfgConfig,
getLosslessScalingBranchStatus,
- updateLsfgConfigFromObject,
- type ConfigUpdateResult,
type SteamBranchStatus
} from "../api/lsfgApi";
-import { ConfigurationData, getDefaults } from "../config/configSchema";
-import { showErrorToast, ToastMessages } from "../utils/toastUtils";
export function useInstallationStatus() {
const [isInstalled, setIsInstalled] = useState<boolean>(false);
@@ -60,57 +55,3 @@ export function useInstallationStatus() {
checkInstallation
};
}
-
-export function useLsfgConfig() {
- const [config, setConfig] = useState<ConfigurationData>(() => getDefaults());
-
- const loadLsfgConfig = useCallback(async () => {
- try {
- const result = await getLsfgConfig();
- if (result.success && result.config) {
- setConfig(result.config);
- } else {
- console.log("lsfg config not available, using defaults:", result.error);
- setConfig(getDefaults());
- }
- } catch (error) {
- console.error("Error loading lsfg config:", error);
- setConfig(getDefaults());
- }
- }, []);
-
- const updateConfig = useCallback(async (newConfig: ConfigurationData): Promise<ConfigUpdateResult> => {
- try {
- const result = await updateLsfgConfigFromObject(newConfig);
- if (result.success) {
- setConfig(newConfig);
- } else {
- showErrorToast(
- ToastMessages.CONFIG_UPDATE_ERROR.title,
- result.error || ToastMessages.CONFIG_UPDATE_ERROR.body
- );
- }
- return result;
- } catch (error) {
- showErrorToast(ToastMessages.CONFIG_UPDATE_ERROR.title, String(error));
- return { success: false, error: String(error) };
- }
- }, []);
-
- const updateField = useCallback(async (fieldName: keyof ConfigurationData, value: boolean | number | string): Promise<ConfigUpdateResult> => {
- const newConfig = { ...config, [fieldName]: value };
- return updateConfig(newConfig);
- }, [config, updateConfig]);
-
- useEffect(() => {
- loadLsfgConfig();
- }, []);
-
- return {
- config,
- setConfig,
- loadLsfgConfig,
- updateConfig,
- updateField
- };
-}
diff --git a/src/i18n/languages.json b/src/i18n/languages.json
index 0528341..3132083 100644
--- a/src/i18n/languages.json
+++ b/src/i18n/languages.json
@@ -88,10 +88,7 @@
"PROFILE_DELETE_DESC_SUFFIX": "この操作は取り消せません。",
"PROFILE_DELETE_BTN": "削除",
"PROFILE_CANNOT_RENAME_TITLE": "デフォルトプロファイルの名前は変更できません",
- "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません",
- "CLIPBOARD_COPIED": "クリップボードにコピーしました",
- "CLIPBOARD_COPYING": "コピー中...",
- "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG"
+ "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません"
},
"ko": {
"CONTENT_FPS_MULTIPLIER": "FPS 배율",
@@ -182,10 +179,7 @@
"PROFILE_DELETE_DESC_SUFFIX": "이 작업은 취소할 수 없습니다.",
"PROFILE_DELETE_BTN": "삭제",
"PROFILE_CANNOT_RENAME_TITLE": "기본 프로필 이름 변경 불가",
- "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다",
- "CLIPBOARD_COPIED": "클립보드에 복사됨",
- "CLIPBOARD_COPYING": "복사 중...",
- "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG"
+ "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다"
},
"language_metadata": {
"ko": {
@@ -304,9 +298,6 @@
"PROFILE_DELETE_DESC_SUFFIX": "? This action cannot be undone.",
"PROFILE_DELETE_BTN": "Delete",
"PROFILE_CANNOT_RENAME_TITLE": "Cannot rename default profile",
- "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed",
- "CLIPBOARD_COPIED": "Copied to clipboard",
- "CLIPBOARD_COPYING": "Copying...",
- "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG"
+ "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed"
}
}
diff --git a/src/styles.ts b/src/styles.ts
index fe40a59..1bc089b 100644
--- a/src/styles.ts
+++ b/src/styles.ts
@@ -1,5 +1,5 @@
export const tabStyles = `
- .lsfg-vk-tabs > div > div:first-child::before {
+ .lsfg-vk-tabs > div > div:first-child {
background: #0D141C;
box-shadow: none;
backdrop-filter: none;
diff --git a/src/utils/clipboardUtils.ts b/src/utils/clipboardUtils.ts
deleted file mode 100644
index 8a04caa..0000000
--- a/src/utils/clipboardUtils.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * Clipboard utilities for reliable copy operations across different environments
- */
-
-/**
- * Reliably copy text to clipboard using multiple fallback methods
- * This is especially important in gaming mode where clipboard APIs may behave differently
- */
-export async function copyToClipboard(text: string): Promise<boolean> {
- const tempInput = document.createElement('input');
- tempInput.value = text;
- tempInput.style.position = 'absolute';
- tempInput.style.left = '-9999px';
- document.body.appendChild(tempInput);
-
- try {
- tempInput.focus();
- tempInput.select();
-
- let copySuccess = false;
- try {
- if (document.execCommand('copy')) {
- copySuccess = true;
- }
- } catch (e) {
- try {
- await navigator.clipboard.writeText(text);
- copySuccess = true;
- } catch (clipboardError) {
- console.error('Both copy methods failed:', e, clipboardError);
- }
- }
-
- return copySuccess;
- } finally {
- document.body.removeChild(tempInput);
- }
-}
-
-/**
- * Verify that text was successfully copied to clipboard
- */
-export async function verifyCopy(expectedText: string): Promise<boolean> {
- try {
- const readBack = await navigator.clipboard.readText();
- return readBack === expectedText;
- } catch (e) {
- return true;
- }
-}
-
-/**
- * Copy text with verification and return success status
- */
-export async function copyWithVerification(text: string): Promise<{ success: boolean; verified: boolean }> {
- const copySuccess = await copyToClipboard(text);
-
- if (!copySuccess) {
- return { success: false, verified: false };
- }
-
- const verified = await verifyCopy(text);
- return { success: true, verified };
-}
diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts
index dce0a59..cbbbc55 100644
--- a/src/utils/toastUtils.ts
+++ b/src/utils/toastUtils.ts
@@ -53,14 +53,6 @@ export const ToastMessages = {
CONFIG_UPDATE_ERROR: {
title: "Update Failed",
body: "Failed to update configuration"
- },
- CLIPBOARD_SUCCESS: {
- title: "Copied to Clipboard!",
- body: "Launch option ready to paste"
- },
- CLIPBOARD_ERROR: {
- title: "Copy Failed",
- body: "Unable to copy to clipboard"
}
} as const;
@@ -99,17 +91,3 @@ export function showUninstallSuccessToast(): void {
export function showUninstallErrorToast(error?: string): void {
showErrorToast(ToastMessages.UNINSTALL_ERROR.title, error || ToastMessages.UNINSTALL_ERROR.body);
}
-
-/**
- * Show clipboard success toast
- */
-export function showClipboardSuccessToast(): void {
- showSuccessToast(ToastMessages.CLIPBOARD_SUCCESS.title, ToastMessages.CLIPBOARD_SUCCESS.body);
-}
-
-/**
- * Show clipboard error toast
- */
-export function showClipboardErrorToast(): void {
- showErrorToast(ToastMessages.CLIPBOARD_ERROR.title, ToastMessages.CLIPBOARD_ERROR.body);
-}