Files
lintunes/lintunes/preferences.py
T
trav 599972fa1a v0.43.0: visualizer presets
Each visualizer style's settings page has a Preset drop-down and a
"Save as Preset…" button. The drop-down lists the built-in Default, your
presets, and "Edit Presets…" (rename in place, delete); it reads "Custom"
whenever the settings match no preset. Presets sync with the preferences;
the per-machine sync delay is never part of one.
2026-10-10 01:41:01 -07:00

171 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import copy
import json
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
DEFAULTS = {
"ui_scale": "medium", # small | medium | large
"highlight": "blue", # see theme.HIGHLIGHT_COLORS
"highlight_palette": {}, # slot name -> "#rrggbb" the user picked
"now_playing_font": None, # None=prefer Century Gothic, ""=use app
# default, "Family"=use app
"volume": 0.8, # logical 0..1 master output volume
"visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle
"visualizer_style": "bars", # bars | radial | laser (visualizer_styles)
# The styles' tunables — ranges in visualizer_styles.LIMITS.
# Fade times in seconds, 0 = off: how long what a bar or dot left
# behind, or the laser's past sweeps, take to fade into the background.
"visualizer_bars_fade": 0.0,
"visualizer_ghost_fade": 1.0, # the radial's
"visualizer_laser_afterimage": 0.1,
"visualizer_laser_width": 1.5, # pixels
"visualizer_laser_dot": 3.0, # pixels across
"visualizer_laser_head": True, # a dot at the moving beam's tip
"visualizer_laser_glow": True, # the haze/scatter bloom
"visualizer_laser_grid": False, # an oscilloscope's dot graticule
"visualizer_presets": {}, # {style: {name: settings}}
"visualizer_laser_low_hz": 20, # the speaker under the mirror:
"visualizer_laser_high_hz": 20000, # what it plays (ends = no filter)
"visualizer_radial_spokes": 120, # frequency bands around the circle
"visualizer_radial_levels": 20, # volume steps along each spoke
"visualizer_radial_dot": 1.0, # dot size, × the automatic size
"visualizer_radial_normalize": True, # even out bass vs treble
"visualizer_laser_distance": 3.5, # metres, mirror → screen center
"visualizer_laser_pitch": 0, # degrees the screen is turned
"visualizer_laser_yaw": 0,
"visualizer_laser_roll": 0,
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the
# current/default look for that surface, so nothing changes until the user
# drags a slider in Preferences.
"color_background": None, # outer chrome (QPalette.Window)
"color_button": None, # rounded transport button boxes
"color_now_playing_bg": None, # panel behind the now-playing text
"color_now_playing_text": None, # the now-playing title/artist text
"color_stripe": None, # alternating row stripes (QPalette.AlternateBase)
"color_visualizer_gray": None, # visualizer bars in dim (gray) mode
"lastfm": {
"api_key": "",
"api_secret": "",
"username": "",
"session_key": "",
"scrobble_enabled": False,
},
# Track identification (Identify Track…). The application key is the
# user's own, registered free at acoustid.org/new-application.
"acoustid": {
"api_key": "",
},
# Which playlists belong on the andTunes device, by persistent_id. Kept
# here rather than on the Playlist so it stays out of the conflict-merge
# machinery — and because riding the syncthing share is the point: both
# machines should agree on what the Rabbit is carrying.
"device_sync": {
"playlist_ids": [],
# Fully ticked folders: everything under them syncs, including
# playlists added to them later (gui/device_sync_dialog.py).
"folder_ids": [],
"copy_art": True,
},
# Which machine runs friend sharing (Cassette): {machine_id, hostname}.
# Synced so the other machine can name it; see lintunes/cassette/host.py.
"cassette_host": None,
# The quiet "tink" when a sync, export or URL import finishes.
"sound_effects": True,
# The yearly Wrapped (storage/listen_history.py). Synced on purpose: one
# tick and both desktops — and the Rabbit, via the sync — keep a history.
"wrapped": {
"track_history": False,
# This year's trimmed history in every friend's cassette folder.
"share_with_friends": False,
},
}
class Preferences(QObject):
"""App preferences persisted to <data_dir>/preferences.json (so they ride
the same syncthing share as the library)."""
changed = pyqtSignal()
def __init__(self, data_dir: Path, parent=None):
super().__init__(parent)
self._path = Path(data_dir) / "preferences.json"
self._data = copy.deepcopy(DEFAULTS)
if self._path.exists():
try:
with open(self._path, "r", encoding="utf-8") as f:
stored = json.load(f)
_deep_merge(self._data, stored)
except (ValueError, OSError):
pass
def get(self, key: str, default=None):
return self._data.get(key, default)
def set(self, key: str, value):
if self._data.get(key) == value:
return
self._data[key] = value
self.save()
self.changed.emit()
def set_live(self, key: str, value):
"""Update a preference and notify listeners *without* writing to disk —
for live drag-preview (e.g. the color sliders). Call save() to persist
once the user stops dragging."""
self._data[key] = value
self.changed.emit()
def update_lastfm(self, **fields):
self._data["lastfm"].update(fields)
self.save()
self.changed.emit()
@property
def lastfm(self) -> dict:
return self._data["lastfm"]
def update_acoustid(self, **fields):
self._data["acoustid"].update(fields)
self.save()
self.changed.emit()
@property
def acoustid(self) -> dict:
return self._data["acoustid"]
def update_device_sync(self, **fields):
self._data["device_sync"].update(fields)
self.save()
self.changed.emit()
@property
def device_sync(self) -> dict:
return self._data["device_sync"]
def update_wrapped(self, **fields):
self._data["wrapped"].update(fields)
self.save()
self.changed.emit()
@property
def wrapped(self) -> dict:
return self._data["wrapped"]
def save(self):
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self._data, f, indent=2)
tmp.replace(self._path)
def _deep_merge(base: dict, override: dict):
for key, value in override.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
_deep_merge(base[key], value)
else:
base[key] = value