Files
lintunes/lintunes/gui/preferences_dialog.py
T
trav d6140f5ac4 v0.35.0: a play history for the yearly Wrapped
The play journals keep running totals per track, which is enough for "most
played ever" and nothing a Wrapped asks: top songs of this year, minutes
listened, which month, what time of day, what got skipped. Those all come
from one timestamped line per listen, so LinTunes now keeps that, while
Preferences -> General -> "Keep a play history for my yearly Wrapped" is on.

history/<machine>/<YYYY-MM>.jsonl, one writer per folder like the journals:
local start time with its offset, ms actually heard (Player adds position
steps under 1.5 s, so seeks and pauses don't count), how it ended, where it
played and from what, and a name/artist/album snapshot that outlives a
delete. Under 5 s is noise and never written.

andTunes 0.3.0 writes the same lines on the Rabbit when sync says the
desktop wants them, and sync copies each month home when the device's copy
is longer. "Share this year's play history with friends" puts a trimmed
copy (no ids, playlists or devices) in each friend's cassette folder, daily
in November and December and monthly otherwise.
2026-10-02 16:47:40 -07:00

434 lines
18 KiB
Python

from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
QStackedWidget, QWidget, QDialogButtonBox, QFrame,
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont
from lintunes import theme
from lintunes.gui import sounds
from lintunes.theme import HIGHLIGHT_COLORS
_SCALES = ["small", "medium", "large"]
# (preference key, row label) for the per-area grayscale sliders.
_COLOR_ROWS = [
("color_background", "Background:"),
("color_button", "Buttons:"),
("color_now_playing_bg", "Now-playing panel:"),
("color_now_playing_text", "Now-playing text:"),
("color_stripe", "Stripes:"),
("color_visualizer_gray", "Visualizer (gray mode):"),
]
class PreferencesDialog(QDialog):
"""App preferences. Applies immediately — there is no OK/Cancel.
Laid out like VLC's (and Sync Settings'): categories on the left, the
chosen page on the right — General, Appearance, Integrations.
``music_folder``/``on_music_folder_change`` are optional callbacks rather
than a LibraryManager reference, keeping this dialog manager-free (the same
injection style as track_table's ``playlists_for_track``). When they are
omitted the music-folder row is simply left out.
"""
def __init__(self, prefs, lastfm, parent=None, *,
music_folder=None, on_music_folder_change=None):
super().__init__(parent)
self._prefs = prefs
self._lastfm = lastfm
self._music_folder = music_folder
self._on_music_folder_change = on_music_folder_change
self.setWindowTitle("Preferences")
self.setMinimumWidth(420)
# Color sliders preview live (set_live emits without writing); persist
# once the user pauses so a drag doesn't hammer the disk.
self._color_save_timer = QTimer(self)
self._color_save_timer.setSingleShot(True)
self._color_save_timer.setInterval(400)
self._color_save_timer.timeout.connect(self._prefs.save)
general = [self._build_sound_group(), self._build_wrapped_group()]
if self._music_folder is not None:
general.insert(0, self._build_music_group())
self.pages_list = QListWidget()
self.pages = QStackedWidget()
for title, groups in (
("General", general),
("Appearance", [self._build_general_group(),
self._build_colors_group()]),
("Integrations", [self._build_lastfm_group(),
self._build_acoustid_group()])):
self._add_page(title, groups)
self.pages_list.currentRowChanged.connect(self._show_page)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(self.pages_list)
splitter.addWidget(self.pages)
splitter.setCollapsible(1, False)
list_width = self.pages_list.sizeHintForColumn(0) + 32
self.pages_list.setFixedWidth(list_width)
splitter.setStretchFactor(1, 1)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(splitter, stretch=1)
layout.addWidget(buttons)
# Open big enough for the tallest page, so nothing starts cut off
# (the scroll areas are only a net for a small screen).
tallest = max(self.pages.widget(i).widget().sizeHint().height()
for i in range(self.pages.count()))
widest = max(self.pages.widget(i).widget().sizeHint().width()
for i in range(self.pages.count()))
screen = self.screen().availableGeometry() if self.screen() else None
height = tallest + buttons.sizeHint().height() + 48
if screen is not None:
height = min(height, screen.height() - 80)
self.resize(list_width + widest + 64, height)
self.pages_list.setCurrentRow(PreferencesDialog._last_page)
lastfm.login_finished.connect(self._on_login_finished)
self._refresh_lastfm_state()
_last_page = 0 # the page last viewed, for this session only
def _add_page(self, title: str, groups: list):
body = QWidget()
column = QVBoxLayout(body)
for group in groups:
column.addWidget(group)
column.addStretch()
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# Never narrower than the page wants: only height may scroll.
scroll.setMinimumWidth(body.sizeHint().width()
+ scroll.verticalScrollBar().sizeHint().width())
scroll.setWidget(body)
self.pages.addWidget(scroll)
self.pages_list.addItem(title)
def _show_page(self, row: int):
self.pages.setCurrentIndex(row)
PreferencesDialog._last_page = row
# ---- Sound ----
def _build_sound_group(self) -> QGroupBox:
group = QGroupBox("Sound effects")
row = QHBoxLayout(group)
self._sound_check = QCheckBox("Play a sound when a sync, export or "
"import finishes")
self._sound_check.setChecked(bool(self._prefs.get("sound_effects", True)))
self._sound_check.toggled.connect(
lambda on: self._prefs.set("sound_effects", on))
row.addWidget(self._sound_check, stretch=1)
preview = QPushButton("Play")
preview.clicked.connect(lambda: sounds.play_done(self._prefs, force=True))
row.addWidget(preview)
return group
# ---- Wrapped ----
def _build_wrapped_group(self) -> QGroupBox:
group = QGroupBox("Yearly Wrapped")
column = QVBoxLayout(group)
wrapped = self._prefs.wrapped
self._history_check = QCheckBox(
"Keep a play history for my yearly Wrapped")
self._history_check.setChecked(bool(wrapped.get("track_history")))
column.addWidget(self._history_check)
column.addWidget(self._note(
"Every song you listen to, here and on the Rabbit: when, for how "
"long, and whether you finished it. Kept in the library folder; "
"nothing leaves your machines."))
self._share_history_check = QCheckBox(
"Share this year's play history with friends")
self._share_history_check.setChecked(
bool(wrapped.get("share_with_friends")))
column.addWidget(self._share_history_check)
column.addWidget(self._note(
"Goes in your cassette folder for each friend: what you played, "
"when and for how long — no playlists or devices. Refreshed "
"monthly, and daily in November and December."))
self._share_history_check.setEnabled(self._history_check.isChecked())
self._history_check.toggled.connect(self._on_history_toggled)
self._share_history_check.toggled.connect(
lambda on: self._prefs.update_wrapped(share_with_friends=on))
return group
@staticmethod
def _note(text: str) -> QLabel:
label = QLabel(text)
label.setWordWrap(True)
label.setEnabled(False) # the dimmed sub-label look
label.setContentsMargins(24, 0, 0, 6)
return label
def _on_history_toggled(self, on: bool):
self._share_history_check.setEnabled(on)
self._prefs.update_wrapped(track_history=on)
# ---- Appearance ----
def _build_general_group(self) -> QGroupBox:
group = QGroupBox("Interface")
form = QFormLayout(group)
# UI size: 3-position slider
size_row = QHBoxLayout()
self._size_slider = QSlider(Qt.Orientation.Horizontal)
self._size_slider.setRange(0, 2)
self._size_slider.setPageStep(1)
self._size_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
self._size_slider.setTickInterval(1)
self._size_slider.setFixedWidth(160)
current_scale = self._prefs.get("ui_scale", "medium")
self._size_slider.setValue(
_SCALES.index(current_scale) if current_scale in _SCALES else 1)
self._size_label = QLabel(current_scale.capitalize())
self._size_slider.valueChanged.connect(self._on_size_changed)
size_row.addWidget(QLabel("Small"))
size_row.addWidget(self._size_slider)
size_row.addWidget(QLabel("Large"))
size_row.addSpacing(8)
size_row.addWidget(self._size_label)
size_row.addStretch()
form.addRow("UI size:", size_row)
# Highlight color swatches
color_row = QHBoxLayout()
self._color_group = QButtonGroup(self)
for name, hex_value in HIGHLIGHT_COLORS.items():
radio = QRadioButton(name)
radio.setIcon(theme.swatch_icon(hex_value))
radio.setChecked(self._prefs.get("highlight") == name)
radio.toggled.connect(
lambda checked, n=name: checked and self._prefs.set("highlight", n))
self._color_group.addButton(radio)
color_row.addWidget(radio)
color_row.addStretch()
form.addRow("Highlight:", color_row)
# Now-playing font. Three states, so three radios: automatic (None),
# the app's own font (""), or an explicit family.
font_row = QHBoxLayout()
self._font_mode = QButtonGroup(self)
self._font_auto = QRadioButton("Automatic")
self._font_auto.setToolTip(
"Century Gothic if you have it, otherwise the closest match installed")
self._font_default = QRadioButton("Same as the app")
self._font_pick = QRadioButton("Choose:")
for button in (self._font_auto, self._font_default, self._font_pick):
self._font_mode.addButton(button)
font_row.addWidget(button)
self._font_combo = QFontComboBox()
font_row.addWidget(self._font_combo, stretch=1)
# State before signals, or building the dialog would write a pref.
saved = self._prefs.get("now_playing_font")
resolved = theme.now_playing_font_family(self._prefs) or self.font().family()
self._font_combo.setCurrentFont(QFont(resolved))
self._font_combo.setEnabled(bool(saved))
(self._font_default if saved == ""
else self._font_pick if saved
else self._font_auto).setChecked(True)
self._font_auto.toggled.connect(
lambda on: on and self._set_font_mode(None))
self._font_default.toggled.connect(
lambda on: on and self._set_font_mode(""))
self._font_pick.toggled.connect(
lambda on: on and self._set_font_mode(self._font_combo.currentFont().family()))
self._font_combo.currentFontChanged.connect(self._on_font_chosen)
form.addRow("Now-playing font:", font_row)
return group
def _set_font_mode(self, value):
self._font_combo.setEnabled(value not in (None, ""))
self._prefs.set("now_playing_font", value)
if value is None:
# Show what "automatic" actually landed on.
resolved = theme.now_playing_font_family(self._prefs)
if resolved:
self._font_combo.blockSignals(True)
self._font_combo.setCurrentFont(QFont(resolved))
self._font_combo.blockSignals(False)
def _on_font_chosen(self, font):
if self._font_pick.isChecked():
self._prefs.set("now_playing_font", font.family())
# ---- music folder ----
def _build_music_group(self) -> QGroupBox:
group = QGroupBox("Music folder")
form = QFormLayout(group)
row = QHBoxLayout()
self._music_edit = QLineEdit()
self._music_edit.setReadOnly(True)
row.addWidget(self._music_edit, stretch=1)
change = QPushButton("Change…")
change.clicked.connect(self._change_music_folder)
row.addWidget(change)
form.addRow("Keep music in:", row)
self._music_note = QLabel()
self._music_note.setWordWrap(True)
form.addRow("", self._music_note)
self.refresh_music_folder()
return group
def refresh_music_folder(self):
"""Repopulate the row from the injected getter."""
if self._music_folder is None or not hasattr(self, "_music_edit"):
return
folder, note = self._music_folder()
self._music_edit.setText(folder)
self._music_edit.setPlaceholderText("Not set")
self._music_note.setText(note)
def _change_music_folder(self):
if self._on_music_folder_change is not None:
self._on_music_folder_change()
self.refresh_music_folder()
def _on_size_changed(self, value: int):
scale = _SCALES[value]
self._size_label.setText(scale.capitalize())
self._prefs.set("ui_scale", scale)
# ---- Colors ----
def _build_colors_group(self) -> QGroupBox:
group = QGroupBox("Colors")
form = QFormLayout(group)
note = QLabel("Each slider runs black → white and updates instantly.")
note.setWordWrap(True)
form.addRow(note)
self._color_sliders = {}
for key, label in _COLOR_ROWS:
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 255)
slider.setValue(theme.gray_value(self._prefs, key)) # before connect
slider.valueChanged.connect(
lambda value, k=key: self._on_color_changed(k, value))
self._color_sliders[key] = slider
form.addRow(label, slider)
return group
def _on_color_changed(self, key: str, value: int):
self._prefs.set_live(key, value) # live preview, no disk write yet
self._color_save_timer.start() # persist once the drag settles
# ---- Last.fm ----
def _build_lastfm_group(self) -> QGroupBox:
group = QGroupBox("Last.fm")
form = QFormLayout(group)
hint = QLabel(
'Needs a free API account: '
'<a href="https://www.last.fm/api/account/create">'
'last.fm/api/account/create</a>')
hint.setOpenExternalLinks(True)
form.addRow(hint)
lf = self._prefs.lastfm
self._api_key = QLineEdit(lf.get("api_key", ""))
self._api_secret = QLineEdit(lf.get("api_secret", ""))
self._api_secret.setEchoMode(QLineEdit.EchoMode.Password)
self._username = QLineEdit(lf.get("username", ""))
self._password = QLineEdit()
self._password.setEchoMode(QLineEdit.EchoMode.Password)
form.addRow("API key:", self._api_key)
form.addRow("Shared secret:", self._api_secret)
form.addRow("Username:", self._username)
form.addRow("Password:", self._password)
button_row = QHBoxLayout()
self._login_button = QPushButton("Log in")
self._login_button.clicked.connect(self._on_login_clicked)
self._status_label = QLabel("")
button_row.addWidget(self._login_button)
button_row.addWidget(self._status_label)
button_row.addStretch()
form.addRow("", button_row)
self._scrobble_check = QCheckBox("Enable scrobbling")
self._scrobble_check.setChecked(lf.get("scrobble_enabled", False))
self._scrobble_check.toggled.connect(
lambda on: self._prefs.update_lastfm(scrobble_enabled=on))
form.addRow("", self._scrobble_check)
return group
def _on_login_clicked(self):
if self._lastfm.is_logged_in():
self._lastfm.logout()
self._refresh_lastfm_state()
return
api_key = self._api_key.text().strip()
secret = self._api_secret.text().strip()
username = self._username.text().strip()
password = self._password.text()
if not all((api_key, secret, username, password)):
self._status_label.setText("Fill in all four fields first")
return
self._login_button.setEnabled(False)
self._status_label.setText("Logging in…")
self._lastfm.login(api_key, secret, username, password)
def _on_login_finished(self, success: bool, message: str):
self._status_label.setText(message)
self._login_button.setEnabled(True)
if success:
self._password.clear()
self._refresh_lastfm_state()
def _refresh_lastfm_state(self):
logged_in = self._lastfm.is_logged_in()
self._login_button.setText("Log out" if logged_in else "Log in")
self._scrobble_check.setEnabled(logged_in)
if logged_in and not self._status_label.text():
self._status_label.setText(
f"Logged in as {self._prefs.lastfm.get('username', '')}")
# ---- Track identification (AcoustID) ----
def _build_acoustid_group(self) -> QGroupBox:
group = QGroupBox("Track Identification")
form = QFormLayout(group)
hint = QLabel(
'Right-click a track → Identify Track… Needs a free application '
'key: <a href="https://acoustid.org/new-application">'
'acoustid.org/new-application</a>')
hint.setOpenExternalLinks(True)
hint.setWordWrap(True)
form.addRow(hint)
self._acoustid_key = QLineEdit(
self._prefs.acoustid.get("api_key", ""))
self._acoustid_key.editingFinished.connect(self._save_acoustid_key)
form.addRow("AcoustID key:", self._acoustid_key)
return group
def _save_acoustid_key(self):
key = self._acoustid_key.text().strip()
if key != self._prefs.acoustid.get("api_key", ""):
self._prefs.update_acoustid(api_key=key)