diff options
Diffstat (limited to 'games/Games.qml')
| -rw-r--r-- | games/Games.qml | 89 |
1 files changed, 83 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 |
