diff --git a/core/ui_appearance.py b/core/ui_appearance.py new file mode 100644 index 00000000..469029d2 --- /dev/null +++ b/core/ui_appearance.py @@ -0,0 +1,40 @@ +"""Pure decisions for UI-appearance defaults. + +Kept here (importable, no Flask/config coupling) so the rules are unit-testable in +isolation; web_server does only the request/config plumbing around them. + +Worker orbs are a blurred 60fps canvas — the main remaining Firefox lag source after +the #935 sweep. So for a FIRST-TIME user (no saved preference) we default them OFF on +Firefox and ON everywhere else: a smooth first impression where it's needed, full +polish where the browser handles it. An explicit saved choice ALWAYS wins — this only +picks the default when the user hasn't chosen. +""" + +from __future__ import annotations + +from typing import Optional + + +def is_firefox_user_agent(user_agent: Optional[str]) -> bool: + """True when a User-Agent string is Firefox. + + Used ONLY to pick a performance-friendly default — never to gate functionality — + so a spoofed or unusual UA simply gets the default and the user can toggle. Chrome, + Edge, Safari, Opera, Brave do not carry 'firefox' in their UA; Firefox does + ('… Gecko/… Firefox/').""" + return 'firefox' in str(user_agent or '').lower() + + +def resolve_worker_orbs_default(explicit: object, is_firefox: bool) -> bool: + """Whether worker orbs should be on. + + ``explicit`` is the saved config value: ``True``/``False`` when the user has chosen, + ``None`` when unset. An explicit choice always wins; when unset, default OFF on + Firefox (perf) and ON elsewhere. + """ + if explicit is None: + return not is_firefox + return explicit is not False + + +__all__ = ['is_firefox_user_agent', 'resolve_worker_orbs_default'] diff --git a/tests/test_ui_appearance.py b/tests/test_ui_appearance.py new file mode 100644 index 00000000..1fd2f544 --- /dev/null +++ b/tests/test_ui_appearance.py @@ -0,0 +1,56 @@ +"""Pure UI-appearance default rules (core/ui_appearance.py). + +Pins the worker-orbs default contract: explicit saved choice ALWAYS wins; when unset, +default OFF on Firefox (the blurred orb canvas is the main remaining Firefox lag +source) and ON elsewhere.""" + +from core.ui_appearance import is_firefox_user_agent, resolve_worker_orbs_default + +_FIREFOX_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) " + "Gecko/20100101 Firefox/128.0") +_CHROME_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") +_SAFARI_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.0 Safari/605.1.15") +_EDGE_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0") + + +# ── is_firefox_user_agent ── + +def test_detects_firefox(): + assert is_firefox_user_agent(_FIREFOX_UA) is True + + +def test_non_firefox_browsers_are_false(): + for ua in (_CHROME_UA, _SAFARI_UA, _EDGE_UA): + assert is_firefox_user_agent(ua) is False + + +def test_empty_or_none_ua_is_not_firefox(): + assert is_firefox_user_agent('') is False + assert is_firefox_user_agent(None) is False + + +# ── resolve_worker_orbs_default: explicit ALWAYS wins ── + +def test_unset_defaults_off_on_firefox(): + assert resolve_worker_orbs_default(None, is_firefox=True) is False + + +def test_unset_defaults_on_elsewhere(): + assert resolve_worker_orbs_default(None, is_firefox=False) is True + + +def test_explicit_true_wins_even_on_firefox(): + # A Firefox user who explicitly enabled orbs keeps them — default never overrides. + assert resolve_worker_orbs_default(True, is_firefox=True) is True + + +def test_explicit_false_wins_even_off_firefox(): + assert resolve_worker_orbs_default(False, is_firefox=False) is False + + +def test_explicit_values_ignore_browser(): + assert resolve_worker_orbs_default(True, is_firefox=False) is True + assert resolve_worker_orbs_default(False, is_firefox=True) is False diff --git a/web_server.py b/web_server.py index 2383b59f..c21a1fba 100644 --- a/web_server.py +++ b/web_server.py @@ -84,6 +84,7 @@ if not pp_logger.handlers: pp_logger.propagate = False from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack, _is_globally_rate_limited as _spotify_rate_limited from core.plex_client import PlexClient +from core.ui_appearance import is_firefox_user_agent, resolve_worker_orbs_default from plexapi.myplex import MyPlexAccount, MyPlexPinLogin from core.jellyfin_client import JellyfinClient from core.navidrome_client import NavidromeClient @@ -359,12 +360,32 @@ def _hsl_to_rgb(hue, saturation, lightness): ) +def _request_is_firefox() -> bool: + """Whether the current request's browser is Firefox (UA-based). Used ONLY to pick a + performance-friendly default; an explicit saved setting always wins. Safe outside a + request context (returns False).""" + try: + from flask import has_request_context, request + if not has_request_context(): + return False + return is_firefox_user_agent(request.headers.get('User-Agent')) + except Exception: + return False + + def _initial_appearance_context(): preset = config_manager.get('ui_appearance.accent_preset', '#1db954') custom = config_manager.get('ui_appearance.accent_color', '#1db954') accent = _valid_hex_color(custom if preset == 'custom' else preset) particles_enabled = config_manager.get('ui_appearance.particles_enabled', False) is True - worker_orbs_enabled = config_manager.get('ui_appearance.worker_orbs_enabled', True) is not False + # Worker orbs: explicit choice wins; unset → OFF on Firefox (the blurred orb canvas + # is the main remaining Firefox lag source), ON elsewhere. config default None so we + # can tell "unset" from an explicit False. (#kettui — single source: the server + # decides, the client consumes the injected value below.) + worker_orbs_enabled = resolve_worker_orbs_default( + config_manager.get('ui_appearance.worker_orbs_enabled', None), + _request_is_firefox(), + ) reduce_effects = config_manager.get('ui_appearance.reduce_effects', False) is True r, g, b = _hex_to_rgb(accent) hue, saturation, lightness = _rgb_to_hsl(r, g, b) diff --git a/webui/static/settings.js b/webui/static/settings.js index 36a5a803..69443963 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1548,8 +1548,14 @@ async function loadSettingsData() { if (particlesCheckbox) particlesCheckbox.checked = particlesEnabled; applyParticlesSetting(particlesEnabled); - // Worker orbs toggle - const workerOrbsEnabled = settings.ui_appearance?.worker_orbs_enabled !== false; // default true + // Worker orbs toggle. When the user hasn't saved a preference, reflect the + // server-decided browser-aware default (window._workerOrbsEnabled — OFF on + // Firefox for perf) so saving settings doesn't silently flip a first-time + // Firefox user's orbs back on. An explicit saved config value always wins. + const _orbsCfg = settings.ui_appearance?.worker_orbs_enabled; + const workerOrbsEnabled = (_orbsCfg === undefined || _orbsCfg === null) + ? (window._workerOrbsEnabled !== false) + : (_orbsCfg !== false); const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled'); if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled; applyWorkerOrbsSetting(workerOrbsEnabled);