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.
171 lines
6.7 KiB
Python
171 lines
6.7 KiB
Python
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
|