Около года назад мне захотелось чуть большего от десктопного музыкального клиента, который и так все знают, чем просто “работает и ладно”. Уже тогда в нём ощущались ограничения, которые сегодня стали только заметнее. Но началось всё, конечно же, с интерфейса: он казался слишком стерильным, а возможностей кастомизации почти не было. Поиски быстро привели меня не к официальным настройкам, а в небольшое сообщество вокруг PulseSync — ещё молодого проекта, который позволял модифицировать клиент через JS и CSS.
Там я наткнулся на тему «Blurity». Красивая, атмосферная, и пользовался бы ей дальше, пока не пришло обновление, которое всё сломало. Те, кто знаком с устройством Electron-приложений, знают эту боль: сегодня ты завязан на один набор сущностей и классов, завтра приложение обновляется, и половина логики просто перестает попадать в нужные точки.
Автор, к сожалению, не нашел сил восстановить тему. Я связался с ним и попросил разрешения продолжить работу. Так родился мой собственный проект: ChromaSync. Сначала — как попытка починить чужую тему, а потом — как отдельный проект runtime-аддона со своей глубокой системой настроек, аудиореактивными эффектами, умным алгоритмом выбора цвета из обложки трека и вычислительным ядром на WASM.
В какой-то момент я поймал себя на том, что чиню уже не тему, а плеер…
(Спойлер: по ходу статьи будут фото-демонстрации, но если вам не терпится — можете сразу промотать в конец и увидеть GIF работы, или перейти в GitHub проекта).
Архитектура проекта
Тема досталась мне уже в довольно потрёпанном состоянии. Я восстановил всё, что сломалось после обновления, пофиксил пару багов и добавил одну из первых действительно заметных функций — получение акцентного цвета прямо из обложки трека. Создал новую ветку темы, собирал отклик, допиливал, и уже через 3 дня получилось релизнуть ChromaSync 1.0.
Дальше всё развивалось по довольно предсказуемому сценарию: сначала одна полезная функция, потом вторая, потом настройка прозрачности и размытия, потом пульсация с анализом дорожки в real time, потом Zen mode, потом работа с локальными треками. В какой-то момент стало ясно: то, что начиналось как «сделать чуть красивее и удобнее», превратилось в полноценный проект со своими слоями ответственности.
Чтобы не утонуть в этом клубке логики, полезно сначала посмотреть на проект сверху. Архитектурно ChromaSync можно разложить на несколько основных частей:
-
metadata.json— точка входа; -
handleEvents.json— декларативная схема настроек; -
script.js— orchestration/runtime слой; -
style.css— визуальный патч; -
WASM-модуль — вычислительное ядро пульсации и тяжелых эффектов;
-
Backend — вспомогательный слой для отдельных сетевых возможностей.
Если упростить, то схема выглядит так: PulseSync загружает аддон через metadata.json, поднимает настройки из handleEvents.json, а затем основной runtime в script.js связывает всё вместе — DOM, стили, состояние плеера, визуальные эффекты, fullscreen-режимы и вычислительную часть на WASM.
На практике всё сводилось к довольно простой цепочке:
-
handleEvents.jsonописывает, какие параметры вообще доступны пользователю; -
PulseSync отдаёт их в runtime;
-
script.jsпревращает эти значения в единый рабочий state; -
style.cssи runtime-логика применяют этот state к интерфейсу; -
WASM берёт на себя часть вычислительно нагруженной логики, связанной с пульсацией.
Настройки в PulseSync
Начать проще всего со слоя настроек. PulseSync превратил тему в систему, которую можно настраивать без правки кода.
Например, вот так в handleEvents.json описывается одна из настроек:
{ "id": "backgroundTrackTransitionMode", "name": "Смена фона (трек)", "description": "Эффект переключения фоновой обложки при смене трека: fade (плавная подмена) или slide (старая уезжает влево, новая въезжает справа).", "type": "selector", "selected": "1", "options": [ { "name": "Fade (по умолчанию)", "id": "fade" }, { "name": "Slide (слева/справа)", "id": "slide-horizontal" } ], "defaultParameter": "fade"},
Здесь ключевую роль играет id. Именно по нему настройка дальше живёт в runtime. Остальная часть структуры нужна в основном самому PulseSync.
Один параметр довольно безобиден, но когда их 70, отслеживать их взаимодействие между собой становится уже не так просто — нужен отдельный слой нормализации и применения состояния. Именно этим дальше и занимается script.js.
Раздуть из мухи слона
Сейчас самое время мысленно спуститься в комментарии и заранее получить вполне справедливый вопрос: почему в теме больше 8000 строк только в script.js? Отчасти ответ прозаичен: это особенность работы с PulseSync, который поддерживает для аддона только один JS-файл. Но и я, конечно, приложил к этому руку. Ещё полгода назад я не думал, что примерно 700 строк JavaScript однажды превратятся вот в это.
Если handleEvents.json отвечает на вопрос «какие настройки вообще существуют», а style.css — «как всё должно выглядеть», то script.js отвечает на вопрос «как всё это должно жить и работать в реальном времени».
Именно он:
-
получает и нормализует настройки из PulseSync;
-
отслеживает состояние плеера, трека, fullscreen-режима и интерфейса;
-
связывает пользовательские настройки с DOM и CSS;
-
управляет динамическими эффектами, которые уже нельзя описать одним только CSS;
-
координирует пульсацию, fullscreen-режимы, фон, цвет, частицы и остальные элементы;
-
вызывает вычислительную часть на WASM, когда нужны более тяжёлые расчёты;
-
по сути, выступает оркестратором всего аддона.
То есть script.js здесь — это слой orchestration-логики: он не столько «рисует интерфейс», сколько синхронизирует между собой настройки, состояние плеера, визуальные эффекты и вычислительное ядро.
Когда потенциал CSS закончился, стало очевидно, что дальше проект будет расти уже не в сторону новых патчей, а в сторону orchestration-скрипта. Иначе тема просто не смогла бы существовать в том виде, в котором она есть сейчас.
Монолит, который мы заслужили
Этот Монолит, в отличие от некоторых, действительно исполняет желания. По крайней мере часть из них. Так что давайте разберём его подробнее.
Посмотрим на эволюцию script.js в разрезе версий. На раннем этапе ChromaSync был самой обычной «темой» для любого другого приложения. Логика в основном сводилась к визуальным трюкам: многослойному фону, параллаксу, плавным переходам и попытке сделать интерфейс менее плоским:
const handleParallax = (event) => { const offsetX = event.clientX / window.innerWidth - 0.5; const offsetY = event.clientY / window.innerHeight - 0.5; const posX = `calc(50% + ${offsetX * 25}px)`; const posY = `calc(50% + ${offsetY * 25}px)`; requestAnimationFrame(() => { bgLayer1.style.backgroundPosition = `${posX} ${posY}`; bgLayer2.style.backgroundPosition = `${posX} ${posY}`; });};
В какой-то момент тема перестала быть просто набором CSS-патчей. Один из первых шагов в эту сторону был довольно невинным: я захотел получать акцентный цвет прямо из обложки трека и прокидывать его в CSS-переменные. Для этого пришлось подтягивать библиотеку, извлекать палитру на лету и применять результат в runtime:
const applyChameleonStyles = (palette, sourceChoice) => { if (settings.useCustomAccentColor?.value) { document.documentElement.style.setProperty( '--accent-color', settings.customAccentColor?.value || '#8a63b3' ); return; } const swatch = palette?.[sourceChoice] || palette?.Vibrant; if (!swatch) return; document.documentElement.style.setProperty('--accent-color', swatch.getHex());};
Уже под конец начинает появляться код, являющийся жизненным циклом подсистемы. Например, fullscreen-пульсация в ChromaSync должна учитывать настройки, наличие нужного DOM-узла, состояние fullscreen-режима, observer’ы и корректную остановку runtime.
function syncFullscreenPulseRuntime(sourceNode) { const currentSettings = typeof settings !== 'undefined' ? settings : window.settings; if (!hasFullscreenPulseWorkEnabled(currentSettings)) { disconnectFullscreenPulseEntryObserver(); stopFullscreenPulseRuntime(); return false; } syncFullscreenPulseEntryObserver(); const target = sourceNode || document.querySelector('.FullscreenPlayerDesktopPoster_cover__CDmhM') || document.querySelector('[data-test-id="FULLSCREEN_PLAYER_MODAL"]'); return target ? startFullscreenPulseRuntime(target) : false;}
Пока логика была относительно простой, её можно было держать прямо в JavaScript. Но когда пульсация превратилась в отдельный вычислительный контур с несколькими режимами, сглаживанием, триггерами и служебной математикой, стало удобнее вынести эту часть в отдельное ядро. Так в проекте появился модуль на Rust/WASM: вычисления переехали в более компактный и изолированный модуль.
async function loadPulseWasm() { const res = await fetch('/аpi/chromasync/rust', { cache: 'no-store' }); const buf = await res.arrayBuffer(); const { instance } = await WebAssembly.instantiate(buf); return instance.exports;}
#[no_mangle]pub extern "C" fn c2(prev: f32, current: f32, weight: f32) -> f32 { prev * weight + current * (1.0 - weight)}
Зачем WASM? Как я уже написал — это разделение математики, но и оптимизация. Используется он в основном для всех алгоритмов пульсации. Поскольку эффектов, функций и прочего уже тонна, невозможно игнорировать оптимизацию, а пульсация — это то, что в окне всегда: она не должна проседать по кадрам, сам объект пульсации не должен никуда пропадать, и она должна быть точной. Поэтому я почти сразу решил реализовывать ее на WASM.
Поскольку за счет уже реализованной логики на JS и помощи нейросети получилось реализовать стабильный и главное оптимизированный функционал, что меня безусловно порадовало
Сколько стоит красота в миллисекундах
Смотря какой fabric, смотря сколько details
Отошли от душной части, выдыхаем. Возвращаясь к теме: само собой, каждая финтифлюшка вообще не бесплатна, и всё имеет свою цену. Красивые эффекты почти всегда добавлять проще, чем потом объяснять пользователю, почему он начал жить на 40 FPS. И ведь всё не предусмотришь: у меня ПК может быть достаточно мощным, чтобы вытягивать такое без проблем, а у Ивана из Твери — встройка и Intel Core i3, и надо бы, чтобы у него тоже хоть что-то работало.
Первонаперво были найдены более оптимальные подходы для реализации той или иной задачи, после этого был сделан некий список с топом самых жрущих эффектов, вот несколько их них:
-
Хамелеон-обводка (Градиент) — высоко;
-
Смена фона (fade/slide) — низко/высоко;
-
Включить Параллакс — высоко;
-
Глитч: The Wired (Lain) — очень тяжело;
-
Шум и пикселизация — заметная дополнительная нагрузка;
-
Размытие фона (rem) — очень тяжело.
И очень быстро стало ясно, что для темы такого размера ручное отключение отдельных фич уже не спасает: нужен был не набор галочек, а несколько заранее продуманных профилей деградации. Так в ChromaSync появились три пресета оптимизации: None, Balancing и Aggressive. Они управляют сразу несколькими контурами runtime — от параллакса и бордеров до fullscreen-пульсации и частоты обновления тяжёлых эффектов.
Причем логика оптимизации распространялась как на «Активное» окно приложения, так и на «Не активное» и работа там происходила разная:
Режим None — это история про максимальное качество. Если окно просто теряет фокус, я сознательно ничего не выключаю: тема продолжает работать так, как будто ничего не случилось.
На практике это значит, что:
-
параллакс остаётся активным;
-
тяжёлые эффекты не режутся;
-
fullscreen-логика не приостанавливается;
Режим Balancing — основной рабочий режим. Его идея в том, чтобы не ломать визуальную часть целиком, а убирать только то, что заметнее всего бьёт по отзывчивости.
Например, при потере фокуса в этом режиме тема не “засыпает” полностью, а только ставит на паузу параллакс и анимации обводок:
window.__csParallaxPaused = true;document.documentElement.classList.add('cs-borders-paused');updateParallaxState();try { ParticleOverlay.syncRuntime() } catch {}try { Cover3DEffect.syncRuntime() } catch {}console.debug('[ChromaSync] Parallax + border animations paused (Balancing Mode). Visuals active.');
То есть картинка остаётся живой, но самые дорогие и второстепенные визуальные мелочи начинают вести себя скромнее.
На этом же уровне включается и более мягкий троттлинг. Например, для параллакса используются разные обработчики в зависимости от выбранного режима:
const parallaxHandler = throttle(handleParallax, 33); // noneconst parallaxHandlerBalancing = throttle(handleParallax, 60); // balancing
Режим Aggressive — это режим, где тема уже откровенно выбирает производительность. При blur здесь тяжёлые эффекты выключаются целиком, отключаются observer’ы, прячутся анимации и останавливаются отдельные runtime-подсистемы.
if (mode === 'aggressive') { window.__csHeavyEffectsPaused = true; document.documentElement.classList.add('cs-borders-hidden'); UI_MANAGER.disconnect(); COVER_CHANGE_OBSERVER.disconnect(); SCOPED_UI_BINDINGS.disconnect(); updateParallaxState(); try { ParticleOverlay.syncRuntime() } catch {} try { Cover3DEffect.syncRuntime() } catch {} try { window.__csSuspendFullscreenPulseRuntime?.() } catch {} console.debug('[ChromaSync] Paused (Aggressive Mode). All effects stopped.');}
Этот режим используется не только как “жёсткая пауза”, но и как способ реже трогать тяжёлые визуальные контуры. Например, в анимации шума и глитча интервалы обновления становятся заметно больше. А в коде пульсации дополнительное ограничение накладывается даже на применение масштаба к фоновым слоям:
if (optimizationMode === 'aggressive') { const now = performance.now(); const minFrameMs = 1000 / 60; if (lastBackgroundScaleApplyTs && (now - lastBackgroundScaleApplyTs) < minFrameMs) { return; } lastBackgroundScaleApplyTs = now;}
К слову в случае если окно стриминга свёрнуто или полностью скрыта, то нагрузка становиться абсолютно нулевой. Давайте посмотрим что нам дают пресеты и какую нагрузку мы можем получить с ChromaSync :
Пресет None
Пресет Balancing
Пресет Aggressive
ChromaSync выключен
Для ChromaSync в данном случае были включены все возможные настройки, которые влияют на производительность. Как видно, рост процессорного времени в режиме Balancing который визуально почти идентичен None, всё равно заметно снижает нагрузку. На мой взгляд, это и есть правильный компромисс: тема остаётся “живой”, но перестаёт требовать от клиента лишнего.
Патч, который зашёл слишком далеко
Ещё многое можно было бы рассказать, но, пожалуй, главное я уже сказал. Этот проект стал для меня не просто «восстановлением работы», а довольно важным и личным занятием. Обновляя и развивая его, я многому научился. В какой-то момент всё это действительно зашло слишком далеко: я брал перерывы на несколько месяцев, но всё равно возвращался.
Это не история о всемогущем разработчике и его легендарном проекте. Скорее, это история о штуке, которую ты однажды начинаешь делать “для себя”, а потом понимаешь, что уже не бросишь. Просто потому, что она нравится тебе самому. И ещё тому десятку людей, которые готовы тебя поддерживать. Если бы мне самому это не нравилось, не было бы ни 10 000 строк кода, ни 70 настроек, ни всего того опыта, который этот проект в итоге мне дал.
И да, в этой статье я сознательно оставил за скобками несколько самых жирных кусков проекта: Частицы, работу с локальными треками, часть интеграций с плеером, комментарии и некоторые экспериментальные режимы. Не потому, что там нечего показать, а потому, что в какой-то момент текст всё же должен закончиться.
Так что если у этой статьи и есть короткий вывод, то он довольно простой: я хотел сделать тему, а сделал штуку, которую словом «тема» уже не очень удобно называть.
Если проект вам интересен, его можно посмотреть на GitHub | TG, там сможете найти более подробную информацию о проекте. В том числе видео-демонстрации
ссылка на оригинал статьи https://habr.com/ru/articles/1025248/