diff --git a/README.md b/README.md index 444abfb..a18c7f4 100644 --- a/README.md +++ b/README.md @@ -35,27 +35,31 @@ Available Filters: - **Last Played** - Selects apps that were last played before/after the provided date. - **Demo** - Selects apps that are/aren't demos. - **Streamable** - Selects apps that can/can't be streamed from another computer. + - **Steam Features** - Selects apps that support specific Steam Features. + - **MicroSD Card** - Selects apps that are present on the inserted/specific MicroSD Card. If you want to see another filter, please open a filter request [here](https://github.com/Tormak9970/TabMaster/issues/new/choose). Filter Examples: - - **Collection**
- - **Installed**
- - **Regex**
- - **Friends**
- - **Tags**
- - **Whitelist**
- - **Blacklist**
- - **Merge**
- - **Platform**
- - **Deck Compatibility**
- - **Review Score**
- - **Time Played**
- - **Size on Disk**
- - **Release Date**
- - **Last Played**
- - **Demo**
- - **Streamable**
+ - **Collection**
+ - **Installed**
+ - **Regex**
+ - **Friends**
+ - **Tags**
+ - **Whitelist**
+ - **Blacklist**
+ - **Merge**
+ - **Platform**
+ - **Deck Compatibility**
+ - **Review Score**
+ - **Time Played**
+ - **Size on Disk**
+ - **Release Date**
+ - **Last Played**
+ - **Demo**
+ - **Streamable**
+ - **Steam Features**
+ - **MicroSD Card**
# Using the plugin diff --git a/assets/docs_blacklist-example.png b/assets/docs_blacklist-example.png deleted file mode 100644 index 474f27a..0000000 Binary files a/assets/docs_blacklist-example.png and /dev/null differ diff --git a/assets/docs_collection-example.png b/assets/docs_collection-example.png deleted file mode 100644 index fc734a3..0000000 Binary files a/assets/docs_collection-example.png and /dev/null differ diff --git a/assets/docs_context-menu.png b/assets/docs_context-menu.png index 2a5ba89..3c675c2 100644 Binary files a/assets/docs_context-menu.png and b/assets/docs_context-menu.png differ diff --git a/assets/docs_create-tab.png b/assets/docs_create-tab.png index e946a70..3b57531 100644 Binary files a/assets/docs_create-tab.png and b/assets/docs_create-tab.png differ diff --git a/assets/docs_deck-compat-example.png b/assets/docs_deck-compat-example.png deleted file mode 100644 index ec24dea..0000000 Binary files a/assets/docs_deck-compat-example.png and /dev/null differ diff --git a/assets/docs_demo-example.png b/assets/docs_demo-example.png deleted file mode 100644 index 9452402..0000000 Binary files a/assets/docs_demo-example.png and /dev/null differ diff --git a/assets/docs_edit-ui.png b/assets/docs_edit-ui.png index d27946b..f5805fb 100644 Binary files a/assets/docs_edit-ui.png and b/assets/docs_edit-ui.png differ diff --git a/assets/docs_friends-example.png b/assets/docs_friends-example.png deleted file mode 100644 index 693d33d..0000000 Binary files a/assets/docs_friends-example.png and /dev/null differ diff --git a/assets/docs_installed-example.png b/assets/docs_installed-example.png deleted file mode 100644 index 4e56fdb..0000000 Binary files a/assets/docs_installed-example.png and /dev/null differ diff --git a/assets/docs_last-played-example.png b/assets/docs_last-played-example.png deleted file mode 100644 index 12f2292..0000000 Binary files a/assets/docs_last-played-example.png and /dev/null differ diff --git a/assets/docs_merge-example.png b/assets/docs_merge-example.png deleted file mode 100644 index 366a026..0000000 Binary files a/assets/docs_merge-example.png and /dev/null differ diff --git a/assets/docs_platform-example.png b/assets/docs_platform-example.png deleted file mode 100644 index d2c1e9b..0000000 Binary files a/assets/docs_platform-example.png and /dev/null differ diff --git a/assets/docs_regex-example.png b/assets/docs_regex-example.png deleted file mode 100644 index 5d9354f..0000000 Binary files a/assets/docs_regex-example.png and /dev/null differ diff --git a/assets/docs_release-date-example.png b/assets/docs_release-date-example.png deleted file mode 100644 index 0dbdeb4..0000000 Binary files a/assets/docs_release-date-example.png and /dev/null differ diff --git a/assets/docs_review-score-example.png b/assets/docs_review-score-example.png deleted file mode 100644 index 427039c..0000000 Binary files a/assets/docs_review-score-example.png and /dev/null differ diff --git a/assets/docs_size-on-disk-example.png b/assets/docs_size-on-disk-example.png deleted file mode 100644 index 26316f6..0000000 Binary files a/assets/docs_size-on-disk-example.png and /dev/null differ diff --git a/assets/docs_streamable-example.png b/assets/docs_streamable-example.png deleted file mode 100644 index e4d26ff..0000000 Binary files a/assets/docs_streamable-example.png and /dev/null differ diff --git a/assets/docs_tags-example.png b/assets/docs_tags-example.png deleted file mode 100644 index 58065a8..0000000 Binary files a/assets/docs_tags-example.png and /dev/null differ diff --git a/assets/docs_time-played-example.png b/assets/docs_time-played-example.png deleted file mode 100644 index f062921..0000000 Binary files a/assets/docs_time-played-example.png and /dev/null differ diff --git a/assets/docs_whitelist-example.png b/assets/docs_whitelist-example.png deleted file mode 100644 index a21a79d..0000000 Binary files a/assets/docs_whitelist-example.png and /dev/null differ diff --git a/assets/filters/docs_blacklist-example.png b/assets/filters/docs_blacklist-example.png new file mode 100644 index 0000000..bac4aba Binary files /dev/null and b/assets/filters/docs_blacklist-example.png differ diff --git a/assets/filters/docs_collection-example.png b/assets/filters/docs_collection-example.png new file mode 100644 index 0000000..cee5b96 Binary files /dev/null and b/assets/filters/docs_collection-example.png differ diff --git a/assets/filters/docs_deck-compat-example.png b/assets/filters/docs_deck-compat-example.png new file mode 100644 index 0000000..82d912d Binary files /dev/null and b/assets/filters/docs_deck-compat-example.png differ diff --git a/assets/filters/docs_demo-example.png b/assets/filters/docs_demo-example.png new file mode 100644 index 0000000..21554d8 Binary files /dev/null and b/assets/filters/docs_demo-example.png differ diff --git a/assets/filters/docs_friends-example.png b/assets/filters/docs_friends-example.png new file mode 100644 index 0000000..9d9910f Binary files /dev/null and b/assets/filters/docs_friends-example.png differ diff --git a/assets/filters/docs_installed-example.png b/assets/filters/docs_installed-example.png new file mode 100644 index 0000000..fef6e25 Binary files /dev/null and b/assets/filters/docs_installed-example.png differ diff --git a/assets/filters/docs_last-played-example.png b/assets/filters/docs_last-played-example.png new file mode 100644 index 0000000..23766aa Binary files /dev/null and b/assets/filters/docs_last-played-example.png differ diff --git a/assets/filters/docs_merge-example.png b/assets/filters/docs_merge-example.png new file mode 100644 index 0000000..2dc1a40 Binary files /dev/null and b/assets/filters/docs_merge-example.png differ diff --git a/assets/filters/docs_microsd-card-example.png b/assets/filters/docs_microsd-card-example.png new file mode 100644 index 0000000..14943a3 Binary files /dev/null and b/assets/filters/docs_microsd-card-example.png differ diff --git a/assets/filters/docs_platform-example.png b/assets/filters/docs_platform-example.png new file mode 100644 index 0000000..94f196f Binary files /dev/null and b/assets/filters/docs_platform-example.png differ diff --git a/assets/filters/docs_regex-example.png b/assets/filters/docs_regex-example.png new file mode 100644 index 0000000..ba15940 Binary files /dev/null and b/assets/filters/docs_regex-example.png differ diff --git a/assets/filters/docs_release-date-example.png b/assets/filters/docs_release-date-example.png new file mode 100644 index 0000000..4aaed96 Binary files /dev/null and b/assets/filters/docs_release-date-example.png differ diff --git a/assets/filters/docs_review-score-example.png b/assets/filters/docs_review-score-example.png new file mode 100644 index 0000000..b62e0e9 Binary files /dev/null and b/assets/filters/docs_review-score-example.png differ diff --git a/assets/filters/docs_size-on-disk-example.png b/assets/filters/docs_size-on-disk-example.png new file mode 100644 index 0000000..71296d8 Binary files /dev/null and b/assets/filters/docs_size-on-disk-example.png differ diff --git a/assets/filters/docs_steam-features-example.png b/assets/filters/docs_steam-features-example.png new file mode 100644 index 0000000..69d3280 Binary files /dev/null and b/assets/filters/docs_steam-features-example.png differ diff --git a/assets/filters/docs_streamable-example.png b/assets/filters/docs_streamable-example.png new file mode 100644 index 0000000..9d4cdc6 Binary files /dev/null and b/assets/filters/docs_streamable-example.png differ diff --git a/assets/filters/docs_tags-example.png b/assets/filters/docs_tags-example.png new file mode 100644 index 0000000..d749064 Binary files /dev/null and b/assets/filters/docs_tags-example.png differ diff --git a/assets/filters/docs_time-played-example.png b/assets/filters/docs_time-played-example.png new file mode 100644 index 0000000..8cb6ad9 Binary files /dev/null and b/assets/filters/docs_time-played-example.png differ diff --git a/assets/filters/docs_whitelist-example.png b/assets/filters/docs_whitelist-example.png new file mode 100644 index 0000000..73f048d Binary files /dev/null and b/assets/filters/docs_whitelist-example.png differ diff --git a/assets/tab-profiles/docs_creating-tab-profiles.png b/assets/tab-profiles/docs_creating-tab-profiles.png new file mode 100644 index 0000000..65a493e Binary files /dev/null and b/assets/tab-profiles/docs_creating-tab-profiles.png differ diff --git a/assets/tab-profiles/docs_managing-tab-profiles.png b/assets/tab-profiles/docs_managing-tab-profiles.png new file mode 100644 index 0000000..36a0790 Binary files /dev/null and b/assets/tab-profiles/docs_managing-tab-profiles.png differ diff --git a/assets/tab-profiles/docs_overwriting-tab-profiles.png b/assets/tab-profiles/docs_overwriting-tab-profiles.png new file mode 100644 index 0000000..fd129d9 Binary files /dev/null and b/assets/tab-profiles/docs_overwriting-tab-profiles.png differ diff --git a/assets/tab-profiles/docs_tab-profiles-context-menu.png b/assets/tab-profiles/docs_tab-profiles-context-menu.png new file mode 100644 index 0000000..4e670b0 Binary files /dev/null and b/assets/tab-profiles/docs_tab-profiles-context-menu.png differ diff --git a/defaults/docs/Filters.md b/defaults/docs/Filters.md index 9f2b700..2f992e1 100644 --- a/defaults/docs/Filters.md +++ b/defaults/docs/Filters.md @@ -23,6 +23,8 @@ - Last Played - Demo - Streamable + - Steam Features + - MicroSD Card (Requires MicroSDeck)
@@ -50,7 +52,7 @@ Example: Inverting a `Collection` filter would cause it to include any apps **no Filters apps based on if they are included in the collection. **Example:**
- +
@@ -62,7 +64,7 @@ Filters apps based on if they are included in the collection. Filters apps based on their install state. **Example:**
- +
@@ -77,7 +79,7 @@ Filters apps based on their install state. - `or`: Filters apps based on if they are owned by any listed friend. **Example:**
- +
@@ -92,7 +94,7 @@ Filters apps based on their install state. - `or`: Filters apps based on if they have any listed tag. **Example:**
- +
@@ -104,7 +106,7 @@ Filters apps based on their install state. Filters apps by if they are in the list. **Example:**
- +
@@ -116,7 +118,7 @@ Filters apps by if they are in the list. Filters apps by if they are not in the list. **Example:**
- +
@@ -133,7 +135,7 @@ Regular expressions can seem daunting and confusing. You can test yours before h Also, by typing a phrase like "Zelda" into the regex field, it will include any game with that phrase in its title. **Example:**
- +
@@ -150,7 +152,7 @@ Groups a set of filters, allowing you to change the logic mode for smaller sets By grouping filters you are able to specify the mode for filters in the group seperately, significantly increasing the utility of TabMaster **Example:**
- +
@@ -162,7 +164,7 @@ By grouping filters you are able to specify the mode for filters in the group se Filters apps based on their platform. **Example:**
- +
@@ -175,7 +177,7 @@ Filters apps based on their platform. Filters apps based on their Steam Deck compatability. **Example:**
- +
@@ -183,13 +185,13 @@ Filters apps based on their Steam Deck compatability. **Options:**
`score` - The desired review score. `type` - The desired review type to use, Metacritic or Steam. -`greater/less` - Wether to include apps that have a review score greater than or equal to the provided score, or less than or equal to it. +`greater/less` - Whether to include apps that have a review score greater than or equal to the provided score, or less than or equal to it. **Behavior:**
Filters apps based on their review score. **Example:**
- +
@@ -197,26 +199,26 @@ Filters apps based on their review score. **Options:**
`play time` - The desired amount of time in the selected interval. `time interval` - The time interval to use, "minutes", "hours", or "days". -`greater/less` - Wether to include apps that are greater than or equal to the provided score, or less than or equal to it. +`greater/less` - Whether to include apps that are greater than or equal to the provided score, or less than or equal to it. **Behavior:**
Filters apps based on your time spent playing them. **Example:**
- +
#### Size on Disk **Options:**
`size` - The desired size of apps to include. -`greater/less` - Wether to include apps that are greater than or equal to the provided score, or less than or equal to it. +`greater/less` - Whether to include apps that are greater than or equal to the provided score, or less than or equal to it. **Behavior:**
Filters apps based on their size. **Example:**
- +
@@ -224,13 +226,13 @@ Filters apps based on their size. **Options:**
`date` - The desired release date of apps to include. `time period` - Whether you want to specify only the year, just the month and year, or the day, month, and year. -`before/after` - Wether to include apps that were released before or after the provided date. +`before/after` - Whether to include apps that were released before or after the provided date. **Behavior:**
Filters apps based on their release date. **Example:**
- +
@@ -238,13 +240,13 @@ Filters apps based on their release date. **Options:**
`date` - The desired last played date of apps to include. `time period` - Whether you want to specify only the year, just the month and year, or the day, month, and year. -`before/after` - Wether to include apps that were last played before or after the provided date. +`before/after` - Whether to include apps that were last played before or after the provided date. **Behavior:**
Filters apps based on when they were last played. **Example:**
- +
@@ -256,7 +258,7 @@ Filters apps based on when they were last played. Filters apps based on if they are a demo or not. **Example:**
- +
@@ -268,7 +270,35 @@ Filters apps based on if they are a demo or not. Filters apps based on if they can be streamed or not. **Example:**
- + + +
+ +#### Steam Features +**Options:**
+`features` - A list of Steam features. +`logic mode` - Specifies whether to use `and` vs. `or` mode. +`inverted` - If true, inverts the filtered apps (exclued apps are now included, and vis versa). + +**Behavior:**
+- `and`: Filters apps based on if they have all listed features. +- `or`: Filters apps based on if they have any listed features. + +**Example:**
+ + +
+ +#### MicroSD Card (Requires MicroSDeck) +**Options:**
+`MicroSD card` - The MicroSD card to use (if none are showing up, make sure they are showing up in MicroSDeck). +`inverted` - If true, inverts the filtered apps (exclued apps are now included, and vis versa). + +**Behavior:**
+Filters apps based on if they are installed on the specified MicroSD card. + +**Example:**
+
diff --git a/defaults/docs/Overview.md b/defaults/docs/Overview.md index b8c3a4d..ec7190e 100644 --- a/defaults/docs/Overview.md +++ b/defaults/docs/Overview.md @@ -19,6 +19,8 @@ These docs serve as a reference for questions you may have, and a guide for help * What is a tab, parts of a tab, and default vs custom. * Filters * What are filters, descriptions/tips for each, and examples. +* Tab Profiles + * User made groups of tabs that can be swapped out on the fly. * The Fix System * TabMaster's system for handling changes that could potentially break it. diff --git a/defaults/docs/Tab_Profiles.md b/defaults/docs/Tab_Profiles.md new file mode 100644 index 0000000..d330cae --- /dev/null +++ b/defaults/docs/Tab_Profiles.md @@ -0,0 +1,58 @@ +## Tab Profiles + +### Table of Contents + - Overview + - The Tab Profiles Context Menu + - Creating Profiles + - Applying Profiles + - Overwriting Profiles + - Deleting Profiles + +
+ + +### Overview +Here you can find everything about Tab Profiles, including adding, applying, overwriting, and deleting them. To get started, use the button pictured below or the TabMaster context menu to open the Tab Profiles Menu. + + + +
+ + +### The Tab Profiles Context Menu + +
+The Tab Profiles context menu is where you can manage your profiles. You can create new ones, and apply, overwrite, and delete existing profiles from here. + +
+ + +### Creating Profiles + +
+Creating profiles is straightforward. First, ensure that the tabs you want included in the profile are all visible, then open the Tab Profiles context menu using one of the two methods mentioned above. Click "Create Profile", entire a name, and the profile will be created. + +
+ + +### Applying Profiles +Overwriting a profile is easy! Just open the Tab Profiles menu, and navigate to the profile you want to apply. Click "A", and the profile will be applied, showing all tabs in the profile, and hiding any that aren't. + +
+ + +### Overwriting Profiles + +
+Made a mistake while making a profile, or just decide something needed to change? Well its easy to overwrite profiles, just open the profiles menu and navigate to the profile you want to delete and click "Y". A window will show up comparing what's in the profile, to what you're overwriting it with, allowing you to see the changes that will be made. Simply confirm here, and the profile will be overwritten. + +
+ + +### Deleting Profiles +Decide you don't like a profile anymore? Well deleting it is easy too! Just open the same Tab Profiles menu, and navigate to the profile you want to delete and click "X". You will be prompted to confirm if you really want to delete the profile, and confirming will delete it. + +
+ + +###### © Travis Lane (Tormak), Jessebofill diff --git a/defaults/docs/Tabs.md b/defaults/docs/Tabs.md index 9b597d7..8d845d0 100644 --- a/defaults/docs/Tabs.md +++ b/defaults/docs/Tabs.md @@ -55,6 +55,8 @@ This is where you can add new tabs to your library. You can find the various opt **Include** - The types of apps to include (defaults to games). +**Auto Hide** - Whether this tab should automatically be hidden from the library when it has no games (this preserves the order of the tabs, unlike hiding it manually). + **Logic Mode** - Two modes, `And` (apps must match all filters) and `Or` (apps must match at least one filter). **Filters** - The filters for this tab. More details on these can be found on the next page. diff --git a/main.py b/main.py index d08e97e..dbf19a6 100644 --- a/main.py +++ b/main.py @@ -147,6 +147,20 @@ async def get_friends_games(self) -> dict[int, list[int]] | None: friends_games = Plugin.users_dict[Plugin.user_id]["friendsGames"] log(f"Got {len(friends_games)} friendsGames") return friends_games or {} + + async def get_tab_profiles(self) -> dict[str, list[str]] | None: + """ + Waits until users_dict is loaded, then returns the tab profiles + + :return: User's tab profiles + """ + while Plugin.users_dict is None: + await asyncio.sleep(0.1) + + user = Plugin.users_dict[Plugin.user_id] + tab_profiles = Plugin.users_dict[Plugin.user_id]["tabProfiles"] if "tabProfiles" in user.keys() else {} + log(f"Got tab profiles {tab_profiles}") + return tab_profiles # Plugin settings setters async def set_tabs(self, tabs: dict[str, dict]): @@ -154,7 +168,7 @@ async def set_tabs(self, tabs: dict[str, dict]): await Plugin.set_setting(self, "usersDict", Plugin.users_dict) async def set_tags(self, tags: list[dict]): - Plugin.tags= tags + Plugin.tags = tags await Plugin.set_setting(self, "tags", Plugin.tags) async def set_friends(self, friends: list[dict]): @@ -165,6 +179,10 @@ async def set_friends_games(self, friends_games: dict[str, list[int]]): Plugin.users_dict[Plugin.user_id]["friendsGames"] = friends_games await Plugin.set_setting(self, "usersDict", Plugin.users_dict) + async def set_tab_profiles(self, tab_profiles: dict[str, list[str]]): + Plugin.users_dict[Plugin.user_id]["tabProfiles"] = tab_profiles + await Plugin.set_setting(self, "usersDict", Plugin.users_dict) + async def get_docs(self): for docsFileName in os.listdir(self.docsDirPath): with open(os.path.join(self.docsDirPath, docsFileName), 'r') as docFile: diff --git a/package.json b/package.json index 5c1059d..dc080dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tabmaster", - "version": "2.3.0", + "version": "2.4.0", "description": "Gives you full control over your Steam library! Support for customizing, adding, and hiding Library Tabs.", "scripts": { "build": "shx rm -rf dist && rollup -c", @@ -62,8 +62,8 @@ }, "dependencies": { "@cebbinghaus/microsdeck": "0.9.8-8cc660c", - "mobx": "^5.15.7", - "react-icons": "^4.10.1", + "mobx": "^6.12.0", + "react-icons": "^4.12.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "uuid": "^9.0.0" @@ -77,4 +77,4 @@ ] } } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbc5afe..7481588 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,11 +5,11 @@ dependencies: specifier: 0.9.8-8cc660c version: 0.9.8-8cc660c mobx: - specifier: ^5.15.7 - version: 5.15.7 + specifier: ^6.12.0 + version: 6.12.0 react-icons: - specifier: ^4.10.1 - version: 4.10.1 + specifier: ^4.12.0 + version: 4.12.0 react-virtualized-auto-sizer: specifier: ^1.0.20 version: 1.0.20 @@ -1541,8 +1541,8 @@ packages: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: true - /mobx@5.15.7: - resolution: {integrity: sha512-wyM3FghTkhmC+hQjyPGGFdpehrcX1KOXsDuERhfK2YbJemkUhEB+6wzEN639T21onxlfYBmriA1PFnvxTUhcKw==} + /mobx@6.12.0: + resolution: {integrity: sha512-Mn6CN6meXEnMa0a5u6a5+RKrqRedHBhZGd15AWLk9O6uFY4KYHzImdt8JI8WODo1bjTSRnwXhJox+FCUZhCKCQ==} dev: false /neo-async@2.6.2: @@ -1696,8 +1696,8 @@ packages: safe-buffer: 5.2.1 dev: true - /react-icons@4.10.1: - resolution: {integrity: sha512-/ngzDP/77tlCfqthiiGNZeYFACw85fUjZtLbedmJ5DTlNDIwETxhwBzdOJ21zj4iJdvc0J3y7yOsX3PpxAJzrw==} + /react-icons@4.12.0: + resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==} peerDependencies: react: '*' peerDependenciesMeta: diff --git a/src/components/CustomTabContainer.tsx b/src/components/CustomTabContainer.tsx index 912a8d3..71e7d67 100644 --- a/src/components/CustomTabContainer.tsx +++ b/src/components/CustomTabContainer.tsx @@ -55,7 +55,10 @@ export class CustomTabContainer implements TabContainer { this.checkMicroSDeckDependency(); } - getActualTab(TabContentComponent: TabContentComponent, sortingProps: Omit, footer: SteamTab['footer'], collectionAppFilter: any): SteamTab { + getActualTab(TabContentComponent: TabContentComponent, sortingProps: Omit, footer: SteamTab['footer'], collectionAppFilter: any, isMicroSDeckInstalled: boolean): SteamTab | null { + if (!isMicroSDeckInstalled && this.dependsOnMicroSDeck) return null; + if (this.autoHide && this.collection.visibleApps.length === 0) return null; + return { title: this.title, id: this.id, diff --git a/src/components/QuickAccessContent.tsx b/src/components/QuickAccessContent.tsx new file mode 100644 index 0000000..49ab725 --- /dev/null +++ b/src/components/QuickAccessContent.tsx @@ -0,0 +1,207 @@ +import { + ButtonItem, + DialogButton, + Field, + Focusable, + Navigation, + PanelSection, + ReorderableEntry, + ReorderableList, + showContextMenu, + quickAccessMenuClasses +} from "decky-frontend-lib"; +import { VFC, useState } from "react"; + +import { FaBook, FaCircleExclamation, FaBookmark } from "react-icons/fa6"; +import { PiListPlusBold } from "react-icons/pi"; + +import { useTabMasterContext } from "../state/TabMasterContext"; + +import { QamStyles } from "./styles/QamStyles"; +import { showModalNewTab } from "./modals/EditTabModal"; +import { TabActionsButton } from "./TabActions"; +import { LogController } from "../lib/controllers/LogController"; +import { PresetMenu } from './context-menus/PresetMenu'; +import { TabListLabel } from './TabListLabel'; +import { MicroSDeckInstallState, MicroSDeckInterop, microSDeckLibVersion } from '../lib/controllers/MicroSDeckInterop'; +import { MicroSDeckNotice } from './MicroSDeckNotice'; +import { CustomTabContainer } from './CustomTabContainer'; +import { TabProfilesMenu } from './context-menus/TabProfileMenu'; +import { TabMasterManager } from '../state/TabMasterManager'; + + +export type TabIdEntryType = { + id: string; +}; + +interface TabEntryInteractablesProps { + entry: ReorderableEntry; +} + +/** + * The Quick Access Menu content for TabMaster. + */ +export const QuickAccessContent: VFC<{}> = ({ }) => { + const [microSDeckNoticeHidden, setMicroSDeckNoticeHidden] = useState(MicroSDeckInterop.noticeHidden); + const { visibleTabsList, hiddenTabsList, tabsMap, tabMasterManager } = useTabMasterContext(); + + const microSDeckInstallState = MicroSDeckInterop.getInstallState(); + const isMicroSDeckInstalled = microSDeckInstallState === MicroSDeckInstallState['good']; + const hasSdTabs = !!visibleTabsList.find(tabContainer => (tabContainer as CustomTabContainer).dependsOnMicroSDeck); + + function TabEntryInteractables({ entry }: TabEntryInteractablesProps) { + const tabContainer = tabsMap.get(entry.data!.id)!; + return (); + } + + const entries = visibleTabsList.map((tabContainer) => { + return { + label: , + position: tabContainer.position, + data: { id: tabContainer.id } + }; + }); + + return ( +
+ {LogController.errorFlag &&
+

+ + Tab Master encountered an error +

+ +
} + {hasSdTabs && !isMicroSDeckInstalled && !microSDeckNoticeHidden && ( +
+ + +
+ { + MicroSDeckInterop.noticeHidden = true; + setMicroSDeckNoticeHidden(true); + }} + > + Hide Notice + +
+
+
+ )} + + +
+ Here you can add, re-order, or remove tabs from the library. +
+ + + + showModalNewTab(tabMasterManager)} onOKActionDescription={'Add Tab'}> + Add Tab + + + {tabMasterManager.hasSettingsLoaded && + + showContextMenu()} + > + + + } + + + +
+ {tabMasterManager.hasSettingsLoaded ? ( + + entries={entries} + interactables={TabEntryInteractables} + onSave={(entries: ReorderableEntry[]) => { + tabMasterManager.reorderTabs(entries.map(entry => entry.data!.id)); + }} + /> + ) : ( +
+ Loading... +
+ )} +
+ +
+ { + hiddenTabsList.map(tabContainer => +
+ } + onClick={() => tabMasterManager.showTab(tabContainer.id)} + onOKActionDescription="Unhide tab" + > + Show + +
+ ) + } +
+ {hasSdTabs && !isMicroSDeckInstalled && microSDeckNoticeHidden && ( + { }}> + + + )} +
+
+ ); +}; + +export interface QuickAccessTitleViewProps { + title: string; + tabMasterManager: TabMasterManager; +} + +const buttonStyle = { height: '28px', width: '40px', minWidth: 0, padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center' }; + +export const QuickAccessTitleView: VFC = ({ title, tabMasterManager }) => { + + return ( + +
{title}
+ showContextMenu()} + > + + + { + Navigation.CloseSideMenus(); + Navigation.Navigate("/tab-master-docs"); + }} + > + + +
+ ); +}; diff --git a/src/components/menus/LibraryMenu.tsx b/src/components/context-menus/LibraryMenu.tsx similarity index 96% rename from src/components/menus/LibraryMenu.tsx rename to src/components/context-menus/LibraryMenu.tsx index 625a53a..aa32e3f 100644 --- a/src/components/menus/LibraryMenu.tsx +++ b/src/components/context-menus/LibraryMenu.tsx @@ -1,7 +1,6 @@ import { Menu, MenuItem, showModal, Focusable, MenuGroup, ReorderableEntry, ReorderableList, MenuItemProps } from 'decky-frontend-lib'; import { FC, Fragment, VFC, useState } from 'react'; import { TabMasterManager } from '../../state/TabMasterManager'; -import { TabIdEntryType } from '../..'; import { TabMasterContextProvider, useTabMasterContext } from '../../state/TabMasterContext'; import { showModalEditTab, showModalNewTab } from '../modals/EditTabModal'; import { LibraryMenuStyles } from '../styles/LibraryMenuStyles'; @@ -11,6 +10,8 @@ import { PresetMenuItems } from './PresetMenu'; import { CustomTabContainer } from '../CustomTabContainer'; import { TabListLabel } from '../TabListLabel'; import { MicroSDeckInterop } from '../../lib/controllers/MicroSDeckInterop'; +import { TabProfilesSubMenu } from './TabProfileMenu'; +import { TabIdEntryType } from "../QuickAccessContent"; export interface LibraryMenuProps { closeMenu: () => void; @@ -65,6 +66,7 @@ const LibraryMenuItems: VFC = ({ selectedTabId, closeMenu
+ @@ -148,7 +150,7 @@ const HiddenItems: VFC = ({ hiddenTabsList, isMicroSDeckInstal setRefresh(refresh => !refresh); }} > - + )} ; diff --git a/src/components/menus/PresetMenu.tsx b/src/components/context-menus/PresetMenu.tsx similarity index 95% rename from src/components/menus/PresetMenu.tsx rename to src/components/context-menus/PresetMenu.tsx index 88f1966..dc20647 100644 --- a/src/components/menus/PresetMenu.tsx +++ b/src/components/context-menus/PresetMenu.tsx @@ -30,7 +30,7 @@ export const PresetMenuItems: VFC = ({ tabMasterManager, i case 'collection': return ( - {collectionStore.userCollections.map(({ displayName, id }: { displayName: string; id: string; }) => + {collectionStore.userCollections.concat([{ displayName: 'Hidden', id: 'hidden'}] as any).map(({ displayName, id }: { displayName: string; id: string; }) => tabMasterManager.createPresetTab(presetName, displayName, id, displayName)} {...getActionDescription(displayName)}> {displayName} diff --git a/src/components/context-menus/TabProfileMenu.tsx b/src/components/context-menus/TabProfileMenu.tsx new file mode 100644 index 0000000..dcc1ccd --- /dev/null +++ b/src/components/context-menus/TabProfileMenu.tsx @@ -0,0 +1,72 @@ +import { Menu, MenuGroup, MenuItem, showModal, GamepadButton } from 'decky-frontend-lib'; +import { VFC, Fragment, useState } from 'react'; +import { TabMasterManager } from '../../state/TabMasterManager'; +import { CreateTabProfileModal, OverwriteTabProfileModal } from '../modals/TabProfileModals'; +import { gamepadContextMenuClasses } from '../../lib/GamepadContextMenuClasses'; +import { DestructiveModal } from '../generic/DestructiveModal'; + + +interface TabsProfilesMenuProps { + tabMasterManager: TabMasterManager, +} + +/** + * Context menu for managing Tab Profiles. + */ +export const TabProfilesMenu: VFC = ({ tabMasterManager }) => { + return + + ; +}; + +/** + * Context menu sub-menu for managing Tab Profiles. + */ +export const TabProfilesSubMenu: VFC = ({ tabMasterManager }) => { + return + + ; +}; + +/** + * Menu items for the Tab Profiles context menu. + */ +const TabProfileMenuItems: VFC = ({ tabMasterManager }) => { + const [_refresh, setRefresh] = useState(true); + return ( + <> + showModal()}> + Create Profile + +
+ {Object.keys(tabMasterManager.tabProfileManager?.tabProfiles ?? {}).map(profileName => { + return ( + tabMasterManager.tabProfileManager?.apply(profileName, tabMasterManager)} + actionDescriptionMap={{ + [GamepadButton.OK]: 'Apply Profile', + [GamepadButton.SECONDARY]: 'Delete Profile', //X + [GamepadButton.OPTIONS]: 'Overwrite Profile', //Y + }} + onSecondaryButton={() => + showModal( { + tabMasterManager.tabProfileManager?.delete(profileName); + setRefresh(cur => !cur); + }} + strTitle={`Deleting Profile: ${profileName}`} + > + Are you sure you want to delete this profile? + ) + } + onOptionsButton={() => showModal()} + > + {profileName} + + ); + })} + + ); +}; + + diff --git a/src/components/docs/DocsRouter.tsx b/src/components/docs/DocsRouter.tsx new file mode 100644 index 0000000..7ec358b --- /dev/null +++ b/src/components/docs/DocsRouter.tsx @@ -0,0 +1,51 @@ +import { SidebarNavigation } from "decky-frontend-lib"; +import { VFC, ReactNode } from "react"; + +import { MdNumbers } from "react-icons/md"; +import { DocPage } from "./DocsPage"; + +type DocRouteEntry = { + title: string, + content: ReactNode, + route: string, + icon: ReactNode, + hideTitle: boolean; +}; + +type DocRoutes = { + [pageName: string]: DocRouteEntry; +}; + +type DocsRouterProps = { + docs: DocPages; +}; + +/** + * The documentation pages router for TabMaster. + */ +export const DocsRouter: VFC = ({ docs }) => { + const docPages: DocRoutes = {}; + Object.entries(docs).map(([pageName, doc]) => { + docPages[pageName] = { + title: pageName, + content: , + route: `/tab-master-docs/${pageName.toLowerCase().replace(/ /g, "-")}`, + icon: , + hideTitle: true + }; + }); + + return ( + + ); +}; diff --git a/src/components/filters/FilterOptions.tsx b/src/components/filters/FilterOptions.tsx index 364dbfc..64ee9c3 100644 --- a/src/components/filters/FilterOptions.tsx +++ b/src/components/filters/FilterOptions.tsx @@ -13,7 +13,7 @@ import React, { VFC, Fragment, useState, useMemo } from "react"; import { FaTag, FaTags, FaUser, FaCompactDisc, FaListCheck, FaSteam } from "react-icons/fa6"; import { FaUserFriends, FaQuestionCircle } from "react-icons/fa"; import { MdApps } from "react-icons/md"; -import { IoGameController } from "react-icons/io5"; +import { IoGameController, IoGrid } from "react-icons/io5"; import { BsWindow } from "react-icons/bs"; import { IconType } from "react-icons/lib"; @@ -29,6 +29,7 @@ import { IncludeCategories } from "../../lib/Utils"; import { Slider } from '../generic/Slider'; import { STEAM_FEATURES_ID_MAP, STEAM_FEATURES_TO_RENDER } from "./SteamFeatures"; import { MicroSDeckInterop } from '../../lib/controllers/MicroSDeckInterop'; +import { ListSearchDropdown } from "../modals/ListSearchModal"; type FilterOptionsProps = { index: number, @@ -55,11 +56,31 @@ function getAppIconType(entry: any): IconType { } } + +/** + * Gets an entry icon for a collection based on if its user made. + * @param entry The collection entry. + * @returns The icon for the collection. + */ +function getCollectionIcon(entry: any): IconType { + const collection = collectionStore.userCollections.find((collection: SteamCollection) => collection.id === entry.data); + if (collection?.bIsEditable) { + return FaUser; + } else { + return FaSteam; + } +} + /** * The options for a collection filter. */ const CollectionFilterOptions: VFC> = ({ index, setContainingGroupFilters, filter, containingGroupFilters }) => { - const collectionDropdownOptions: DropdownOption[] = collectionStore.userCollections.map((collection: { displayName: string; id: string; }) => { return { label: collection.displayName, data: collection.id }; }); + const collectionDropdownOptions: SingleDropdownOption[] = collectionStore.userCollections.concat([{ displayName: 'Hidden', id: 'hidden'}] as any).map((collection: SteamCollection) => { + return { + label: collection.displayName, + data: collection.id + }; + }); function onChange(data: SingleDropdownOption) { const updatedFilter = { ...filter }; @@ -73,7 +94,16 @@ const CollectionFilterOptions: VFC> = ({ index, return ( } + description={ + + } /> ); }; @@ -110,7 +140,7 @@ const RegexFilterOptions: VFC> = ({ index, setContai return ( } + description={} /> ); }; @@ -285,13 +315,12 @@ const MergeFilterOptions: VFC> = ({ index, filter, c {(isEditing ? "Edit" : "Create") + " Merge Group"} +
Merge Mode - {mergeParams.mode}
+
Filters:
- {mergeParams.filters.map((filter, index) => ( -
+ {mergeParams.filters.map((filter) => ( +
-
- {index !== mergeParams.filters.length - 1 && mergeParams.mode} -
))}
diff --git a/src/components/filters/FilterPreview.tsx b/src/components/filters/FilterPreview.tsx index 2959443..9e2cf08 100644 --- a/src/components/filters/FilterPreview.tsx +++ b/src/components/filters/FilterPreview.tsx @@ -1,104 +1,131 @@ -import { Fragment, VFC } from "react"; -import { FilterType, TabFilterSettings, compatCategoryToLabel } from "./Filters"; +import { Fragment, VFC, createElement } from "react"; +import { FilterIcons, FilterType, TabFilterSettings, compatCategoryToLabel } from "./Filters"; import { dateToLabel } from '../generic/DatePickers'; import { capitalizeEachWord } from '../../lib/Utils'; import { MicroSDeckInterop } from '../../lib/controllers/MicroSDeckInterop'; +type FilterPreviewGenericProps = { + filter: TabFilterSettings, + displayData: string | undefined, + isInverted?: boolean +}; + +const FilterPreviewGeneric: VFC = ({ filter, displayData, isInverted }) => { + return ( +
+
+ {createElement(FilterIcons[filter.type], { size: '.8em' })} +
+
+ {capitalizeEachWord(filter.type)}{' - ' + displayData + (isInverted ? " (inverted)" : "")} +
+
+ ); +} + type FilterPreviewProps = { filter: TabFilterSettings; }; - const CollectionFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.name ?? filter.params.id}{filter.inverted ? " (inverted)" : ""}
; + return ; }; const InstalledFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.installed ? "yes" : "no"}
; + return ; }; const RegexFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.regex}{filter.inverted ? " (inverted)" : ""}
; + return ; }; const FriendsFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.friends.length} {filter.params.friends.length == 1 ? "friend" : "friends"}{filter.inverted ? " (inverted)" : ""}
; + return ; }; const TagsFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.tags.length} {filter.params.tags.length == 1 ? "tag" : "tags"}{filter.inverted ? " (inverted)" : ""}
; + return ; }; const WhitelistFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.games.length} whitelisted
; + return ; }; const BlackListFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.games.length} blacklisted
; + return ; }; const MergeFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.filters.length} grouped filters{filter.inverted ? " (inverted)" : ""}
; + return ; }; const PlatformFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.platform === "steam" ? "Steam" : "Non Steam"}
; + return ; }; const DeckCompatFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{compatCategoryToLabel(filter.params.category)}{filter.inverted ? " (inverted)" : ""}
; + return ; }; const ReviewScoreFilterPreview: VFC> = ({ filter }) => { const { scoreThreshold, condition, type } = filter.params; - return
{capitalizeEachWord(filter.type) + ' - '}{type === 'metacritic' ? `Metacritic of ${scoreThreshold} or ${condition === 'above' ? 'higher' : 'lower'}` : `At ${condition === 'above' ? 'least' : 'most'} ${scoreThreshold}% positive Steam reviews`}
; + return ; }; const TimePlayedFilterPreview: VFC> = ({ filter }) => { const { timeThreshold, condition, units } = filter.params; - return
{capitalizeEachWord(filter.type) + ' - '}{`${timeThreshold} ${timeThreshold === 1 ? units.slice(0, -1) : units} or ${condition === 'above' ? 'more' : 'less'}`}
; + return ; }; const SizeOnDiskFilterPreview: VFC> = ({ filter }) => { const { gbThreshold, condition } = filter.params; - return
{capitalizeEachWord(filter.type) + ' - '}{`${gbThreshold} GB or ${condition === 'above' ? 'more' : 'less'}`}
; + return ; }; const ReleaseDateFilterPreview: VFC> = ({ filter }) => { + let displayData: string; + if (filter.params.date) { const { day, month, year } = filter.params.date; - return
{capitalizeEachWord(filter.type) + ' - '}{`${!day ? 'In' : 'On'} or ${filter.params.condition === 'above' ? 'after' : 'before'} ${dateToLabel(year, month, day, { dateStyle: 'long' })}`}
; + displayData = `${!day ? 'In' : 'On'} or ${filter.params.condition === 'above' ? 'after' : 'before'} ${dateToLabel(year, month, day, { dateStyle: 'long' })}`; } else { const daysAgo = filter.params.daysAgo; - return
{capitalizeEachWord(filter.type) + ' - '}{`${daysAgo} day${daysAgo === 1 ? '' : 's'} ago or ${filter.params.condition === 'above' ? 'later' : 'earlier'}`}
; + displayData = `${daysAgo} day${daysAgo === 1 ? '' : 's'} ago or ${filter.params.condition === 'above' ? 'later' : 'earlier'}`; } + + return ; }; const LastPlayedFilterPreview: VFC> = ({ filter }) => { + let displayData: string; + if (filter.params.date) { const { day, month, year } = filter.params.date; - return
{capitalizeEachWord(filter.type) + ' - '}{`${!day ? 'In' : 'On'} or ${filter.params.condition === 'above' ? 'after' : 'before'} ${dateToLabel(year, month, day, { dateStyle: 'long' })}`}
; + displayData = `${!day ? 'In' : 'On'} or ${filter.params.condition === 'above' ? 'after' : 'before'} ${dateToLabel(year, month, day, { dateStyle: 'long' })}`; } else { const daysAgo = filter.params.daysAgo; - return
{capitalizeEachWord(filter.type) + ' - '}{`${daysAgo} day${daysAgo === 1 ? '' : 's'} ago or ${filter.params.condition === 'above' ? 'later' : 'earlier'}`}
; + displayData = `${daysAgo} day${daysAgo === 1 ? '' : 's'} ago or ${filter.params.condition === 'above' ? 'later' : 'earlier'}`; } + + return ; }; + const DemoFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.isDemo ? "yes" : "no"}
; + return ; }; const StreamableFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.isStreamable ? "yes" : "no"}
; + return ; }; const SteamFeaturesFilterPreview: VFC> = ({ filter }) => { - return
{capitalizeEachWord(filter.type) + ' - '}{filter.params.features.length} {filter.params.features.length == 1 ? "feature" : "features"}{filter.inverted ? " (inverted)" : ""}
; + return ; }; const SDCardFilterPreview: VFC> = ({ filter }) => { const isInsertCard = !filter.params.card; - const card = (MicroSDeckInterop.isInstallOk() && window.MicroSDeck?.CardsAndGames.find(([card]) => card.uid === filter.params.card)?.[0].name) || filter.params.card - return
{capitalizeEachWord(filter.type) + ' - '}{isInsertCard ? 'Inserted Card' : card}{filter.inverted ? " (inverted)" : ""}
; + const card = (MicroSDeckInterop.isInstallOk() && window.MicroSDeck?.CardsAndGames.find(([card]) => card.uid === filter.params.card)?.[0].name) || filter.params.card; + return ; } /** diff --git a/src/components/filters/FilterSelect.tsx b/src/components/filters/FilterSelect.tsx index c0bb632..7d81902 100644 --- a/src/components/filters/FilterSelect.tsx +++ b/src/components/filters/FilterSelect.tsx @@ -1,6 +1,6 @@ -import { Fragment, VFC, useEffect, useState } from "react"; +import { Fragment, VFC, createElement, useEffect, useState } from "react"; import { Focusable, ModalRoot, SingleDropdownOption } from "decky-frontend-lib"; -import { FilterDefaultParams, FilterDescriptions, FilterType } from "./Filters"; +import { FilterDefaultParams, FilterDescriptions, FilterIcons, FilterType } from "./Filters"; import { capitalizeEachWord } from "../../lib/Utils"; import { FilterSelectStyles, achievementClasses, mainMenuAppRunningClasses } from "../styles/FilterSelectionStyles"; import { IoFilter } from 'react-icons/io5' @@ -54,12 +54,15 @@ interface FilterSelectElement { const FilterSelectElement: VFC = ({ filterType, focusable, onClick }) => { let disabled = false; let requiredMicroSDeckVer = ''; + if (filterType === 'sd card') { disabled = !MicroSDeckInterop.isInstallOk(); const [major, minor, patch] = microSDeckLibVersion.split(/[.+-]/, 3); + if (+major > 0) requiredMicroSDeckVer = major + '.x.x'; if (+major === 0) requiredMicroSDeckVer = `0.${minor}.${patch}`; } + const canFocus = focusable && !disabled; return ( @@ -73,9 +76,14 @@ const FilterSelectElement: VFC = ({ filterType, focusable, className={`${achievementClasses.AchievementListItemBase} ${disabled && "entry-disabled"}`} style={{ display: "flex", flexDirection: "column", padding: "0.5em", height: "60px" }} > -
- {capitalizeEachWord(filterType)} - {filterType === 'sd card' && {`requires MicroSDeck ${requiredMicroSDeckVer}`}} +
+
+ {createElement(FilterIcons[filterType], { size: '.8em' })} +
+
+ {capitalizeEachWord(filterType)} +
+ {filterType === 'sd card' && {`requires MicroSDeck ${requiredMicroSDeckVer}`}}
{FilterDescriptions[filterType]}
diff --git a/src/components/filters/Filters.tsx b/src/components/filters/Filters.tsx index 5427a0c..85f22b1 100644 --- a/src/components/filters/Filters.tsx +++ b/src/components/filters/Filters.tsx @@ -1,7 +1,14 @@ +import { IconType } from "react-icons/lib"; import { MicroSDeckInterop } from '../../lib/controllers/MicroSDeckInterop'; import { PluginController } from "../../lib/controllers/PluginController"; import { DateIncludes, DateObj } from '../generic/DatePickers'; import { STEAM_FEATURES_ID_MAP } from "./SteamFeatures"; +import { FaCheckCircle, FaHdd, FaSdCard, FaUserFriends } from "react-icons/fa"; +import { IoGrid } from "react-icons/io5"; +import { SiSteamdeck } from "react-icons/si"; +import { FaAward, FaBan, FaCalendarDays, FaCloudArrowDown, FaCompactDisc, FaListCheck, FaPlay, FaRegClock, FaSteam, FaTags } from "react-icons/fa6"; +import { BsClockHistory, BsRegex } from "react-icons/bs"; +import { LuCombine } from "react-icons/lu"; export type FilterType = 'collection' | 'installed' | 'regex' | 'friends' | 'tags' | 'whitelist' | 'blacklist' | 'merge' | 'platform' | 'deck compatibility' | 'review score' | 'time played' | 'size on disk' | 'release date' | 'last played' | 'demo' | 'streamable' | 'steam features' | 'sd card'; @@ -75,7 +82,7 @@ type FilterFunction = (params: FilterParams, appOverview: SteamAppOv * Checking and settings defaults in component is unnecessary */ export const FilterDefaultParams: { [key in FilterType]: FilterParams } = { - "collection": { id: "", name: "" }, + "collection": { id: "favorite", name: "Favorites" }, "installed": { installed: true }, "regex": { regex: "" }, "friends": { friends: [], mode: 'and' }, @@ -118,7 +125,32 @@ export const FilterDescriptions: { [filterType in FilterType]: string } = { demo: "Selects apps that are/aren't demos.", streamable: "Selects apps that can/can't be streamed from another computer.", "steam features": "Selects apps that support specific Steam Features.", - "sd card": "Selects apps that are present on the inserted/specific MicroSD Card" + "sd card": "Selects apps that are present on the inserted/specific MicroSD Card." +} + +/** + * Dictionary of icons for each filter. + */ +export const FilterIcons: { [filterType in FilterType]: IconType } = { + collection: IoGrid, + installed: FaPlay, + regex: BsRegex, + friends: FaUserFriends, + tags: FaTags, + whitelist: FaCheckCircle, + blacklist: FaBan, + merge: LuCombine, + platform: FaSteam, + "deck compatibility": SiSteamdeck, + "review score": FaAward, + "time played": FaRegClock, + "size on disk": FaHdd, + "release date": FaCalendarDays, + "last played": BsClockHistory, + demo: FaCompactDisc, + streamable: FaCloudArrowDown, + "steam features": FaListCheck, + "sd card": FaSdCard } diff --git a/src/components/generic/ScrollableWindow.tsx b/src/components/generic/ScrollableWindow.tsx new file mode 100644 index 0000000..87981c4 --- /dev/null +++ b/src/components/generic/ScrollableWindow.tsx @@ -0,0 +1,66 @@ +import { GamepadButton, gamepadDialogClasses, scrollPanelClasses } from 'decky-frontend-lib'; +import { FC, Fragment, useRef } from 'react'; +import { ModalPosition, Panel, ScrollPanelGroup } from '../docs/Scrollable'; +import { useIsOverflowing } from '../../hooks/useIsOverflowing'; + +export interface ScrollableWindowProps { + height: string; + fadeAmount: string; + scrollBarWidth?: string; +} +export const ScrollableWindow: FC = ({ height, fadeAmount, scrollBarWidth, children }) => { + const barWidth = scrollBarWidth === undefined || scrollBarWidth === '' ? '4px' : scrollBarWidth; + + const scrollPanelRef = useRef(); + const isOverflowing = useIsOverflowing(scrollPanelRef); + + const panel = ( + + + {children} + + + ); + + return ( + <> + +
+ {isOverflowing ? ( + + {panel} + + ) : ( +
+ {panel} +
+ )} +
+ + ); +}; diff --git a/src/components/modals/EditMergeFilterModal.tsx b/src/components/modals/EditMergeFilterModal.tsx index ba090b8..a1f54f9 100644 --- a/src/components/modals/EditMergeFilterModal.tsx +++ b/src/components/modals/EditMergeFilterModal.tsx @@ -2,7 +2,7 @@ import { ConfirmModal } from "decky-frontend-lib"; import { VFC, useState, Fragment, useEffect } from "react"; import { ModalStyles } from "../styles/ModalStyles"; import { FiltersPanel } from "../filters/FiltersPanel"; -import { TabFilterSettings, FilterType } from "../filters/Filters"; +import { TabFilterSettings, FilterType, FilterDefaultParams } from "../filters/Filters"; import { isValidParams } from "../filters/Filters"; import { PythonInterop } from "../../lib/controllers/PythonInterop"; @@ -40,7 +40,7 @@ export const EditMergeFilterModal: VFC = ({ closeModa updatedFilters.push({ type: "collection", inverted: false, - params: { id: "", name: "" } + params: FilterDefaultParams.collection }); setGroupFilters(updatedFilters); diff --git a/src/components/modals/EditTabModal.tsx b/src/components/modals/EditTabModal.tsx index f2c9555..e4bbe09 100644 --- a/src/components/modals/EditTabModal.tsx +++ b/src/components/modals/EditTabModal.tsx @@ -10,7 +10,7 @@ import { showModal } from "decky-frontend-lib"; import { useState, VFC, useEffect, Fragment } from "react"; -import { FilterType, TabFilterSettings, isValidParams } from "../filters/Filters"; +import { FilterDefaultParams, FilterType, TabFilterSettings, isValidParams } from "../filters/Filters"; import { PythonInterop } from "../../lib/controllers/PythonInterop"; import { TabMasterContextProvider } from "../../state/TabMasterContext"; import { TabMasterManager } from "../../state/TabMasterManager"; @@ -47,7 +47,7 @@ export const EditTabModal: VFC = ({ closeModal, onConfirm, ta const [patchInput, setPatchInput] = useState(true); const [autoHide, setAutoHide] = useState(_autoHide); - const nameInputElement = ; + const nameInputElement = ; //reference to input field class component instance, which has a focus method let inputComponentInstance: any; @@ -99,7 +99,7 @@ export const EditTabModal: VFC = ({ closeModal, onConfirm, ta updatedFilters.push({ type: "collection", inverted: false, - params: { id: "", name: "" } + params: FilterDefaultParams.collection }); setTopLevelFilters(updatedFilters); } diff --git a/src/components/modals/ListSearchModal.tsx b/src/components/modals/ListSearchModal.tsx index 28013cd..c127b45 100644 --- a/src/components/modals/ListSearchModal.tsx +++ b/src/components/modals/ListSearchModal.tsx @@ -9,13 +9,14 @@ import { SingleDropdownOption, TextField } from "decky-frontend-lib"; -import { VFC, useEffect, useState } from "react"; +import { VFC, useEffect, useMemo, useState } from "react"; import { IconType } from "react-icons/lib"; import { FixedSizeList as List } from "react-window"; import AutoSizer from "react-virtualized-auto-sizer"; import { FaMagnifyingGlass } from "react-icons/fa6"; import { BaseModalProps, CustomDropdown } from "../generic/CustomDropdown"; import { ListSearchModalStyles } from "../styles/ListSearchModalStyles"; +import { BiSolidDownArrow, BiSolidUpArrow } from "react-icons/bi"; export type ListSearchModalProps = { rgOptions: SingleDropdownOption[], @@ -33,12 +34,28 @@ const iconStyles = { export const ListSearchModal: VFC = ({ rgOptions: list, entryLabel, determineEntryIcon, onSelectOption, closeModal }: ListSearchModalProps) => { const [query, setQuery] = useState(""); const [filteredList, setFilteredList] = useState(list); + const [renderTopArrow, setRenderTopArrow] = useState(false); + const [renderBottomArrow, setRenderBottomArrow] = useState(true); useEffect(() => { setFilteredList(list.filter((entry) => (entry.label as string).toLowerCase().includes(query.toLowerCase()))); }, [query]); - const ListEntry = ({ index, style }: { index: number, style: any}) => { + function onItemsRendered({ visibleStartIndex, visibleStopIndex }: { visibleStartIndex: number, visibleStopIndex: number }) { + if (!renderTopArrow && visibleStartIndex !== 0) { + setRenderTopArrow(true); + } else if (renderTopArrow && visibleStartIndex === 0) { + setRenderTopArrow(false); + } + + if (!renderBottomArrow && visibleStopIndex !== filteredList.length - 1) { + setRenderBottomArrow(true); + } else if (renderBottomArrow && visibleStopIndex === filteredList.length - 1) { + setRenderBottomArrow(false); + } + } + + const ListEntry = useMemo<(props: { index: number, style: any}) => JSX.Element >(() => ({ index, style }) => { const EntryIcon = determineEntryIcon(filteredList[index]); return (
@@ -57,7 +74,7 @@ export const ListSearchModal: VFC = ({ rgOptions: list, en
); - }; + }, [filteredList]); return (
@@ -84,6 +101,7 @@ export const ListSearchModal: VFC = ({ rgOptions: list, en
{ setQuery(e.target.value); }} style={{ height: "100%" }} /> @@ -91,25 +109,34 @@ export const ListSearchModal: VFC = ({ rgOptions: list, en - - - - {/* @ts-ignore */} - {({ height, width }) => ( - - {ListEntry} - - )} - - - +
+
+ {renderTopArrow && } +
+ + + + {/* @ts-ignore */} + {({ height, width }) => ( + + {ListEntry} + + )} + + + +
+ {renderBottomArrow && } +
+
@@ -117,7 +144,7 @@ export const ListSearchModal: VFC = ({ rgOptions: list, en ); }; -export type ListSearchTrigger = { +export type ListSearchTriggerProps = { entryLabel: string, labelOverride: string, options: SingleDropdownOption[], @@ -127,7 +154,7 @@ export type ListSearchTrigger = { disabled: boolean } -export function ListSearchTrigger({ entryLabel, labelOverride, options, onChange, TriggerIcon, determineEntryIcon, disabled }: ListSearchTrigger) { +export function ListSearchTrigger({ entryLabel, labelOverride, options, onChange, TriggerIcon, determineEntryIcon, disabled }: ListSearchTriggerProps) { const ModalWrapper: VFC = ({ onSelectOption, rgOptions, closeModal }: BaseModalProps) => { return } @@ -143,3 +170,34 @@ export function ListSearchTrigger({ entryLabel, labelOverride, options, onChange /> ); } + +export type ListSearchDropdownProps = { + entryLabel: string, + rgOptions: SingleDropdownOption[], + selectedOption: any, + onChange: (option: SingleDropdownOption) => void, + TriggerIcon: IconType, + determineEntryIcon: (entry?: any) => IconType, + disabled?: boolean +} + +export function ListSearchDropdown({ entryLabel, rgOptions, selectedOption, onChange, TriggerIcon, determineEntryIcon, disabled }: ListSearchDropdownProps) { + const [selected, setSelected] = useState(rgOptions.find((option: SingleDropdownOption) => option.data === selectedOption)!); + + function onChangeWrapper(data: SingleDropdownOption) { + setSelected(data); + onChange(data); + } + + return ( + + ); +} diff --git a/src/components/modals/TabProfileModals.tsx b/src/components/modals/TabProfileModals.tsx new file mode 100644 index 0000000..619a91c --- /dev/null +++ b/src/components/modals/TabProfileModals.tsx @@ -0,0 +1,131 @@ +import { ConfirmModal, Field, TextField, quickAccessControlsClasses } from 'decky-frontend-lib'; +import { VFC, useState, Fragment, FC } from 'react'; +import { TabMasterManager } from '../../state/TabMasterManager'; +import { TabMasterContextProvider } from "../../state/TabMasterContext"; +import { TabProfileModalStyles } from "../styles/TabProfileModalStyles"; +import { TabListLabel } from '../TabListLabel'; +import { ScrollableWindow } from '../generic/ScrollableWindow'; +import { DestructiveModal } from '../generic/DestructiveModal'; + +export interface CreateTabProfileModalProps { + tabMasterManager: TabMasterManager, + closeModal?: () => void, +} + +export const CreateTabProfileModal: VFC = ({ tabMasterManager, closeModal }) => { + const [name, setName] = useState(''); + const visibleTabs = tabMasterManager.getTabs().visibleTabsList; + + function onNameChange(e: React.ChangeEvent) { + setName(e?.target.value); + } + + return ( + + +
+ { + tabMasterManager.tabProfileManager?.write(name, visibleTabs.map(tabContainer => tabContainer.id)); + closeModal!(); + }} + onCancel={() => closeModal!()} + > +
+ +
+ Profile Name +
+ + + } /> +
+
+ +
+ {visibleTabs.map(tabContainer => + + + + )} +
+
+
+
+
+
+ ); +}; + +export interface OverwriteTabProfileModalProps extends CreateTabProfileModalProps { + profileName: string; +} + +export const OverwriteTabProfileModal: VFC = ({ profileName, tabMasterManager, closeModal }) => { + const { visibleTabsList, tabsMap } = tabMasterManager.getTabs(); + const existingTabs = tabMasterManager.tabProfileManager!.tabProfiles[profileName].map(tabId => tabsMap.get(tabId)); + + return ( + + +
+ { + tabMasterManager.tabProfileManager?.write(profileName, visibleTabsList.map(tabContainer => tabContainer.id)); + closeModal!(); + }} + onCancel={() => closeModal!()} + > +
+
+
+ New Tabs +
+
+ Existing Tabs +
+
+
+
+ +
+
+ {visibleTabsList.map(tabContainer => + + + + )} +
+
+ {existingTabs.map(tabContainer => + + + + )} +
+
+
+
+
+ +
+ + ); +}; + +const TabItem: FC<{}> = ({ children }) => { + + return ( + <> +
+ {children} +
+
+ + ); +}; + + diff --git a/src/components/multi-selects/ModeMultiSelect.tsx b/src/components/multi-selects/ModeMultiSelect.tsx index 3e65b43..0021339 100644 --- a/src/components/multi-selects/ModeMultiSelect.tsx +++ b/src/components/multi-selects/ModeMultiSelect.tsx @@ -90,7 +90,7 @@ export const ModeMultiSelect:VFC = ({ options, selected, f labelOverride={dropdownSelected.label!} disabled={available.length == 0 || (!!maxOptions && selected.length == maxOptions)} TriggerIcon={TriggerIcon} - determineEntryIcon={(entry) => { return determineEntryIcon ? determineEntryIcon(entry) : EntryIcon}} + determineEntryIcon={(entry) => { return (determineEntryIcon ? determineEntryIcon(entry) : EntryIcon) as IconType }} /> = ({}) => { background: radial-gradient(155.42% 100% at 0% 0%, #060a0e 0 0%, #0e141b 100%); } - .tab-master-filter-select .gpfocuswithin .${achievementClasses.AchievementListItemBase} { - background: #767a8773; - } - .tab-master-filter-select .entry-label { font-size: 22px; text-align: initial; @@ -124,6 +120,7 @@ export const FilterSelectStyles: VFC<{}> = ({}) => { .tab-master-filter-select .entry-disabled { color: #92939B; + background-color: #20222996; } .tab-master-filter-select .entry-desc { diff --git a/src/components/styles/ListSearchModalStyles.tsx b/src/components/styles/ListSearchModalStyles.tsx index 9aab61d..d1049ec 100644 --- a/src/components/styles/ListSearchModalStyles.tsx +++ b/src/components/styles/ListSearchModalStyles.tsx @@ -18,6 +18,44 @@ export const ListSearchModalStyles: VFC<{}> = ({}) => { .tab-master-list-search-modal .${gamepadDialogClasses.ModalPosition} > .${gamepadDialogClasses.GamepadDialogContent} { background: radial-gradient(155.42% 100% at 0% 0%, #060a0e 0 0%, #0e141b 100%); } + + @keyframes tab-master-arrow-bounce-up { + 0% { transform: translateY(1px) } + 50% { transform: translateY(-2px) } + 100% { transform: translateY(1px) } + } + + @keyframes tab-master-arrow-bounce-down { + 0% { transform: translateY(-1px) } + 50% { transform: translateY(2px) } + 100% { transform: translateY(-1px) } + } + + .tab-master-list-search-modal .more-above-arrow { + position: absolute; + top: 14px; + + display: flex; + justify-content: center; + width: 100%; + + transition: visibility 0.2s ease-in-out; + + animation: tab-master-arrow-bounce-up 2.7s infinite ease-in-out; + } + + .tab-master-list-search-modal .more-below-arrow { + position: absolute; + bottom: -16px; + + display: flex; + justify-content: center; + width: 100%; + + transition: visibility 0.2s ease-in-out; + + animation: tab-master-arrow-bounce-down 2.7s infinite ease-in-out; + } `} ); } diff --git a/src/components/styles/ModalStyles.tsx b/src/components/styles/ModalStyles.tsx index 5110713..8f6dbdc 100644 --- a/src/components/styles/ModalStyles.tsx +++ b/src/components/styles/ModalStyles.tsx @@ -98,13 +98,6 @@ export const ModalStyles: VFC<{}> = ({}) => { color: #a9a9a9; } - /* merge entries */ - .tab-master-modal-scope .merge-filter-entries .merge-filter-entry-container { - margin: 5px; - display: flex; - justify-content: space-between; - } - .autohide-toggle-container .${gamepadDialogClasses.Field} { padding: 10px calc(28px + 1.4vw); } diff --git a/src/components/styles/TabProfileModalStyles.tsx b/src/components/styles/TabProfileModalStyles.tsx new file mode 100644 index 0000000..21d0862 --- /dev/null +++ b/src/components/styles/TabProfileModalStyles.tsx @@ -0,0 +1,56 @@ +import { gamepadDialogClasses } from "decky-frontend-lib"; +import { VFC } from "react"; + +// New modal background should be "radial-gradient(155.42% 100% at 0% 0%, #060a0e 0 0%, #0e141b 100%)" + +/** + * CSS styling for TabMaster's Tab profile modals. + */ +export const TabProfileModalStyles: VFC<{}> = ({}) => { + return ( + + ); +} diff --git a/src/hooks/useIsOverflowing.tsx b/src/hooks/useIsOverflowing.tsx new file mode 100644 index 0000000..aba924e --- /dev/null +++ b/src/hooks/useIsOverflowing.tsx @@ -0,0 +1,19 @@ +import { MutableRefObject, useLayoutEffect, useState } from 'react'; + +export const useIsOverflowing = (ref: MutableRefObject) => { + const [isOverflow, setIsOverflow] = useState(false); + + useLayoutEffect(() => { + const { current } = ref; + const trigger = () => { + const hasOverflow = current!.scrollHeight > current!.clientHeight; + setIsOverflow(hasOverflow); + }; + + if (current) { + trigger(); + } + }, [ref]); + + return isOverflow; +}; diff --git a/src/index.tsx b/src/index.tsx index 237b3c7..7ee7ee5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,46 +1,26 @@ import { - ButtonItem, definePlugin, - DialogButton, - Field, - Focusable, - Navigation, - PanelSection, - ReorderableEntry, - ReorderableList, RoutePatch, ServerAPI, - showContextMenu, - SidebarNavigation, - staticClasses, } from "decky-frontend-lib"; -import { VFC, ReactNode, useState } from "react"; import { TbLayoutNavbarExpand } from "react-icons/tb"; -import { FaCircleExclamation } from "react-icons/fa6"; -import { PiListPlusBold } from "react-icons/pi"; -import { MdNumbers } from "react-icons/md"; import { PluginController } from "./lib/controllers/PluginController"; import { PythonInterop } from "./lib/controllers/PythonInterop"; -import { TabMasterContextProvider, useTabMasterContext } from "./state/TabMasterContext"; +import { TabMasterContextProvider } from "./state/TabMasterContext"; import { TabMasterManager } from "./state/TabMasterManager"; import { patchLibrary } from "./patches/LibraryPatch"; import { patchSettings } from "./patches/SettingsPatch"; -import { QamStyles } from "./components/styles/QamStyles"; -import { showModalNewTab } from "./components/modals/EditTabModal"; -import { TabActionsButton } from "./components/TabActions"; import { LogController } from "./lib/controllers/LogController"; -import { DocPage } from "./components/docs/DocsPage"; -import { PresetMenu } from './components/menus/PresetMenu'; -import { TabListLabel } from './components/TabListLabel'; import { MicroSDeck } from "@cebbinghaus/microsdeck"; -import { MicroSDeckInstallState, MicroSDeckInterop, microSDeckLibVersion } from './lib/controllers/MicroSDeckInterop'; -import { MicroSDeckNotice } from './components/MicroSDeckNotice'; -import { CustomTabContainer } from './components/CustomTabContainer'; +import { MicroSDeckInterop } from './lib/controllers/MicroSDeckInterop'; +import { QuickAccessContent, QuickAccessTitleView } from "./components/QuickAccessContent"; +import { DocsRouter } from "./components/docs/DocsRouter"; +import { Fragment } from 'react'; declare global { let DeckyPluginLoader: { pluginReloadQueue: { name: string; version?: string; }[]; }; @@ -55,186 +35,6 @@ declare global { let settingsStore: SettingsStore; } -export type TabIdEntryType = { - id: string; -}; - -interface TabEntryInteractablesProps { - entry: ReorderableEntry; -} - -/** - * The Quick Access Menu content for TabMaster. - */ -const Content: VFC<{}> = ({ }) => { - const [microSDeckNoticeHidden, setMicroSDeckNoticeHidden] = useState(MicroSDeckInterop.noticeHidden); - const { visibleTabsList, hiddenTabsList, tabsMap, tabMasterManager } = useTabMasterContext(); - - const microSDeckInstallState = MicroSDeckInterop.getInstallState(); - const isMicroSDeckInstalled = microSDeckInstallState === MicroSDeckInstallState['good']; - const hasSdTabs = !!visibleTabsList.find(tabContainer => (tabContainer as CustomTabContainer).dependsOnMicroSDeck); - - function TabEntryInteractables({ entry }: TabEntryInteractablesProps) { - const tabContainer = tabsMap.get(entry.data!.id)!; - return (); - } - - const entries = visibleTabsList.map((tabContainer) => { - return { - label: , - position: tabContainer.position, - data: { id: tabContainer.id } - }; - }); - - return ( -
- {LogController.errorFlag &&
-

- - Tab Master encountered an error -

- -
} - {hasSdTabs && !isMicroSDeckInstalled && !microSDeckNoticeHidden && ( -
- - -
- { - MicroSDeckInterop.noticeHidden = true; - setMicroSDeckNoticeHidden(true); - }} - > - Hide Notice - -
-
-
- )} - - { Navigation.CloseSideMenus(); Navigation.Navigate("/tab-master-docs"); }}> -
- Here you can add, re-order, or remove tabs from the library. -
- - - - showModalNewTab(tabMasterManager)} onOKActionDescription={'Add Tab'}> - Add Tab - - - {tabMasterManager.hasSettingsLoaded && - - showContextMenu()} - > - - - } - - - -
- {tabMasterManager.hasSettingsLoaded ? ( - - entries={entries} - interactables={TabEntryInteractables} - onSave={(entries: ReorderableEntry[]) => { - tabMasterManager.reorderTabs(entries.map(entry => entry.data!.id)); - }} - /> - ) : ( -
- Loading... -
- )} -
- -
- { - hiddenTabsList.map(tabContainer => -
- } - onClick={() => tabMasterManager.showTab(tabContainer.id)} - onOKActionDescription="Unhide tab" - > - Show - -
- ) - } -
- {hasSdTabs && !isMicroSDeckInstalled && microSDeckNoticeHidden && ( - { }}> - - - )} -
-
- ); -}; - -type DocRouteEntry = { - title: string, - content: ReactNode, - route: string, - icon: ReactNode, - hideTitle: boolean; -}; - -type DocRoutes = { - [pageName: string]: DocRouteEntry; -}; - -type TabMasterDocsRouterProps = { - docs: DocPages; -}; - -/** - * The documentation pages router for TabMaster. - */ -const TabMasterDocsRouter: VFC = ({ docs }) => { - const docPages: DocRoutes = {}; - Object.entries(docs).map(([pageName, doc]) => { - docPages[pageName] = { - title: pageName, - content: , - route: `/tab-master-docs/${pageName.toLowerCase().replace(/ /g, "-")}`, - icon: , - hideTitle: true - }; - }); - - return ( - - ); -}; - export default definePlugin((serverAPI: ServerAPI) => { let libraryPatch: RoutePatch; @@ -252,25 +52,23 @@ export default definePlugin((serverAPI: ServerAPI) => { settingsPatch = patchSettings(serverAPI, tabMasterManager); }); - const onWakeUnregister = SteamClient.System.RegisterForOnResumeFromSuspend(PluginController.onWakeFromSleep.bind(PluginController)).unregister; - PythonInterop.getDocs().then((pages: DocPages | Error) => { if (pages instanceof Error) { LogController.error(pages); } else { serverAPI.routerHook.addRoute("/tab-master-docs", () => ( - + )); } }); - return { - title:
TabMaster
, + title: <>, + titleView: , content: - + , icon: , onDismount: () => { @@ -279,7 +77,6 @@ export default definePlugin((serverAPI: ServerAPI) => { serverAPI.routerHook.removeRoute("/tab-master-docs"); loginUnregisterer.unregister(); - onWakeUnregister(); PluginController.dismount(); }, }; diff --git a/src/lib/controllers/MicroSDeckInterop.ts b/src/lib/controllers/MicroSDeckInterop.ts index bc94c4a..8663c41 100644 --- a/src/lib/controllers/MicroSDeckInterop.ts +++ b/src/lib/controllers/MicroSDeckInterop.ts @@ -103,18 +103,22 @@ export class MicroSDeckInterop { * @returns MicroSDeckInstallState */ private static checkVersion() { - const [pluginVerMajor, pluginVerMinor, pluginVerPatch] = window.MicroSDeck!.Version.split(/[.+-]/, 3).map(str => +str); - const [libVerMajor, libVerMinor, libVerPatch] = microSDeckLibVersion.split(/[.+-]/, 3).map(str => +str); + if (window.MicroSDeck?.Version) { + const [pluginVerMajor, pluginVerMinor, pluginVerPatch] = window.MicroSDeck!.Version.split(/[.+-]/, 3).map(str => +str); + const [libVerMajor, libVerMinor, libVerPatch] = microSDeckLibVersion.split(/[.+-]/, 3).map(str => +str); - if (isNaN(pluginVerMajor) || isNaN(pluginVerMinor) || isNaN(pluginVerPatch) || isNaN(libVerMajor) || isNaN(libVerMinor) || isNaN(libVerPatch)) return MicroSDeckInstallState['ver unknown']; - if (pluginVerMajor === 0 && libVerMajor === 0) { - if (pluginVerMinor > libVerMinor || pluginVerPatch > libVerPatch) return MicroSDeckInstallState['ver too high']; - if (pluginVerMinor < libVerMinor || pluginVerPatch < libVerPatch) return MicroSDeckInstallState['ver too low']; + if (isNaN(pluginVerMajor) || isNaN(pluginVerMinor) || isNaN(pluginVerPatch) || isNaN(libVerMajor) || isNaN(libVerMinor) || isNaN(libVerPatch)) return MicroSDeckInstallState['ver unknown']; + if (pluginVerMajor === 0 && libVerMajor === 0) { + if (pluginVerMinor > libVerMinor || pluginVerPatch > libVerPatch) return MicroSDeckInstallState['ver too high']; + if (pluginVerMinor < libVerMinor || pluginVerPatch < libVerPatch) return MicroSDeckInstallState['ver too low']; + return MicroSDeckInstallState['good']; + } + + if (pluginVerMajor > libVerMajor) return MicroSDeckInstallState['ver too high']; + if (pluginVerMajor < libVerMajor) return MicroSDeckInstallState['ver too low']; return MicroSDeckInstallState['good']; + } else { + return MicroSDeckInstallState['ver too low']; //* version is so old it doesn't have the Version prop. } - - if (pluginVerMajor > libVerMajor) return MicroSDeckInstallState['ver too high']; - if (pluginVerMajor < libVerMajor) return MicroSDeckInstallState['ver too low']; - return MicroSDeckInstallState['good']; } } diff --git a/src/lib/controllers/PluginController.tsx b/src/lib/controllers/PluginController.tsx index 0cdb841..18c5ab9 100644 --- a/src/lib/controllers/PluginController.tsx +++ b/src/lib/controllers/PluginController.tsx @@ -46,6 +46,8 @@ export class PluginController { private static steamController: SteamController; + private static onWakeSub: Unregisterer; + /** * Sets the plugin's serverAPI. * @param server The serverAPI to use. @@ -79,6 +81,8 @@ export class PluginController { */ static async init(): Promise { LogController.log("PluginController initialized."); + + this.onWakeSub = this.steamController.registerForOnResumeFromSuspend(this.onWakeFromSleep.bind(this)); // @ts-ignore return new Promise(async (resolve, reject) => { @@ -121,7 +125,10 @@ export class PluginController { * Function to run when the plugin dismounts. */ static dismount(): void { + if (this.onWakeSub) this.onWakeSub.unregister(); + this.tabMasterManager.disposeReactions(); + LogController.log("PluginController dismounted."); } } diff --git a/src/lib/controllers/PythonInterop.ts b/src/lib/controllers/PythonInterop.ts index 22c78e6..ddcfc56 100644 --- a/src/lib/controllers/PythonInterop.ts +++ b/src/lib/controllers/PythonInterop.ts @@ -1,5 +1,6 @@ import { ServerAPI } from "decky-frontend-lib"; import { validateTabs } from "../Utils"; +import { TabProfileDictionary } from '../../state/TabProfileManager'; /** * Class for frontend -> backend communication. @@ -135,8 +136,8 @@ export class PythonInterop { } /** - * Gets the store tabs. - * @returns A promise resolving to the store tabs. + * Gets the store tags. + * @returns A promise resolving to the store tags. */ static async getTags(): Promise { let result = await PythonInterop.serverAPI.callPluginMethod<{}, TagResponse[]>("get_tags", {}); @@ -180,6 +181,20 @@ export class PythonInterop { } } + /** + * Gets the user's tab profiles. + * @returns A promise resolving the user's tab profiles. + */ + static async getTabProfiles(): Promise { + let result = await PythonInterop.serverAPI.callPluginMethod<{}, TabProfileDictionary>("get_tab_profiles", {}); + + if (result.success) { + return result.result; + } else { + return new Error(result.result); + }; + } + /** * Sets the plugin's tabs. * @param tabs The plugin's tabsDictionary. @@ -251,6 +266,21 @@ export class PythonInterop { }; } + /** + * Sets the user's tab profiles. + * @param tabProfiles The tab profiles. + * @returns A promise resolving to whether or not the tab profiles were successfully set. + */ + static async setTabProfiles(tabProfiles: TabProfileDictionary): Promise { + let result = await PythonInterop.serverAPI.callPluginMethod<{ tab_profiles: TabProfileDictionary }, void>("set_tab_profiles", { tab_profiles: tabProfiles }); + + if (result.success) { + return result.result; + } else { + return new Error(result.result); + }; + } + /** * Shows a toast message. * @param title The title of the toast. diff --git a/src/lib/controllers/SteamController.ts b/src/lib/controllers/SteamController.ts index 666918a..fe2c6d6 100644 --- a/src/lib/controllers/SteamController.ts +++ b/src/lib/controllers/SteamController.ts @@ -69,6 +69,15 @@ export class SteamController { })) ?? false; } + /** + * Register a function for when the Steamdeck resumes from sleep. + * @param callback The callback to register. + * @returns A function that unsubscribes the callback. + */ + registerForOnResumeFromSuspend(callback: () => void): Unregisterer { + return SteamClient.System.RegisterForOnResumeFromSuspend(callback); + } + /** * Gets the localized tags from a list of ids. * @param tags The list of tag ids. diff --git a/src/patches/LibraryPatch.tsx b/src/patches/LibraryPatch.tsx index 929db98..cb76565 100644 --- a/src/patches/LibraryPatch.tsx +++ b/src/patches/LibraryPatch.tsx @@ -11,7 +11,7 @@ import { ReactElement, useEffect, useState } from "react"; import { TabMasterManager } from "../state/TabMasterManager"; import { CustomTabContainer } from "../components/CustomTabContainer"; import { LogController } from "../lib/controllers/LogController"; -import { LibraryMenu } from '../components/menus/LibraryMenu'; +import { LibraryMenu } from '../components/context-menus/LibraryMenu'; import { MicroSDeckInterop } from '../lib/controllers/MicroSDeckInterop'; /** @@ -87,11 +87,7 @@ export const patchLibrary = (serverAPI: ServerAPI, tabMasterManager: TabMasterMa pacthedTabs = tablist.flatMap((tabContainer) => { if (tabContainer.filters) { const footer = { ...(tabTemplate!.footer ?? {}), onMenuButton: getShowMenu(tabContainer.id, tabMasterManager), onMenuActionDescription: 'Tab Master' }; - - //if MicroSDeck isn't installed don't display any tabs that depend on it; return empty array for flat map - if (!isMicroSDeckInstalled && (tabContainer as CustomTabContainer).dependsOnMicroSDeck) return []; - if ((tabContainer as CustomTabContainer).autoHide && (tabContainer as CustomTabContainer).collection.visibleApps.length === 0) return []; - return (tabContainer as CustomTabContainer).getActualTab(tabContentComponent, sortingProps, footer, collectionsAppFilterGamepad); + return (tabContainer as CustomTabContainer).getActualTab(tabContentComponent, sortingProps, footer, collectionsAppFilterGamepad, isMicroSDeckInstalled) || []; } else { return tabs.find(actualTab => { if (actualTab.id === tabContainer.id) { diff --git a/src/presets/presets.ts b/src/presets/presets.ts index 1fe6ca6..af48c15 100644 --- a/src/presets/presets.ts +++ b/src/presets/presets.ts @@ -10,10 +10,12 @@ type TabPreset = { const presetDefines = { collection: (collectionId: string, collectionName: string) => { + let include = IncludeCategories.games; + if (collectionId === 'hidden') include |= (IncludeCategories.music | IncludeCategories.software | IncludeCategories.hidden); return { filters: [{ type: 'collection', inverted: false, params: { id: collectionId, name: collectionName } }], filtersMode: 'and', - categoriesToInclude: IncludeCategories.games + categoriesToInclude: include }; }, diff --git a/src/state/TabMasterManager.tsx b/src/state/TabMasterManager.tsx index 3ad9aac..8e34530 100644 --- a/src/state/TabMasterManager.tsx +++ b/src/state/TabMasterManager.tsx @@ -9,6 +9,7 @@ import { LogController } from "../lib/controllers/LogController"; import { PresetName, PresetOptions, getPreset } from '../presets/presets'; import { MicroSDeckInterop } from '../lib/controllers/MicroSDeckInterop'; import { TabErrorController } from '../lib/controllers/TabErrorController'; +import { TabProfileManager } from './TabProfileManager'; /** * Converts a list of filters into a 1D array. @@ -66,6 +67,8 @@ export class TabMasterManager { private collectionRemoveReaction: IReactionDisposer | undefined; + public tabProfileManager: TabProfileManager | undefined; + /** * Creates a new TabMasterManager. */ @@ -218,14 +221,29 @@ export class TabMasterManager { * @param storeTagLocalizationMap The store tag localization map. */ private storeTagReaction(storeTagLocalizationMap: StoreTagLocalizationMap) { - this.allStoreTags = Array.from(storeTagLocalizationMap._data.entries()).map(([tag, entry]) => { - return { - tag: tag, - string: entry.value - }; - }); + let tagLocalizationMap = storeTagLocalizationMap._data; + if (!tagLocalizationMap && storeTagLocalizationMap.data_) { + tagLocalizationMap = storeTagLocalizationMap.data_ + } + + if (tagLocalizationMap) { + const tagEntriesArray = Array.from(tagLocalizationMap.entries()); + + if (tagEntriesArray[0][1].value || tagEntriesArray[0][1].value_) { + this.allStoreTags = tagEntriesArray.map(([tag, entry]) => { + return { + tag: tag, + string: entry.value ?? entry.value_ + }; + }); - PythonInterop.setTags(this.allStoreTags); + PythonInterop.setTags(this.allStoreTags); + } else { + LogController.error("Failed to get store tags. Both entry.value and entry.value_ were undefined"); + } + } else { + LogController.error("Failed to get store tags. Both _data and data_ were undefined"); + } } /** @@ -429,6 +447,8 @@ export class TabMasterManager { } } this.tabsMap.delete(tabId); + this.tabProfileManager?.onDeleteTab(tabId); + if (!this.tabProfileManager) LogController.error('Attempted to delete a tab before TabProfileManager has been initialized.', 'This should not be possible.'); this.updateAndSave(); } @@ -474,15 +494,10 @@ export class TabMasterManager { } } - - /** - * Loads the user's tabs from the backend. + * Other async load calls that don't need to be waited for when starting the plugin */ - loadTabs = async () => { - this.initReactions(); - const settings = await PythonInterop.getTabs(); - //* We don't need to wait for these, since if we get the store ones, we don't care about them + asyncLoadOther() { PythonInterop.getTags().then((res: TagResponse[] | Error) => { if (res instanceof Error) { LogController.log("TabMaster couldn't load tags settings"); @@ -513,13 +528,30 @@ export class TabMasterManager { } } }); + } + + /** + * Loads the user's tabs from the backend. + */ + loadTabs = async () => { + this.initReactions(); + const settings = await PythonInterop.getTabs(); + const profiles = await PythonInterop.getTabProfiles(); + + this.asyncLoadOther(); if (settings instanceof Error) { LogController.log("TabMaster couldn't load tab settings"); LogController.error(settings.message); return; } + if (profiles instanceof Error) { + LogController.log("TabMaster couldn't load tab profiles"); + LogController.error(profiles.message); + return; + } + this.tabProfileManager = new TabProfileManager(profiles); TabErrorController.validateSettingsOnLoad((Object.keys(settings).length > 0) ? settings : defaultTabsSettings, this, this.finishLoadingTabs.bind(this)); }; diff --git a/src/state/TabProfileManager.tsx b/src/state/TabProfileManager.tsx new file mode 100644 index 0000000..ac6b631 --- /dev/null +++ b/src/state/TabProfileManager.tsx @@ -0,0 +1,62 @@ +import { PythonInterop } from '../lib/controllers/PythonInterop'; +import { TabMasterManager } from './TabMasterManager'; + +export type TabProfileDictionary = { + [name: string]: string[]; //array of ordered tab ids +}; + +export class TabProfileManager { + tabProfiles: TabProfileDictionary; + + /** + * Creates a new TabProfileManager. + * @param tabProfiles The existing tab profiles the current user has. + */ + constructor(tabProfiles: TabProfileDictionary) { + this.tabProfiles = tabProfiles; + } + + /** + * Writes a tab profile. + * @param tabProfileName The name of the tab profile to write. + * @param tabIds The list of ids of the tabs that are included in this profile. + */ + write(tabProfileName: string, tabIds: string[]) { + this.tabProfiles[tabProfileName] = tabIds; + this.save(); + } + + /** + * Applies a tab profile. + * @param tabProfileName The name of the tab profile to apply. + * @param tabMasterManager The plugin manager. + */ + apply(tabProfileName: string, tabMasterManager: TabMasterManager) { + tabMasterManager.getTabs().tabsMap.forEach(tabContainer => tabContainer.position = -1); + tabMasterManager.reorderTabs(this.tabProfiles[tabProfileName]); + } + + delete(tabProfileName: string) { + delete this.tabProfiles[tabProfileName]; + this.save(); + } + + /** + * Removes tab from profiles when it has been deleted + * @param deletedId The tab id that is being deleted + */ + onDeleteTab(deletedId: string) { + Object.values(this.tabProfiles).forEach(tabs => { + const deletedIndex = tabs.findIndex(tabId => tabId === deletedId); + if (deletedIndex > -1) tabs.splice(deletedIndex, 1); + }); + this.save(); + } + + /** + * Saves all changes made to the tab profiles. + */ + private save() { + PythonInterop.setTabProfiles(this.tabProfiles); + } +} diff --git a/src/types/stores/appStore.d.ts b/src/types/stores/appStore.d.ts index 628890a..2298b8d 100644 --- a/src/types/stores/appStore.d.ts +++ b/src/types/stores/appStore.d.ts @@ -1,14 +1,22 @@ // Types for the global appStore type StoreTagLocalizationEntry = { - value: string //? This is the string of the tag + /** + * @deprecated Replaced by data_. Used before Dec 13 2023 on the stable Steam Client Channel, while it used MobX 5.x.x (now uses 6.x.x). + */ + value: string, + value_: string //? This is the string of the tag } type StoreTagLocalizationMap = { - _data: Map + /** + * @deprecated Replaced by data_. Used before Dec 13 2023 on the stable Steam Client Channel, while it used MobX 5.x.x (now uses 6.x.x). + */ + _data?: Map + data_?: Map } type AppStore = { GetAppOverviewByAppID: (appId: number) => SteamAppOverview | null; m_mapStoreTagLocalization: StoreTagLocalizationMap -} \ No newline at end of file +}