From 6b68cae8e0c4ec43c4a9a8a812a644b7bea746e7 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 27 Oct 2026 13:22:43 +0100 Subject: 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/ 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 --- games/Games.qml | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 6 deletions(-) (limited to 'games/Games.qml') 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 -- cgit v1.2.3