17 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.7 4494fe979c Bump pkgver to r44.3e5a554
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:06:01 -04:00
mollusk 3e5a554a59 Merge branch 'feature/api-key-domain-clarity' 2026-05-09 20:05:22 -04:00
molluskandClaude Opus 4.7 dc69aa7d30 Clarify Steam API key domain field is not validated
Adds a visible callout in the settings dialog and a "Don't have a
domain?" note + common-failure list in the README, so users (and the
friends they share the app with) don't bounce off the registration
page thinking they need to own a real domain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:05:19 -04:00
molluskandClaude Opus 4.7 f8f8e9ab18 Release v0.2.0
Highlights since v0.1.0:
- Store Page button next to Play
- Opt-in store-price label with four format styles, 15-min in-memory cache
- Updated README and screenshot

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:55:32 -04:00
molluskandClaude Opus 4.7 e28646ad1a Bump pkgver to r40.7b5e777
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:52:16 -04:00
mollusk 7b5e777277 Merge branch 'feature/readme-store-features' 2026-05-09 19:51:55 -04:00
molluskandClaude Opus 4.7 a7db5c8bc0 Update README + screenshot for Store Page button and price label
Adds bullets covering the new Store Page action, opt-in store-price
label, and the settings entry that controls them. Refreshes the
screenshot to show the current layout (Balatro on sale, formatted
in full strike+sale+% style).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:51:51 -04:00
molluskandClaude Opus 4.7 15ff3527e8 Bump pkgver to r37.052b6d4
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:48:37 -04:00
mollusk 052b6d43c5 Merge branch 'feature/store-price' 2026-05-09 19:48:13 -04:00
molluskandClaude Opus 4.7 47abb10d6e Add opt-in store price label
Settings checkbox (off by default) toggles a price label between the
title and the cover image. Format combo offers four styles
(final / final+%, strikethrough, full strike+sale+%). Prices come from
the public appdetails endpoint, cached in-memory for 15 minutes.
"Free" / "—" cover F2P / unavailable apps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:48:10 -04:00
molluskandClaude Opus 4.7 3b852d0f5a Bump pkgver to r34.3e84c3a
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:36:05 -04:00
mollusk 3e84c3a210 Merge branch 'feature/store-page-button' 2026-05-09 19:35:12 -04:00
molluskandClaude Opus 4.7 2221636e18 Add Store Page button next to Play
Opens https://store.steampowered.com/app/<appid>/ via xdg-open for
the currently rolled game. Toggles visibility in lockstep with Play.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:35:10 -04:00
molluskandClaude Opus 4.7 b43eb11924 Use relative path for README screenshot
The previous absolute URL pointed at the Gitea instance and was
proxied by GitHub's camo image cache, which kept serving an old
copy after the screenshot was updated. A relative path lets each
forge (GitHub, Gitea) resolve and serve the image from its own
repo blob, bypassing camo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:17:18 -04:00
molluskandClaude Opus 4.7 a152c9b188 Bump pkgver to r30.4ba3aa0
Catches the cached pkgver up to the current HEAD so makepkg's
displayed version matches what the dynamic pkgver() function
would compute.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:13:29 -04:00
mollusk 4ba3aa06a7 Merge branch 'feature/readme-update' 2026-05-08 21:11:19 -04:00
molluskandClaude Opus 4.7 29dfe43d3f Update README + screenshot for genre/tag/friend filters
The README still described the install-state filter as the only top-row
control. Bring it in line with what the app actually offers now: install
state, genre, multi-select tags (with search, AND'd), and multi-select
friends (intersection). Document the friends-filter prerequisites
(friend's profile privacy must expose friends list + game details).

Adds keyring and xdg-utils to the hard-dep list (already required at
runtime; previously omitted from the README) and python-steam as the
optional dep that unlocks instant genre + tag filtering via
appinfo.vdf. Replaces the Void Linux install hint with an Arch one
that mirrors what the PKGBUILD installs.

PKGBUILD optdepends wording updated alongside: it now reflects that
python-steam unlocks both genre AND tag filters, not just genre.

Screenshot replaced with one taken on the current build showing all
four filter controls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:11:06 -04:00
4 changed files with 273 additions and 28 deletions
+2 -2
View File
@@ -1,14 +1,14 @@
# Maintainer: Mollusk <silvernode@gmail.com> # Maintainer: Mollusk <silvernode@gmail.com>
pkgname=steam-dice-git pkgname=steam-dice-git
_pkgname=steam-dice _pkgname=steam-dice
pkgver=r17.be764eb pkgver=r44.3e5a554
pkgrel=1 pkgrel=1
pkgdesc="A PyQt6 desktop app that picks a random game from your Steam library" pkgdesc="A PyQt6 desktop app that picks a random game from your Steam library"
arch=('any') arch=('any')
url="https://github.com/silvernode/steam-dice" url="https://github.com/silvernode/steam-dice"
license=('GPL-2.0-only') license=('GPL-2.0-only')
depends=('python' 'python-pyqt6' 'python-requests' 'python-keyring' 'xdg-utils') depends=('python' 'python-pyqt6' 'python-requests' 'python-keyring' 'xdg-utils')
optdepends=('python-steam: instant genre filter via Steam'\''s local appinfo.vdf cache (otherwise falls back to rate-limited Steam API)') optdepends=('python-steam: instant genre + tag filters via Steam'\''s local appinfo.vdf cache (without it, genres fall back to the rate-limited Steam API and tag filtering is unavailable)')
makedepends=('git') makedepends=('git')
provides=("$_pkgname") provides=("$_pkgname")
conflicts=("$_pkgname") conflicts=("$_pkgname")
+50 -14
View File
@@ -2,15 +2,21 @@
A small desktop app that picks a random game from your Steam library and lets you launch it directly. Stop staring at your backlog — let the dice decide. A small desktop app that picks a random game from your Steam library and lets you launch it directly. Stop staring at your backlog — let the dice decide.
![Steam Dice screenshot](https://gitbutter.xyz/mollusk/steam-dice/raw/branch/main/screenshot.jpg) ![Steam Dice screenshot](screenshot.jpg)
## Features ## Features
- Rolls a random game from your Steam library and displays its header art - Rolls a random game from your Steam library and displays its header art
- **Installed / Not installed / All** filter so you can limit rolls to games you can actually play right now - Combinable filters in the top row, all AND'd together:
- **Install state** — All / Installed / Not installed, to limit rolls to games you can actually play right now
- **Genre** — official Steam genre (Action, Strategy, RPG, …)
- **Tags** — multi-select popup with a search box; a game must carry every selected tag to survive
- **Friends** — multi-select popup; picks games every selected friend also owns, perfect for "what should we play tonight?"
- **Play** button launches the selected game immediately via Steam - **Play** button launches the selected game immediately via Steam
- Settings dialog for your API key and Steam ID, persisted across sessions - **Store Page** button opens the rolled game's Steam store page in your browser — handy for checking screenshots, reviews, or gifting it to a friend
- Refresh button with a 60-second cooldown to avoid hammering the Steam API - **Optional store-price label** (off by default) shows the current store price under the title, with a configurable format (final only, with discount %, strikethrough original, or full strike + sale + %) — useful for spotting sales when picking a gift
- Settings dialog for your API key and Steam ID, persisted across sessions (API key kept in your system keyring)
- Refresh button with a 60-second cooldown to avoid hammering the Steam API; refreshing also re-fetches selected friends' libraries
- Clean Steam-themed dark UI built with PyQt6 - Clean Steam-themed dark UI built with PyQt6
- Wayland-native with X11 fallback - Wayland-native with X11 fallback
@@ -19,18 +25,26 @@ A small desktop app that picks a random game from your Steam library and lets yo
- Python 3.8+ - Python 3.8+
- [PyQt6](https://pypi.org/project/PyQt6/) - [PyQt6](https://pypi.org/project/PyQt6/)
- [requests](https://pypi.org/project/requests/) - [requests](https://pypi.org/project/requests/)
- [keyring](https://pypi.org/project/keyring/) — used to store your Steam API key in the system keyring
- `xdg-utils` — used to launch games via `steam://` URLs
- Steam installed locally (for the "Installed" filter and launching games) - Steam installed locally (for the "Installed" filter and launching games)
Install dependencies with pip: **Optional:** [python-steam](https://pypi.org/project/steam/) enables instant genre **and** tag filters by reading Steam's local `appinfo.vdf` cache. Without it, genres fall back to a rate-limited Steam Store API fetch and tag filtering is unavailable (no API fallback exists for tags).
Install with pip:
```bash ```bash
pip install PyQt6 requests pip install PyQt6 requests keyring
# optional, for instant genre + tag filters
pip install steam
``` ```
Or with your distro's package manager, e.g. on Void Linux: Or on Arch (mirrors what the [PKGBUILD](PKGBUILD) installs):
```bash ```bash
xbps-install python3-PyQt6 python3-requests pacman -S python-pyqt6 python-requests python-keyring xdg-utils
# optional
pacman -S python-steam
``` ```
## Setup ## Setup
@@ -39,9 +53,17 @@ xbps-install python3-PyQt6 python3-requests
You need a free Steam Web API key to fetch your library. You need a free Steam Web API key to fetch your library.
1. Go to [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) and log in. 1. Go to [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) and log in with your Steam account.
2. Enter any domain name (e.g. `localhost`) and click **Register**. 2. In the **Domain Name** field, type any string and click **Register**.
3. Copy the 32-character key shown on the page. 3. Copy the 32-character key shown on the next page — paste it into Steam Dice's settings dialog.
> **Don't have a domain?** You don't need one. Steam's "Domain Name" field is **not validated** — `localhost`, your name, `personal`, or any other text works. It's just a label Steam stores alongside your key; it doesn't have to be a real domain you own.
If the page refuses to issue a key, the usual causes are:
- Your Steam account has spent **less than $5** lifetime on Steam (Valve's anti-spam threshold for API access).
- Your account is **limited** (new account with no purchases) or has an unverified email.
- You're signed into the wrong Steam account in your browser.
### 2. Steam ID (64-bit) ### 2. Steam ID (64-bit)
@@ -66,10 +88,24 @@ On first launch the settings dialog will open automatically. Enter your API key
Once loaded: Once loaded:
- Click the **dice** button to roll a random game - Click the **dice** button to roll a random game
- Use the **filter dropdown** to restrict rolls to installed or uninstalled games - Combine the four filter controls in the top row to narrow the pool:
- **All games / Installed / Not installed**
- **All genres** dropdown — pick one Steam genre
- **Tags ▾** — opens a popup with a search box and checkboxes; selected tags are AND'd, so a game must carry every checked tag
- **Friends ▾** — opens a popup listing your Steam friends; check any number to keep only games every selected friend also owns
- Click **Play** to launch the rolled game via Steam - Click **Play** to launch the rolled game via Steam
- Click the **refresh** button (⟳) to re-fetch your library (60s cooldown applies) - Click **Store Page** to open the rolled game's Steam store page in your browser
- Click the **settings** button (⚙) to update your credentials at any time - Click ⟳ to re-fetch your library (60s cooldown applies); selected friends' libraries refresh too
- Click ⚙ to update your credentials, toggle the store-price label, or change its format
### Friends filter prerequisites
For a friend to appear and contribute to filtering, the **friend's** Steam profile privacy must allow:
- Friends list visibility (so you can see them in the popup at all)
- Game details visibility (so their owned-game list is fetchable)
Friends with private game details show as `(private / 0 games)` in the popup and never narrow the result.
## License ## License
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 60 KiB

+221 -12
View File
@@ -4,6 +4,7 @@ import json
import os import os
import re import re
import sys import sys
import time
import random import random
import subprocess import subprocess
import requests import requests
@@ -13,12 +14,12 @@ import keyring
if os.environ.get("WAYLAND_DISPLAY"): if os.environ.get("WAYLAND_DISPLAY"):
os.environ.setdefault("QT_QPA_PLATFORM", "wayland") os.environ.setdefault("QT_QPA_PLATFORM", "wayland")
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QComboBox, QDialog, QDialogButtonBox, QLineEdit, QPushButton, QLabel, QComboBox, QCheckBox, QDialog, QDialogButtonBox,
QMessageBox, QFrame, QListWidget, QListWidgetItem) QLineEdit, QMessageBox, QFrame, QListWidget, QListWidgetItem)
from PyQt6.QtCore import Qt, QPoint, QSettings, QThread, QTimer, pyqtSignal from PyQt6.QtCore import Qt, QPoint, QSettings, QThread, QTimer, pyqtSignal
from PyQt6.QtGui import QPixmap, QFont, QIcon from PyQt6.QtGui import QPixmap, QFont, QIcon
VERSION = "v0.1.0" VERSION = "v0.2.0"
def _get_version(): def _get_version():
try: try:
@@ -250,11 +251,21 @@ IMG_H = 215
MARGIN = 20 MARGIN = 20
TOP_ROW_H = 46 # refresh button (28) + gap (4) + cooldown label (14) TOP_ROW_H = 46 # refresh button (28) + gap (4) + cooldown label (14)
TITLE_H = 30 TITLE_H = 30
PRICE_H = 18
STATUS_H = 20 STATUS_H = 20
DICE_H = 100 DICE_H = 100
SPACING = 12 SPACING = 12
PLAY_BTN_H = 34 PLAY_BTN_H = 34
REFRESH_COOLDOWN = 60 # seconds REFRESH_COOLDOWN = 60 # seconds
PRICE_CACHE_TTL = 15 * 60 # seconds; sale state changes too fast for longer caching
# Price format keys persisted in QSettings; combo display order matches.
PRICE_FORMAT_OPTIONS = [
("full", "$̶1̶9̶.̶9̶9̶ $9.99 (-50%)"),
("strike", "$̶1̶9̶.̶9̶9̶ $9.99"),
("final_pct", "$9.99 (-50%)"),
("final", "$9.99"),
]
# Window is ~40px wider than image+margin to fit a fourth control (Friends) in # Window is ~40px wider than image+margin to fit a fourth control (Friends) in
# the top filter row alongside install/genre/tag combos. # the top filter row alongside install/genre/tag combos.
WIN_W = IMG_W + MARGIN * 2 + 40 WIN_W = IMG_W + MARGIN * 2 + 40
@@ -441,6 +452,37 @@ class FetchImageThread(QThread):
self.done.emit(QPixmap()) self.done.emit(QPixmap())
class FetchPriceThread(QThread):
# status: "priced" (data is price_overview dict), "free" (no price_overview but
# the app exists), or "unavailable" (lookup failed / app not on store).
done = pyqtSignal(int, str, dict) # appid, status, data
def __init__(self, appid):
super().__init__()
self.appid = appid
def run(self):
try:
url = (
"https://store.steampowered.com/api/appdetails"
f"?appids={self.appid}&filters=price_overview"
)
r = requests.get(url, timeout=10)
r.raise_for_status()
entry = r.json().get(str(self.appid), {})
if not entry.get("success"):
self.done.emit(self.appid, "unavailable", {})
return
data = entry.get("data") or {}
price = data.get("price_overview")
if price:
self.done.emit(self.appid, "priced", price)
else:
self.done.emit(self.appid, "free", {})
except Exception:
self.done.emit(self.appid, "unavailable", {})
class FetchGenresThread(QThread): class FetchGenresThread(QThread):
progress = pyqtSignal(int, int, dict) # done, total, cache snapshot progress = pyqtSignal(int, int, dict) # done, total, cache snapshot
finished_ok = pyqtSignal(dict) # final cache finished_ok = pyqtSignal(dict) # final cache
@@ -923,12 +965,20 @@ class SettingsDialog(QDialog):
key_help = QLabel( key_help = QLabel(
'Get your free key at ' 'Get your free key at '
'<a href="https://steamcommunity.com/dev/apikey">steamcommunity.com/dev/apikey</a>.' '<a href="https://steamcommunity.com/dev/apikey">steamcommunity.com/dev/apikey</a>. '
'<br>Log in with Steam, enter any domain name (e.g. <i>localhost</i>), and copy the key shown.' 'Log in with Steam, then copy the 32-character key shown after registering.'
'<br><br>'
'<b>Don\'t have a domain?</b> You don\'t need one. The <i>Domain Name</i> field '
'on Steam\'s page is <b>not validated</b> — type literally anything '
'(<i>localhost</i>, your name, <i>personal</i>) and click <b>Register</b>.'
) )
key_help.setOpenExternalLinks(True) key_help.setOpenExternalLinks(True)
key_help.setWordWrap(True) key_help.setWordWrap(True)
key_help.setStyleSheet("color: #8f98a0; font-size: 9pt; padding-bottom: 10px;") key_help.setStyleSheet(
"color: #c7d5e0; font-size: 9pt; "
"background-color: #1f2a36; border: 1px solid #2a475e; border-radius: 4px; "
"padding: 8px 10px; margin-bottom: 10px;"
)
layout.addWidget(key_help) layout.addWidget(key_help)
# --- Steam ID --- # --- Steam ID ---
@@ -949,6 +999,37 @@ class SettingsDialog(QDialog):
id_help.setStyleSheet("color: #8f98a0; font-size: 9pt; padding-bottom: 10px;") id_help.setStyleSheet("color: #8f98a0; font-size: 9pt; padding-bottom: 10px;")
layout.addWidget(id_help) layout.addWidget(id_help)
# --- Store price ---
layout.addWidget(QLabel("<b>Store price</b>"))
self.price_check = QCheckBox("Show current Steam store price under the title")
self.price_check.setChecked(settings.value("show_price", False, type=bool))
layout.addWidget(self.price_check)
fmt_row = QHBoxLayout()
fmt_row.setSpacing(6)
fmt_label = QLabel("Format:")
fmt_label.setStyleSheet("color: #8f98a0;")
fmt_row.addWidget(fmt_label)
self.price_format_combo = QComboBox()
for key, sample in PRICE_FORMAT_OPTIONS:
self.price_format_combo.addItem(sample, key)
saved_fmt = settings.value("price_format", "full")
idx = self.price_format_combo.findData(saved_fmt)
self.price_format_combo.setCurrentIndex(max(idx, 0))
fmt_row.addWidget(self.price_format_combo, 1)
layout.addLayout(fmt_row)
self.price_format_combo.setEnabled(self.price_check.isChecked())
self.price_check.toggled.connect(self.price_format_combo.setEnabled)
price_help = QLabel(
'Disabled by default. When on, fetches the public store price for the rolled game '
'(handy for spotting gift-able sales). Cached for 15 minutes per game.'
)
price_help.setWordWrap(True)
price_help.setStyleSheet("color: #8f98a0; font-size: 9pt; padding-bottom: 10px;")
layout.addWidget(price_help)
# --- Buttons --- # --- Buttons ---
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
@@ -974,6 +1055,8 @@ class SettingsDialog(QDialog):
settings = QSettings("butter", "steam-dice") settings = QSettings("butter", "steam-dice")
settings.remove("api_key") # migrate away from plaintext storage settings.remove("api_key") # migrate away from plaintext storage
settings.setValue("steam_id", steam_id) settings.setValue("steam_id", steam_id)
settings.setValue("show_price", self.price_check.isChecked())
settings.setValue("price_format", self.price_format_combo.currentData())
self.accept() self.accept()
@@ -991,6 +1074,11 @@ class SteamDice(QMainWindow):
self.genres_thread = None self.genres_thread = None
self.tags_table_thread = None self.tags_table_thread = None
# Store-price feature: opt-in via settings; in-memory cache only.
# cache value: (timestamp, status, data) where status is "priced" / "free" / "unavailable".
self.price_thread = None
self.price_cache = {}
# Friends filter state. Friend list comes from disk; per-friend libraries # Friends filter state. Friend list comes from disk; per-friend libraries
# populate lazily into self.friend_games as the user toggles them on. # populate lazily into self.friend_games as the user toggles them on.
self.friends = _load_friends_cache() # {sid: {"name": ...}} self.friends = _load_friends_cache() # {sid: {"name": ...}}
@@ -1149,6 +1237,17 @@ class SteamDice(QMainWindow):
self.title_label.setVisible(False) self.title_label.setVisible(False)
layout.addWidget(self.title_label) layout.addWidget(self.title_label)
# Store price (only visible when enabled in settings + value loaded)
self.price_label = QLabel()
self.price_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.price_label.setTextFormat(Qt.TextFormat.RichText)
price_font = QFont()
price_font.setPointSize(10)
self.price_label.setFont(price_font)
self.price_label.setFixedHeight(PRICE_H)
self.price_label.setVisible(False)
layout.addWidget(self.price_label)
# Game image # Game image
self.image_label = QLabel() self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -1157,12 +1256,13 @@ class SteamDice(QMainWindow):
self.image_label.setVisible(False) self.image_label.setVisible(False)
layout.addWidget(self.image_label) layout.addWidget(self.image_label)
# Play button # Action buttons: Play | Store Page
self.play_btn = QPushButton(" Play")
self.play_btn.setIcon(QIcon.fromTheme("media-playback-start"))
play_font = QFont() play_font = QFont()
play_font.setPointSize(11) play_font.setPointSize(11)
play_font.setBold(True) play_font.setBold(True)
self.play_btn = QPushButton(" Play")
self.play_btn.setIcon(QIcon.fromTheme("media-playback-start"))
self.play_btn.setFont(play_font) self.play_btn.setFont(play_font)
self.play_btn.setFixedHeight(34) self.play_btn.setFixedHeight(34)
self.play_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.play_btn.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -1179,7 +1279,31 @@ class SteamDice(QMainWindow):
""") """)
self.play_btn.setVisible(False) self.play_btn.setVisible(False)
self.play_btn.clicked.connect(self._launch_game) self.play_btn.clicked.connect(self._launch_game)
layout.addWidget(self.play_btn)
self.store_btn = QPushButton(" Store Page")
self.store_btn.setIcon(QIcon.fromTheme("applications-internet"))
self.store_btn.setFont(play_font)
self.store_btn.setFixedHeight(34)
self.store_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.store_btn.setStyleSheet("""
QPushButton {
background-color: #316282;
color: #c6d4df;
border: none;
border-radius: 4px;
padding: 0 16px;
}
QPushButton:hover { background-color: #4082a3; }
QPushButton:pressed { background-color: #1f4257; }
""")
self.store_btn.setVisible(False)
self.store_btn.clicked.connect(self._open_store_page)
action_row = QHBoxLayout()
action_row.setContentsMargins(0, 0, 0, 0)
action_row.addWidget(self.play_btn)
action_row.addWidget(self.store_btn)
layout.addLayout(action_row)
# Status / loading text # Status / loading text
self.status_label = QLabel("Loading library…") self.status_label = QLabel("Loading library…")
@@ -1244,10 +1368,12 @@ class SteamDice(QMainWindow):
self.fetch_thread.start() self.fetch_thread.start()
def _open_settings(self): def _open_settings(self):
old_steam_id = QSettings("butter", "steam-dice").value("steam_id", "") s = QSettings("butter", "steam-dice")
old_steam_id = s.value("steam_id", "")
dlg = SettingsDialog(self) dlg = SettingsDialog(self)
if dlg.exec() == QDialog.DialogCode.Accepted: if dlg.exec() == QDialog.DialogCode.Accepted:
new_steam_id = QSettings("butter", "steam-dice").value("steam_id", "") s = QSettings("butter", "steam-dice")
new_steam_id = s.value("steam_id", "")
if old_steam_id and new_steam_id != old_steam_id: if old_steam_id and new_steam_id != old_steam_id:
# Different account — friend list belongs to the previous user. # Different account — friend list belongs to the previous user.
# Clear in-memory state; the next click on Friends will re-fetch. # Clear in-memory state; the next click on Friends will re-fetch.
@@ -1256,6 +1382,14 @@ class SteamDice(QMainWindow):
self.friend_games = {} self.friend_games = {}
self.friend_status = {} self.friend_status = {}
self.friends_btn.clear() self.friends_btn.clear()
# Sync price label visibility to the current setting state.
if not s.value("show_price", False, type=bool):
self.price_label.clear()
self.price_label.setVisible(False)
elif self.current_appid is not None:
self._maybe_fetch_price(self.current_appid)
self.status_label.setText("Loading library…") self.status_label.setText("Loading library…")
self.dice_btn.setEnabled(False) self.dice_btn.setEnabled(False)
self.filter_combo.setEnabled(False) self.filter_combo.setEnabled(False)
@@ -1651,6 +1785,9 @@ class SteamDice(QMainWindow):
self.image_label.setText("Loading…") self.image_label.setText("Loading…")
self.image_label.setVisible(True) self.image_label.setVisible(True)
self.play_btn.setVisible(False) self.play_btn.setVisible(False)
self.store_btn.setVisible(False)
self.price_label.clear()
self.price_label.setVisible(False)
self.status_label.setText("") self.status_label.setText("")
if self.image_thread is not None: if self.image_thread is not None:
@@ -1659,9 +1796,12 @@ class SteamDice(QMainWindow):
self.image_thread.done.connect(self._on_image_loaded) self.image_thread.done.connect(self._on_image_loaded)
self.image_thread.start() self.image_thread.start()
self._maybe_fetch_price(game["appid"])
def _on_image_loaded(self, pixmap): def _on_image_loaded(self, pixmap):
self.dice_btn.setEnabled(True) self.dice_btn.setEnabled(True)
self.play_btn.setVisible(True) self.play_btn.setVisible(True)
self.store_btn.setVisible(True)
if pixmap.isNull(): if pixmap.isNull():
self.image_label.setText("No image available") self.image_label.setText("No image available")
else: else:
@@ -1677,6 +1817,73 @@ class SteamDice(QMainWindow):
if self.current_appid is not None: if self.current_appid is not None:
subprocess.Popen(["xdg-open", f"steam://rungameid/{self.current_appid}"]) subprocess.Popen(["xdg-open", f"steam://rungameid/{self.current_appid}"])
def _open_store_page(self):
if self.current_appid is not None:
subprocess.Popen(["xdg-open", f"https://store.steampowered.com/app/{self.current_appid}/"])
def _maybe_fetch_price(self, appid):
s = QSettings("butter", "steam-dice")
if not s.value("show_price", False, type=bool):
return
cached = self.price_cache.get(appid)
if cached and time.time() - cached[0] < PRICE_CACHE_TTL:
self._render_price(appid, cached[1], cached[2])
return
if self.price_thread is not None and self.price_thread.isRunning():
try:
self.price_thread.done.disconnect()
except TypeError:
pass
self.price_thread = FetchPriceThread(appid)
self.price_thread.done.connect(self._on_price_loaded)
self.price_thread.start()
def _on_price_loaded(self, appid, status, data):
self.price_cache[appid] = (time.time(), status, data)
self._render_price(appid, status, data)
def _render_price(self, appid, status, data):
# Race protection: another roll happened while we were fetching.
if appid != self.current_appid:
return
s = QSettings("butter", "steam-dice")
if not s.value("show_price", False, type=bool):
return
fmt = s.value("price_format", "full")
text = self._format_price(status, data, fmt)
self.price_label.setText(text)
self.price_label.setVisible(True)
@staticmethod
def _format_price(status, data, style):
if status == "free":
return '<span style="color: #8f98a0;">Free</span>'
if status == "unavailable" or not data:
return '<span style="color: #8f98a0;">—</span>'
final = data.get("final_formatted") or ""
initial = data.get("initial_formatted") or ""
pct = data.get("discount_percent") or 0
on_sale = pct > 0 and initial and initial != final
if not on_sale:
return f'<span style="color: #c7d5e0;">{final}</span>'
strike = f'<s style="color: #6b7884;">{initial}</s>'
sale = f'<span style="color: #a4d007;">{final}</span>'
pct_part = f'<span style="color: #a4d007;"> (-{pct}%)</span>'
if style == "final":
return sale
if style == "final_pct":
return f"{sale}{pct_part}"
if style == "strike":
return f"{strike} &nbsp; {sale}"
# "full" (default)
return f"{strike} &nbsp; {sale}{pct_part}"
def closeEvent(self, a0): def closeEvent(self, a0):
if self.genres_thread and self.genres_thread.isRunning(): if self.genres_thread and self.genres_thread.isRunning():
self.genres_thread.stop() self.genres_thread.stop()
@@ -1688,6 +1895,8 @@ class SteamDice(QMainWindow):
if self.friend_games_thread and self.friend_games_thread.isRunning(): if self.friend_games_thread and self.friend_games_thread.isRunning():
self.friend_games_thread.stop() self.friend_games_thread.stop()
self.friend_games_thread.wait(15000) self.friend_games_thread.wait(15000)
if self.price_thread and self.price_thread.isRunning():
self.price_thread.wait(15000)
super().closeEvent(a0) super().closeEvent(a0)