Add multi-select friends filter

Adds a fourth filter control next to install/genre/tag: a "Friends ▾" button
that opens a checkable popup of the user's Steam friends. Selected friends'
libraries are intersected with the user's so only games everyone owns survive,
making it easy to combine with genre/tag to find a category of game everyone
in the room can play.

Friend list (with display names) is fetched via GetFriendList +
GetPlayerSummaries and cached at ~/.cache/steam-dice/friends.json. Each
friend's owned-games set is fetched lazily the first time they're checked
and cached at ~/.cache/steam-dice/friend_games/<steamid>.json. The main
refresh button re-fetches selected friends alongside the user's library;
the popup's own refresh button re-pulls just the friend list.

While a selected friend's library is still loading, the dice button stays
disabled and the status line shows which friend(s) are pending. Settings
changes that switch the steam_id clear in-memory friends state so the
previous user's friends don't pollute the new account.

Window width grows 40px (500 -> 540) and combo width shrinks 115 -> 100 so
all four controls fit on a single row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:13:11 -04:00
parent 643715d0c6
commit b812558def
+505 -12
View File
@@ -14,8 +14,8 @@ if os.environ.get("WAYLAND_DISPLAY"):
os.environ.setdefault("QT_QPA_PLATFORM", "wayland")
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QComboBox, QDialog, QDialogButtonBox, QLineEdit,
QMessageBox)
from PyQt6.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal
QMessageBox, QFrame, QListWidget, QListWidgetItem)
from PyQt6.QtCore import Qt, QPoint, QSettings, QThread, QTimer, pyqtSignal
from PyQt6.QtGui import QPixmap, QFont, QIcon
VERSION = "v0.1.0"
@@ -193,6 +193,58 @@ def _save_tags_table(table):
os.replace(tmp, path)
def _friends_cache_path():
return os.path.join(_cache_dir(), "friends.json")
def _friend_games_dir():
return os.path.join(_cache_dir(), "friend_games")
def _friend_games_path(steamid):
return os.path.join(_friend_games_dir(), f"{steamid}.json")
def _load_friends_cache():
"""{steamid_str: {"name": "..."}}"""
try:
with open(_friends_cache_path()) as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _save_friends_cache(friends):
path = _friends_cache_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(friends, f)
os.replace(tmp, path)
def _load_friend_games(steamid):
"""Returns set of appids the friend owns, or None if no cache file exists.
Empty set means we successfully fetched but the friend's library was empty
or private — distinct from None."""
try:
with open(_friend_games_path(steamid)) as f:
data = json.load(f)
return set(data) if isinstance(data, list) else None
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
def _save_friend_games(steamid, appids):
os.makedirs(_friend_games_dir(), exist_ok=True)
path = _friend_games_path(steamid)
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(sorted(appids), f)
os.replace(tmp, path)
IMG_W = 460
IMG_H = 215
MARGIN = 20
@@ -203,7 +255,9 @@ DICE_H = 100
SPACING = 12
PLAY_BTN_H = 34
REFRESH_COOLDOWN = 60 # seconds
WIN_W = IMG_W + MARGIN * 2
# Window is ~40px wider than image+margin to fit a fourth control (Friends) in
# the top filter row alongside install/genre/tag combos.
WIN_W = IMG_W + MARGIN * 2 + 40
WIN_H = (MARGIN + TOP_ROW_H + SPACING + TITLE_H + SPACING
+ IMG_H + SPACING + PLAY_BTN_H + SPACING + STATUS_H + SPACING + DICE_H + MARGIN)
@@ -233,7 +287,7 @@ COMBO_STYLE = """
border: 1px solid #3d5a7a;
border-radius: 4px;
padding: 2px 8px;
min-width: 115px;
min-width: 95px;
}
QComboBox:hover { border-color: #5a8ab0; }
QComboBox:disabled { color: #4a5a6a; border-color: #2a3a50; }
@@ -253,6 +307,48 @@ COMBO_STYLE = """
}
"""
FRIENDS_BTN_STYLE = """
QPushButton {
background-color: #2a3f5f;
color: #c6d4df;
border: 1px solid #3d5a7a;
border-radius: 4px;
padding: 2px 8px;
text-align: left;
min-width: 95px;
}
QPushButton:hover { border-color: #5a8ab0; }
QPushButton:disabled { color: #4a5a6a; border-color: #2a3a50; }
"""
FRIENDS_POPUP_STYLE = """
QFrame#FriendsPopup {
background-color: #1b2838;
border: 1px solid #3d5a7a;
border-radius: 4px;
}
QFrame#FriendsPopup QLabel { color: #8f98a0; }
QFrame#FriendsPopup QPushButton {
background-color: #2a3f5f;
color: #c6d4df;
border: 1px solid #3d5a7a;
border-radius: 4px;
padding: 2px 4px;
}
QFrame#FriendsPopup QPushButton:hover { background-color: #3d5a7a; }
QFrame#FriendsPopup QListWidget {
background-color: #2a3f5f;
color: #c6d4df;
border: 1px solid #3d5a7a;
border-radius: 3px;
outline: none;
padding: 2px;
}
QFrame#FriendsPopup QListWidget::item { padding: 3px 4px; }
QFrame#FriendsPopup QListWidget::item:selected,
QFrame#FriendsPopup QListWidget::item:hover { background-color: #3d6b9e; }
"""
REFRESH_STYLE = """
QPushButton {
background: transparent;
@@ -390,6 +486,86 @@ class FetchGenresThread(QThread):
self.finished_ok.emit(self.cache)
class FetchFriendsThread(QThread):
"""Fetch friend list and resolve display names via GetPlayerSummaries."""
done = pyqtSignal(dict) # {steamid_str: {"name": "..."}}
error = pyqtSignal(str)
def __init__(self, api_key, steam_id):
super().__init__()
self.api_key = api_key
self.steam_id = steam_id
def run(self):
try:
url = (
"https://api.steampowered.com/ISteamUser/GetFriendList/v1/"
f"?key={self.api_key}&steamid={self.steam_id}&relationship=friend"
)
r = requests.get(url, timeout=15)
r.raise_for_status()
ids = [
f["steamid"]
for f in r.json().get("friendslist", {}).get("friends", [])
]
result = {}
for i in range(0, len(ids), 100):
batch = ids[i:i + 100]
url = (
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
f"?key={self.api_key}&steamids={','.join(batch)}"
)
r = requests.get(url, timeout=15)
r.raise_for_status()
for p in r.json().get("response", {}).get("players", []):
sid = p.get("steamid")
if sid:
result[sid] = {"name": p.get("personaname", "Unknown")}
self.done.emit(result)
except Exception as e:
msg = str(e).replace(self.api_key, "[REDACTED]")
self.error.emit(msg)
class FetchFriendGamesThread(QThread):
"""Fetch a batch of friends' owned games sequentially."""
progress = pyqtSignal(str, list) # steamid, list of appids
error = pyqtSignal(str, str) # steamid, error message
finished_ok = pyqtSignal()
REQUEST_INTERVAL_MS = 500
def __init__(self, api_key, steam_ids):
super().__init__()
self.api_key = api_key
self.steam_ids = list(steam_ids)
self._stop = False
def stop(self):
self._stop = True
def run(self):
for sid in self.steam_ids:
if self._stop:
break
try:
url = (
"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/"
f"?key={self.api_key}&steamid={sid}&format=json"
)
r = requests.get(url, timeout=15)
r.raise_for_status()
games = r.json().get("response", {}).get("games", [])
appids = [g["appid"] for g in games]
_save_friend_games(sid, appids)
self.progress.emit(sid, appids)
except Exception as e:
msg = str(e).replace(self.api_key, "[REDACTED]")
self.error.emit(sid, msg)
self.msleep(self.REQUEST_INTERVAL_MS)
self.finished_ok.emit()
class LazyComboBox(QComboBox):
"""QComboBox that intercepts the dropdown popup until permission is granted."""
popup_blocked = pyqtSignal()
@@ -408,6 +584,143 @@ class LazyComboBox(QComboBox):
super().showPopup()
class FriendsPopup(QFrame):
"""Borderless popup with a checkable friend list. Auto-closes on outside click."""
selection_changed = pyqtSignal(set)
refresh_requested = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Popup)
self.setObjectName("FriendsPopup")
self.setStyleSheet(FRIENDS_POPUP_STYLE)
self.setFixedWidth(240)
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(4)
header = QHBoxLayout()
header.setSpacing(4)
header.setContentsMargins(0, 0, 0, 0)
self.status_label = QLabel("")
status_font = QFont()
status_font.setPointSize(8)
self.status_label.setFont(status_font)
header.addWidget(self.status_label, 1)
self.refresh_btn = QPushButton()
self.refresh_btn.setIcon(QIcon.fromTheme("view-refresh"))
self.refresh_btn.setFixedSize(22, 22)
self.refresh_btn.setToolTip("Refresh friend list from Steam")
self.refresh_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.refresh_btn.clicked.connect(self.refresh_requested)
header.addWidget(self.refresh_btn)
layout.addLayout(header)
self.list_widget = QListWidget()
self.list_widget.setMinimumHeight(180)
self.list_widget.setMaximumHeight(360)
self.list_widget.itemChanged.connect(self._on_item_changed)
layout.addWidget(self.list_widget)
self._suppress = False
def populate(self, friends, selected_ids, friend_status):
"""friends: {sid: {"name": ...}}; friend_status: {sid: "loading"|"empty"|"error"|...}"""
self._suppress = True
self.list_widget.clear()
if friends:
self.status_label.setText(f"{len(friends)} friend(s)")
for sid, info in sorted(friends.items(), key=lambda kv: kv[1]["name"].lower()):
label = info["name"]
status = friend_status.get(sid)
if status == "loading":
label += " (loading…)"
elif status == "empty":
label += " (private / 0 games)"
elif status == "error":
label += " (error)"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, sid)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked if sid in selected_ids else Qt.CheckState.Unchecked
)
self.list_widget.addItem(item)
self._suppress = False
def set_status(self, text):
self.status_label.setText(text)
def _on_item_changed(self, _item):
if self._suppress:
return
selected = set()
for i in range(self.list_widget.count()):
it = self.list_widget.item(i)
if it is None:
continue
if it.checkState() == Qt.CheckState.Checked:
selected.add(it.data(Qt.ItemDataRole.UserRole))
self.selection_changed.emit(selected)
class FriendsButton(QPushButton):
"""Combo-styled button that opens a checkable friend list popup."""
selection_changed = pyqtSignal(set)
refresh_requested = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet(FRIENDS_BTN_STYLE)
self.popup = FriendsPopup(self)
self.popup.selection_changed.connect(self._on_selection_changed)
self.popup.refresh_requested.connect(self.refresh_requested)
self._friends = {}
self._selected = set()
self._friend_status = {}
self._update_label()
def show_popup(self):
pos = self.mapToGlobal(QPoint(0, self.height()))
self.popup.move(pos)
self.popup.show()
def populate(self, friends, friend_status):
self._friends = friends
# Drop any selected sid that disappeared from the friend list.
self._selected = {sid for sid in self._selected if sid in friends}
self._friend_status = friend_status
self.popup.populate(friends, self._selected, friend_status)
self._update_label()
def update_status(self, friend_status):
self._friend_status = friend_status
self.popup.populate(self._friends, self._selected, friend_status)
def set_status(self, msg):
self.popup.set_status(msg)
def selected(self):
return set(self._selected)
def clear(self):
self._friends = {}
self._selected = set()
self._friend_status = {}
self.popup.populate({}, set(), {})
self.popup.set_status("")
self._update_label()
def _on_selection_changed(self, selected):
self._selected = selected
self._update_label()
self.selection_changed.emit(selected)
def _update_label(self):
n = len(self._selected)
self.setText(f"Friends ({n}) ▾" if n else "Friends ▾")
DIALOG_STYLE = """
QDialog, QWidget { background-color: #1b2838; }
QLabel { color: #c6d4df; }
@@ -537,6 +850,21 @@ class SteamDice(QMainWindow):
self.genres_thread = None
self.tags_table_thread = None
# Friends filter state. Friend list comes from disk; per-friend libraries
# populate lazily into self.friend_games as the user toggles them on.
self.friends = _load_friends_cache() # {sid: {"name": ...}}
self.selected_friends = set()
self.friend_games = {} # {sid: set(appids)}
self.friend_status = {} # {sid: "loading"|"empty"|"error"}
self.friends_thread = None
self.friend_games_thread = None
for sid in self.friends:
cached = _load_friend_games(sid)
if cached is not None:
self.friend_games[sid] = cached
if not cached:
self.friend_status[sid] = "empty"
self.setWindowTitle("Steam Dice")
self.setFixedSize(WIN_W, WIN_H)
self.setStyleSheet(STYLE)
@@ -554,18 +882,20 @@ class SteamDice(QMainWindow):
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(8)
COMBO_W = 115
COMBO_W = 100
def _combo_column(combo, sub_widget):
"""Combo with a 14px sub-row underneath (progress label or spacer)
so all three filter columns share identical geometry."""
def _combo_column(widget, sub_widget):
"""Top-row control with a 14px sub-row underneath so all four filter
columns share identical geometry. Combo widgets get COMBO_STYLE;
other widgets are expected to set their own style."""
col = QVBoxLayout()
col.setSpacing(4)
col.setContentsMargins(0, 0, 0, 0)
combo.setFixedHeight(28)
combo.setFixedWidth(COMBO_W)
combo.setStyleSheet(COMBO_STYLE)
col.addWidget(combo)
widget.setFixedHeight(28)
widget.setFixedWidth(COMBO_W)
if isinstance(widget, QComboBox):
widget.setStyleSheet(COMBO_STYLE)
col.addWidget(widget)
sub_widget.setFixedHeight(14)
col.addWidget(sub_widget)
return col
@@ -601,6 +931,18 @@ class SteamDice(QMainWindow):
self.tag_combo.popup_blocked.connect(self._prompt_tags_fetch)
top_row.addLayout(_combo_column(self.tag_combo, QLabel()))
# Friends filter (multi-select via checklist popup). Intersects the user's
# library with each selected friend's owned-game set so only games
# everyone owns survive.
self.friends_btn = FriendsButton()
self.friends_btn.setEnabled(False)
self.friends_btn.clicked.connect(self._handle_friends_open)
self.friends_btn.selection_changed.connect(self._on_friends_selection_changed)
self.friends_btn.refresh_requested.connect(self._refresh_friends_list)
if self.friends:
self.friends_btn.populate(self.friends, self.friend_status)
top_row.addLayout(_combo_column(self.friends_btn, QLabel()))
# When the cache already has data, allow the dropdowns to open immediately.
if any(isinstance(e, dict) and e.get("genres") for e in self.taxonomy_cache.values()):
self.genre_combo.set_allow_popup(True)
@@ -762,8 +1104,18 @@ class SteamDice(QMainWindow):
self.fetch_thread.start()
def _open_settings(self):
old_steam_id = QSettings("butter", "steam-dice").value("steam_id", "")
dlg = SettingsDialog(self)
if dlg.exec() == QDialog.DialogCode.Accepted:
new_steam_id = QSettings("butter", "steam-dice").value("steam_id", "")
if old_steam_id and new_steam_id != old_steam_id:
# Different account — friend list belongs to the previous user.
# Clear in-memory state; the next click on Friends will re-fetch.
self.friends = {}
self.selected_friends = set()
self.friend_games = {}
self.friend_status = {}
self.friends_btn.clear()
self.status_label.setText("Loading library…")
self.dice_btn.setEnabled(False)
self.filter_combo.setEnabled(False)
@@ -780,6 +1132,7 @@ class SteamDice(QMainWindow):
self.filter_combo.setEnabled(True)
self.genre_combo.setEnabled(True)
self.tag_combo.setEnabled(True)
self.friends_btn.setEnabled(True)
# Try Steam's local appinfo.vdf cache first — instant, no network.
# Genres populate immediately even before the tag-id table is available;
@@ -864,7 +1217,26 @@ class SteamDice(QMainWindow):
if tag in self.taxonomy_cache.get(str(g["appid"]), {}).get("tags", [])
]
# Friend filter: intersection — keep only games every selected friend
# also owns. Friends whose libraries haven't loaded yet are reported
# via the status line and the dice stays disabled until they arrive.
pending = []
for sid in self.selected_friends:
owned = self.friend_games.get(sid)
if owned is None:
pending.append(self.friends.get(sid, {}).get("name", sid))
continue
games = [g for g in games if g["appid"] in owned]
self.games = games
if pending:
shown = ", ".join(pending[:3])
if len(pending) > 3:
shown += f" +{len(pending) - 3}"
self.status_label.setText(f"Loading {shown}'s library…")
self.dice_btn.setEnabled(False)
return
count = len(self.games)
if count:
self.status_label.setText(f"{count} games — roll the dice!")
@@ -999,6 +1371,115 @@ class SteamDice(QMainWindow):
self._rebuild_genre_combo()
self._apply_filter()
def _handle_friends_open(self):
"""Friends button click: open the popup, fetching the friend list first
if we've never loaded it for this account."""
api_key = keyring.get_password("steam-dice", "api_key") or ""
steam_id = QSettings("butter", "steam-dice").value("steam_id", "")
if not api_key or not steam_id:
QMessageBox.information(
self, "Steam Dice",
"Configure your Steam API key and ID first (⚙)."
)
return
if self.friends:
self.friends_btn.show_popup()
return
if self.friends_thread and self.friends_thread.isRunning():
self.friends_btn.show_popup()
return
reply = QMessageBox.question(
self, "Load friends from Steam?",
"Load your Steam friend list to enable friend-based filtering?\n\n"
"Names are fetched once and cached. Each friend's library is "
"loaded on demand the first time you check them.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if reply != QMessageBox.StandardButton.Yes:
return
self.friends_btn.set_status("Loading friend list…")
self.friends_btn.show_popup()
self._start_friends_fetch(api_key, steam_id)
def _refresh_friends_list(self):
"""Popup's refresh button: re-pull friend list + names from Steam."""
api_key = keyring.get_password("steam-dice", "api_key") or ""
steam_id = QSettings("butter", "steam-dice").value("steam_id", "")
if not api_key or not steam_id:
return
if self.friends_thread and self.friends_thread.isRunning():
return
self.friends_btn.set_status("Refreshing friend list…")
self._start_friends_fetch(api_key, steam_id)
def _start_friends_fetch(self, api_key, steam_id):
self.friends_thread = FetchFriendsThread(api_key, steam_id)
self.friends_thread.done.connect(self._on_friends_loaded)
self.friends_thread.error.connect(self._on_friends_error)
self.friends_thread.start()
def _on_friends_loaded(self, friends):
if not friends:
self.friends_btn.set_status("No friends found (profile may be private).")
return
self.friends = friends
try:
_save_friends_cache(friends)
except OSError:
pass
# Carry over any per-friend status we already have from disk; mark new
# friends as "no cache yet" by leaving them out of friend_status.
for sid in friends:
cached = _load_friend_games(sid)
if cached is not None and sid not in self.friend_games:
self.friend_games[sid] = cached
if not cached:
self.friend_status[sid] = "empty"
self.friends_btn.populate(friends, self.friend_status)
def _on_friends_error(self, msg):
self.friends_btn.set_status(f"Error: {msg}")
def _on_friends_selection_changed(self, selected):
self.selected_friends = selected
# Kick off library fetches for any selected friend whose games we don't
# have cached and aren't already loading.
to_fetch = [
sid for sid in selected
if sid not in self.friend_games and self.friend_status.get(sid) != "loading"
]
if to_fetch:
for sid in to_fetch:
self.friend_status[sid] = "loading"
self.friends_btn.update_status(self.friend_status)
self._start_friend_games_fetch(to_fetch)
self._apply_filter()
def _start_friend_games_fetch(self, steam_ids):
api_key = keyring.get_password("steam-dice", "api_key") or ""
if not api_key:
return
thread = FetchFriendGamesThread(api_key, steam_ids)
thread.progress.connect(self._on_friend_games_loaded)
thread.error.connect(self._on_friend_games_error)
# Keep a reference so it isn't GC'd; we don't need to coordinate
# multiple in-flight batches since each batch only writes its own keys.
self.friend_games_thread = thread
thread.start()
def _on_friend_games_loaded(self, sid, appids):
appid_set = set(appids)
self.friend_games[sid] = appid_set
self.friend_status[sid] = "empty" if not appid_set else "loaded"
self.friends_btn.update_status(self.friend_status)
self._apply_filter()
def _on_friend_games_error(self, sid, _msg):
self.friend_status[sid] = "error"
self.friends_btn.update_status(self.friend_status)
self._apply_filter()
def _refresh(self):
self.refresh_btn.setEnabled(False)
self.dice_btn.setEnabled(False)
@@ -1008,6 +1489,13 @@ class SteamDice(QMainWindow):
self.cooldown_label.setVisible(True)
self._cooldown_timer.start()
self._fetch_library()
# Also re-fetch each currently-selected friend's library so the filter
# reflects what they own right now.
if self.selected_friends:
for sid in self.selected_friends:
self.friend_status[sid] = "loading"
self.friends_btn.update_status(self.friend_status)
self._start_friend_games_fetch(list(self.selected_friends))
def _on_cooldown_tick(self):
self.cooldown_remaining -= 1
@@ -1064,6 +1552,11 @@ class SteamDice(QMainWindow):
self.genres_thread.wait(12000) # cover in-flight 10s HTTP timeout + final save
if self.tags_table_thread and self.tags_table_thread.isRunning():
self.tags_table_thread.wait(15000)
if self.friends_thread and self.friends_thread.isRunning():
self.friends_thread.wait(15000)
if self.friend_games_thread and self.friend_games_thread.isRunning():
self.friend_games_thread.stop()
self.friend_games_thread.wait(15000)
super().closeEvent(a0)