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.
434 lines
18 KiB
Python
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)
|