diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-27 13:22:43 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-27 13:22:43 +0100 |
| commit | 6b68cae8e0c4ec43c4a9a8a812a644b7bea746e7 (patch) | |
| tree | a8fdf6542fc8b84adcaa90ed9b1f9990da924046 | |
| parent | 583ac2e98c3ff4fa379194b2eb4e7530c07b275d (diff) | |
| download | quickshell-6b68cae8e0c4ec43c4a9a8a812a644b7bea746e7.tar.gz quickshell-6b68cae8e0c4ec43c4a9a8a812a644b7bea746e7.zip | |
games: search the Steam store from the launcher
Down switches the strip and preview from the library to store results,
searched as you type with a 350ms debounce through the store's keyless
api/storesearch endpoint, using QML's own XMLHttpRequest rather than a
process. Results already in the library read Owned or Installed in
green, the rest their price; Enter opens steam://store/<appid> in the
client, Up or Escape returns to the library with the query kept, so a
name that matches nothing locally is one keypress from the store.
The endpoint answers {"total":0} for every term when no cc is given,
despite looking like it geolocates, and the locale here is en_US, so
the country is fixed to Italy. Store art comes from Steam's CDN; newer
apps keep theirs under hashed paths that 404, so the preview layers the
search's capsule under the hero and the tile shows the initial until
its cover is actually ready.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | games/Games.qml | 89 | ||||
| -rw-r--r-- | games/README.md | 9 |
2 files changed, 92 insertions, 6 deletions
diff --git a/games/Games.qml b/games/Games.qml index 2d22460..ef5576e 100644 --- a/games/Games.qml +++ b/games/Games.qml @@ -21,6 +21,10 @@ Scope { property string monitor: "DP-1" property var items: [] property string query: "" + // Down switches the strip to the Steam store, searched as you type. + property bool store: false + property var results: [] + property bool searching: false // Horizontal shear of the strip's tiles, as a fraction of their height. readonly property real slant: -0.25 @@ -30,6 +34,7 @@ Scope { Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] readonly property var shown: { + if (root.store) return root.results; const q = root.query.toLowerCase(); return root.items.filter(g => g.name.toLowerCase().includes(q)); } @@ -46,6 +51,11 @@ Scope { function launch(g) { if (!g) return; + if (root.store) { + Quickshell.execDetached(["steam", `steam://store/${g.appid}`]); + root.open = false; + return; + } // -silent keeps the Steam window down whether or not Steam is running. // Ids are strings: a shortcut's game id does not fit a JS number. Quickshell.execDetached(["steam", "-silent", @@ -58,10 +68,58 @@ Scope { // read again. onOpenChanged: if (open) { root.query = ""; + root.store = false; + root.results = []; list.running = true; refresh.running = true; } + // Local art is a path, store art a URL. + function src(p) { return p ? (p.startsWith("/") ? `file://${p}` : p) : ""; } + + onQueryChanged: if (store) search.restart() + onStoreChanged: if (store) search.restart() + + Timer { + id: search + interval: 350 + onTriggered: root.storeSearch(root.query.trim()) + } + + // The store's own search endpoint needs no key, but answers nothing + // without a country, which also sets the prices. The locale is en_US + // here, so it cannot be derived from that. + // ponytail: fixed to Italy; a setting when another country matters. + readonly property string country: "it" + + function storeSearch(term) { + if (!term) { root.results = []; root.searching = false; return; } + root.searching = true; + const xhr = new XMLHttpRequest(); + xhr.onreadystatechange = () => { + if (xhr.readyState !== XMLHttpRequest.DONE || term !== root.query.trim()) return; + root.searching = false; + let found = []; + try { found = JSON.parse(xhr.responseText).items ?? []; } catch (e) {} + const mine = {}; + for (const g of root.items) if (g.appid) mine[g.appid] = g; + const cdn = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/"; + root.results = found.map(r => { + const a = String(r.id); + // Newer apps keep their art under hashed paths, so these + // can 404; the capsule search returns always exists. + // installed: drawn like a library game, not greyed. + return { name: r.name, id: a, appid: a, mine: mine[a], + installed: true, last: 0, price: r.price, + cover: `${cdn}${a}/library_600x900.jpg`, + hero: `${cdn}${a}/library_hero.jpg`, capsule: r.tiny_image }; + }); + }; + xhr.open("GET", `https://store.steampowered.com/api/storesearch/?l=english&cc=${root.country}&term=` + + encodeURIComponent(term)); + xhr.send(); + } + Process { id: list command: ["/usr/bin/python3", `${Quickshell.shellDir}/games.py`] @@ -116,9 +174,16 @@ Scope { Item { anchors { top: parent.top; left: parent.left; right: parent.right; bottom: strip.top } + // A store result's capsule, under its hero in case that 404s. + Image { + anchors.fill: parent + source: root.src(win.game?.capsule) + fillMode: Image.PreserveAspectCrop + asynchronous: true + } Image { anchors.fill: parent - source: win.game ? (win.game.hero || win.game.cover ? `file://${win.game.hero || win.game.cover}` : "") : "" + source: win.game ? root.src(win.game.hero || win.game.cover) : "" fillMode: Image.PreserveAspectCrop asynchronous: true } @@ -136,7 +201,11 @@ Scope { spacing: 8 Text { - text: win.game?.name ?? (root.items.length ? "No match" : "") + text: win.game?.name + ?? (!root.store ? (root.items.length ? "No match · Down searches the store" : "") + : root.searching ? "Searching the store…" + : root.query.trim() ? "Nothing on the store" + : "Type to search the Steam store") color: Theme.text font.family: Theme.fontFamily font.pixelSize: 56 @@ -145,10 +214,15 @@ Scope { Text { visible: !!win.game text: !win.game ? "" + : root.store ? [win.game.mine ? (win.game.mine.installed ? "Installed" : "Owned") + : win.game.price ? `${(win.game.price.final / 100).toFixed(2)} ${win.game.price.currency}` + : "Free or unpriced", + "Enter opens the store page"].join(" · ") : !win.game.installed ? "Not installed · Enter installs" : win.game.last ? `Last played ${new Date(win.game.last * 1000).toLocaleDateString(Qt.locale(), "d MMMM yyyy")}` : "Never played" - color: win.game?.installed ? Theme.subtext : Theme.peach + color: root.store && win.game?.mine ? Theme.green + : win.game?.installed ? Theme.subtext : Theme.peach font.family: Theme.fontFamily font.pixelSize: 20 } @@ -166,7 +240,9 @@ Scope { font.pixelSize: 28 onTextEdited: root.query = text - Keys.onEscapePressed: root.open = false + Keys.onEscapePressed: { if (root.store) root.store = false; else root.open = false; } + Keys.onDownPressed: root.store = true + Keys.onUpPressed: root.store = false Keys.onLeftPressed: strip.decrementCurrentIndex() Keys.onRightPressed: strip.incrementCurrentIndex() Keys.onReturnPressed: root.launch(win.game) @@ -218,6 +294,7 @@ Scope { Rectangle { anchors.fill: parent; color: Theme.surface } Image { + id: cover x: -tile.height * Math.abs(root.slant) / 2 width: parent.width + tile.height * Math.abs(root.slant) height: parent.height @@ -225,7 +302,7 @@ Scope { matrix: Qt.matrix4x4(1, -root.slant, 0, root.slant * tile.height / 2, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) } - source: tile.modelData.cover ? `file://${tile.modelData.cover}` : "" + source: root.src(tile.modelData.cover) sourceSize.width: 480 fillMode: Image.PreserveAspectCrop asynchronous: true @@ -236,7 +313,7 @@ Scope { // No cover: the initial stands in for it. Text { anchors.centerIn: parent - visible: !tile.modelData.cover + visible: cover.status !== Image.Ready text: tile.modelData.name.charAt(0) color: Theme.text font.family: Theme.fontFamily diff --git a/games/README.md b/games/README.md index b0b6f5b..46f0359 100644 --- a/games/README.md +++ b/games/README.md @@ -14,6 +14,15 @@ Escape or a click on the background closes. Everything goes through for an installed game or shortcut (keeping its Proton and launch options), `install` for one that is not. +Down switches the strip to the Steam store: typing searches it through +the store's keyless `api/storesearch` endpoint (country fixed to +Italy, without one it answers nothing), and results show the +price, or Owned / Installed in green for games already in the library. +Enter opens the store page in the Steam client; Up or Escape goes back to +the library, keeping what was typed. Art comes from Steam's CDN; newer +apps keep theirs under hashed paths that 404, so the preview falls back +to the search's capsule image and the tile to the initial. + `games.py` reads, on every opening: - installed games from the `appmanifest_*.acf` files of every library in |
