Spotify на 196 МБ вместо 2 ГБ, видеоплеер с пультом на iPhone и скринсейвер «Матрица»: что бывает, когда остались токены - QubStore

Spotify на 196 МБ вместо 2 ГБ, видеоплеер с пультом на iPhone и скринсейвер «Матрица»: что бывает, когда остались токены

Содержание статьи

Знаете этот опасный момент в конце расчётного периода, когда задачи по основным проектам закрыты, а на балансе остались неистраченные токены? Нормальный человек ложится спать. Разработчик с остатком лимитов начинает творить всякую дичь.

Проект Kinrox (GitHub) родился ровно из этого состояния, когда в одной точке сошлись две бытовые боли и одно вечное эстетическое требование: сделать удобный видеоплеер с пультом на iPhone, лёгкий проигрыватель Spotify и, самое главное, встроить туда скринсейвер «Матрицы».

Первая боль — диван и проектор. MacBook подключён по HDMI к HD‑проектору, ты лежишь под пледом, смотришь кино, и тут нужно поставить на паузу, убавить звук, переключить звуковую дорожку из вложенной папки или включить следующую серию. Вставать с дивана и идти к ноутбуку каждый раз — преступление против лени.

Вторая боль — цены на память у Apple. На моём MacBook Pro (MacBookPro18,2, Apple Silicon) всего 32 ГБ оперативки, и официальный клиент Spotify.app с удовольствием отъедает от них около 2 ГБ просто за право крутить музыку в фоне.

В англоязычном посте про релиз я коротко пошутил:

Memory is so expensive these days. My MacBook only has 32 GB, and Spotify takes up 2 GB of it. So I decided to rewrite the Spotify client in Rust. Now it uses 196 MB and three times less CPU. The app is open source, of course. It also has a built‑in video player with iPhone remote control. Since Apple’s review process can now take 2–3 weeks, the app is currently available for Mac download only. Now, when I watch videos on a projector through HDMI, I can control everything from my iPhone and hardly have to get up at all. Hehe.

В коротком твите написать «переписал на Rust» проще всего, но для Хабра дадим полную раскладку без сокращений. Сам плеер и интерфейс написаны на нативном Swift 6 и SwiftUI (плюс крошечный сторожевой процесс на C), а в поисках лёгкого движка для музыки мы прошли через целый зоопарк клиентов Spotify: от обычного Web API и изучения Rust‑крейта librespot до совсем свежего официального headless‑бинаря Spotify Soloist, который вышел буквально на днях и ест 25–27 МиБ памяти.

Короткая видеодемонстрация того, как всё это работает вживую:

Поехали.

Диван, HDMI и почему существующие плееры не подошли

Spotify на 196 МБ вместо 2 ГБ, видеоплеер с пультом на iPhone и скринсейвер «Матрица»: что бывает, когда остались токены

С видеоплеером задача выглядела простой: нужен минималистичный плеер на macOS, который всеядно крутит тяжёлые MKV и MP4, не перегружен сотней кнопок и управляется с родного iOS‑приложения по локальной сети. Без облаков, регистраций, внешних серверов и телеметрии.

Казалось бы, есть IINA и VLC. Но у IINA нет официального нативного пульта под iPhone, а веб‑интерфейс VLC с паролем в мобильном браузере — это последнее, во что хочется тыкать в темноте на диване.

Мы взяли проверенное ядро libmpv 0.41.0 (FFmpeg 9.0.1, OpenGL и аппаратное декодирование через VideoToolbox), обернули его в чистый Swift‑протокол PlayerEngine, чтобы типы C (mpv_handle, mpv_event) никогда не протекали наружу из модуля MPVKit, и собрали пару PlayerMac + RemoteiOS.

На пальцах. Как устроен пульт, чтобы не нужно было ничего настраивать руками: Mac‑плеер поднимает локальный сервер через Network.framework и анонсирует себя в подсети по Bonjour → iPhone мгновенно находит Mac → при первой связке на экране ноутбука появляется 6-значный PIN‑код → приложения проводят криптографический хендшейк через CryptoKit, сохраняют токен в системный Data Protection Keychain и дальше гоняют зашифрованные JSON‑фреймы по локальному WebSocket.

Попутно для видео закрыли три вещи, которые постоянно бесят при просмотре сериалов с торрентов. Например:

  • Рекурсивный поиск внешних дорожек и субтитров. Если озвучка и .ass/.srt лежат в соседних вложенных папках, плеер сам находит их и запоминает выбор для следующего эпизода в этой директории.

  • Живой плейлист папки с естественной сортировкой (2 идёт перед 10). Пока ты смотришь первую серию, а в ту же папку докачивается вторая, кнопка Next на iPhone загорается сама. Временные хвосты недокачанных файлов вроде .part, .crdownload и .!qB фильтр игнорирует, пока скачивание не завершится.

  • Единый координатор PlaybackCoordinator («один звук за раз»). Нажал Play в фильме — музыка гарантированно встала на паузу; включил трек — остановилось видео.

Видеоплеер взлетел за пять дней (версии 0.1.0 — 0.3.0). И тут взгляд упал на Activity Monitor, где рядом со скромным Kinrox сидел двухгигабайтный Spotify.app, а на счету всё ещё оставались свободные токены.

Иллюзия быстрой победы: почему у Spotify столько клиентов и как мы написали «экономитель памяти», требующий открытого Spotify

Spotify на 196 МБ вместо 2 ГБ, видеоплеер с пультом на iPhone и скринсейвер «Матрица»: что бывает, когда остались токены

Ну а теперь начнём разбираться, как задача «быстренько прикрутить музыку на выходные» превратилась в многосерийное детективное расследование.

Изначально план казался железобетонным: делаем во вкладке Music классический трёхколоночный интерфейс в духе старого Winamp (Source -> Albums -> Tracks, никаких гигантских обложек на пол‑экрана, всё плотно и быстро), авторизуемся по OAuth PKCE со своим Client ID и играем музыку. Главное правило, которое мы записали в спецификацию первой строкой: никаких WKWebView, скрытых браузеров и Chromium внутри приложения. Иначе борьба с двухгигабайтным Electron‑клиентом теряет всякий смысл.

Но когда начинаешь копать экосистему Spotify в 2026 году, выясняется, что «клиентов Spotify» на самом деле несколько, и у каждого своя засада:

  1. Официальный Spotify Web API (/v1/me/player, он же Spotify Connect) сам декодировать аудиопоток не умеет. Он только отдаёт команды активному устройству в сети.

  2. Браузерный Web Playback SDK требует среду с EME/DRM, то есть тот самый WebView, который мы запретили на пороге.

  3. Неофициальные клиенты на базе реверс‑инжиниринга протокола (librespot на Rust, go-librespot) умеют играть сами, но тянут за собой возню с закрытым протоколом и правилами платформы.

  4. И наконец, Spotify Soloist (com.spotify.soloist) — совсем свежий официальный headless‑клиент от самой Spotify для Apple Silicon и Core Audio, управляемый по локальному WebSocket.

Поначалу (в релизе 0.4.0) мы пошли по первому, самому «правильному» пути — через официальный Web API и Spotify Connect. Сделали авторизацию, подтянули библиотеку, выпустили билд… и тут до нас дошла вся комичность ситуации.

Чтобы SpotifyConnectMusicEngine издал хотя бы один звук через /v1/me/player, на Маке должен быть запущен активный приёмник Spotify Connect. То есть официальный Spotify.app должен быть открыт!

Понимаете уровень инженерии? Мы затеяли всю эту историю ради того, чтобы убить прожорливый Spotify.app и освободить 2 ГБ оперативки, а в первой версии написали для него… внешнюю панель управления, которая без запущенного Spotify.app выдаёт SpotifyError.noDevice и вежливо просит открыть официальный клиент. План был отличный, реализация тоже ничего.

Но даже в роли пульта для Spotify.app первая реализация устроила нам показательную порку.

Грабли № 1. HTTP 204, который молча убивает воспроизведение

По документации Spotify Web API, чтобы запустить свою очередь треков, можно отправить массив uris в PUT /v1/me/player/play. Мы так и сделали. Сервер бодро ответил HTTP 204 No Content, после чего десктопный клиент Spotify… просто очистил плеер и замолчал. А любая следующая команда падала с 403 Restriction violated.

Оказалось, что это известный баг актуального десктопного клиента при передаче сырого списка URI. Пришлось городить обходной манёвр: создавать в аккаунте приватный технический плейлист Kinrox Queue, зеркалировать туда локальную очередь и запускать воспроизведение уже в контексте этого плейлиста.

Грабли № 2. Один трек и вечная заморозка из‑за гонки поллинга

Выкатываем 0.4.0, включаем альбом. Первая песня играет отлично, а дальше интерфейс Kinrox намертво замирает на старом треке и перестаёт реагировать на кнопки.

Расследование в 0.4.1 показало классическую асинхронную гонку:

  1. Поскольку у Web API нет пуш‑уведомлений, движок опрашивал GET /v1/me/player каждые 2 секунды.

  2. Пока в воздухе висел старый запрос поллинга от предыдущего состояния, пользователь нажимал Play на новом альбоме.

  3. Команда Play успевала полностью выполниться и подтвердить старт новой очереди. Флаг isCommandRunning снова становился false.

  4. И в этот момент возвращался тот самый запоздавший ответ от старого поллинга (со старым контекстом или случайным HTTP 503).

  5. Движок видел чужой контекст при isCommandRunning == false, решал, что воспроизведение перехватили извне, выбрасывал ошибку и обнулял задачу поллинга (polling = nil). Новую задачу никто не создавал, потому что в момент старта Play старая ещё числилась живой.

Вылечили введением монотонного счётчика поколений commandGeneration: каждая команда сдвигает эпоху, и любой ответ поллинга со старым номером поколения отправляется в мусорку.

Грабли № 3. Почему переключение трека делало 11 HTTP‑запросов, а библиотека грузилась вечность

Когда мы посмотрели в сетевой лог 0.4.1 под микроскопом, волосы встали дыбом.

Во‑первых, при каждом холодном запуске приложение грузило с нуля всю медиатеку строго последовательно: все страницы лайкнутых треков → все сохранённые альбомы → всех артистов → список плейлистов → все треки каждого редактируемого плейлиста. Пока этот паровоз не доезжал до конца, экран альбомов ждал. Самое обидное: метод GET /v1/me/albums уже возвращает внутри себя до 50 треков каждого альбома (SpotifyAlbum.tracks), но наш парсер их просто выбрасывал и при клике на альбом делал ещё 2 HTTP‑запроса! А слияние каталога в памяти крутило линейные поиски first(where:) со сложностью O(N²) по тысячам треков прямо на @MainActor.

Во‑вторых, когда пользователь нажимал Next, Previous или даже обычный Resume после паузы, вызывался полный цикл startQueue. Выглядело это так: от 9 до 11 последовательных HTTP‑запросов (проверить девайсы → прочитать плеер → поставить на паузу → подтвердить паузу → проверить плейлист Kinrox Queue → перезаписать в нём треки → выключить шаффл → выставить репит → запустить play → дождаться подтверждения). При этом проверка подтверждения спала на том же фиксированном интервале в 2 секунды! Даже добавление одной песни в очередь на лету (addToQueue) ставило музыку на паузу, переписывало весь плейлист и перезапускало текущий трек. А фоновый поллинг раз в 2 секунды сжигал по 1800 запросов в час, рискуя упереться в HTTP 429 и лимиты Spotify Development Mode.

В версии 0.4.2 мы вычистили это под корень:

  • Подняли локальный кэш в SQLite (libsqlite3 в отдельном сериализованном акторе SpotifyCacheStore, 5 компактных таблиц без лишних join‑таблиц, лимит 100 МиБ). Теперь при запуске медиатека на 10 000 треков открывается с диска за 40,98 мс (на 100 треках — за 16,65 мс) вообще без единого сетевого запроса.

  • Заменили линейные сканы в MusicCatalog на словари O(1): выбор плейлиста на 10 000 треков ускорился со 138,61 мс до 2,08 мс (почти в 67 раз).

  • Сделали трёхволновую загрузку (Wave 0 с диска → Wave 1 альбомы со встроенными треками → Wave 2/3 остальное в фоне с приоритетным планировщиком запросов, где команды управления всегда обгоняют синхронизацию) и поддержку ETag / HTTP 304.

  • Убили периодический поллинг. Совсем. На паузе Kinrox теперь делает 0 фоновых запросов, а во время игры двигает прогресс по локальным монотонным часам ContinuousClock и ставит ровно один одноразовый таймер на ожидаемую границу конца трека (duration — position + 0.35 с).

  • Сделали быстрые пути (Fast Paths) для Resume, Next и Previous по смещению (offset) и точечную вставку в очередь через POST /v1/playlists/{id}/items без остановки музыки, а интервалы подтверждения перевели на быстрый бэкофф 80 → 150 → 300 мс.

Вывод. Приложение полетело, но главная заноза осталась: рядом в системе всё ещё висел двухгигабайтный Spotify.app. Пришло время резать по живому.

Переход на Spotify Soloist: одно чиним — другое взрывается

Итак, задача для релиза 0.5.0 звучала бескомпромиссно: Spotify.app должен быть полностью закрыт (pgrep -x Spotify обязан возвращать пустоту на протяжении всего теста). Никаких свёрнутых окон и скрытых браузеров.

Мы изучили варианты и остановились на Spotify Soloist (com.spotify.soloist) — свежем официальном консольном движке от самой Spotify под macOS (arm64, Core Audio). По условиям Spotify его нельзя вшивать внутрь чужого .dmg, поэтому Kinrox при настройке сам скачивает архив с официального CDN Spotify, проверяет цифровую подпись Apple Developer ID (Team ID 2FNC3A47ZF) и нотаризацию через SecStaticCodeCheckValidity, распаковывает в изолированную папку и общается с ним по локальному WebSocket на 127.0.0.1. Один раз спариваешь его через приложение Spotify на телефоне в одной Wi‑Fi‑сети, закрываешь Spotify везде — и музыка играет напрямую через Core Audio.

И вот тут начался настоящий триллер.

Серия 1. Трек длится 5 минут, а потом врывается бессмертный Autoplay

Запускаем в Soloist обычный постоянный режим (persistent mode). Нам важно, чтобы очередью треков на 100% владел сам Kinrox: у нас свои правила шаффла, повтора и истории. Поэтому на iPhone в настройках Spotify мы явно выключаем Autoplay, а по WebSocket отправляем в Soloist команды shuffle: false и repeat: «off».

Загружаем длинный трек на 323 секунды (5 с лишним минут). Терпеливо ждём все пять минут, пока он доиграет до естественного конца (EOF), ожидая, что плеер спокойно перейдёт к следующему треку нашей очереди.

Вместо этого на 324-й секунде Soloist радостно игнорирует все выключенные тумблеры и врубает левый рекомендованный трек с пометкой source: autoplay! Оказалось, что в постоянном режиме внутренний движок после окончания одиночного URI всё равно лезет за авторекомендациями Spotify, плевать он хотел на ваши локальные планы.

Серия 2. Включаем ‑single‑track и получаем бомбу с часовым механизмом на 60 секунд

Хорошо, раз постоянный режим своевольничает, переводим Soloist в режим —single-track: один процесс на один трек из очереди. Отыграл песню → чисто завершился с exit code 0 → наш редьюсер проверил, что это реальный конец трека, и запустил следующий. Автоплей побеждён!

Нажимаем паузу, чтобы налить чаю. Проходит ровно минута. На отметке 60,6 секунды (во втором контрольном прогоне — 61,75 секунды) стоящий на паузе процесс soloist молча умирает с exit code 1! Кнопки управления в плеере превращаются в тыкву.

Снова лезем в трассировки: в режиме —single-track движок считает любую паузу длиннее ~60 секунд брошенной сессией и аварийно выходит.

Как сделать так, чтобы пауза могла длиться хоть сутки, но процесс не падал через минуту? Мы придумали механизм 20-секундной парковки:

  • Пока пауза короткая (до 20 секунд) — процесс soloist ждёт наготове, и Resume срабатывает за 11–22 мс.

  • Как только пауза переваливает за 20 секунд (задолго до роковой 60-й секунды!), Kinrox запоминает точную подтверждённую позицию в миллисекундах и сам аккуратно гасит дочерний процесс soloist. В итоге на длинной паузе фоновый движок потребляет 0 МБ памяти и 0% CPU.

  • Когда ты возвращаешься через полчаса и жмёшь Space, Kinrox поднимает свежий процесс soloist с громкостью 0% (чтобы не было слышно начала песни с первой секунды), делает seek на сохранённую позицию, ждёт подтверждения от декодера и только после этого возвращает твою громкость. Всё восстановление занимает 1,6–2,1 секунды.

Правда, и тут мы умудрились наступить на грабли: сначала таймер парковки запускался только тогда, когда паузу нажали внутри самого Kinrox. Во время тестового прослушивания пауза прилетела внешним событием, таймер на 20 секунд не взвёлся, и через 60 секунд soloist снова гордо вышел с кодом 1. Починили: теперь любое наблюдаемое состояние paused ставит процесс на парковку.

Серия 3. «Не удалось открыть видео: Spotify не разрешил»

Казалось бы, музыка наконец играет без Spotify.app. Решаем проверить, не сломали ли мы обычный видеоплеер. Открываем локальный .mkv с фильмом… и получаем на весь экран красную плашку:

Failed to open video / Soloist could not confirm the playback command.

Немая сцена. Ты открываешь локальный видеофайл с диска в видеоплеере, а тебе отказывают, потому что музыкальный движок Spotify не подтвердил команду!

Причина оказалась анекдотичной: наш PlaybackCoordinator перед открытием фильма честно выполнял правило «один звук за раз» и просил музыкальный движок встать на паузу. А новый SoloistMusicEngine при получении setPaused(true) проверял, есть ли в очереди активный трек (occurrenceID), видел nil (музыку‑то с момента запуска ещё никто не включал!) и выбрасывал ошибку неподтверждённой команды, которая отменяла открытие видео.

Серия 4. Зомби после SIGKILL и релиз с громкостью 0%

Дальше всплыли ещё два сюрприза.

Во‑первых, проблема процесса‑сироты при жёстком убийстве приложения. Поскольку Kinrox запускает soloist как дочерний процесс, при обычном выходе мы его аккуратно гасим. Но если сам Kinrox.app прибить через kill -9 (SIGKILL), дочерний soloist останется сиротой и продолжит играть музыку из ниоткуда без единого окна в системе. Чтобы это исключить, мы написали крошечный сторожевой процесс на чистом C — KinroxSoloistHost (весит 53 КБ на диске, 0,94 МиБ в памяти и 0% CPU). Ему не передаются никакие ключи или токены — только унаследованный файловый дескриптор пайпа (pipe FD), PID процесса soloist, время его старта и путь к бинарю. Если Kinrox внезапно умирает от SIGKILL, пайп закрывается ядром ОС, KinroxSoloistHost просыпается, сверяет proc_pidpath и время старта (чтобы случайно не убить чужой процесс, если ОС переиспользовала PID) и гасит soloist за 139,7 мс.

Во‑вторых, курьёз со звуком в 0.5.0. Чтобы прогнать тысячи автоматических тестов (включая 30-минутный прогон и стресс‑тест на 1003 команды с реального iPhone) и не оглохнуть, все тесты по правилам репозитория обязаны запускаться с gain = 0 (0% громкости внутри плеера). Мы так привыкли видеть в логах заветное confirmed gain zero, что в релизе 0.5.0 оставили 0% стартовой громкостью по умолчанию для новых установок! Включаешь трек, время бежит, всё работает — а в колонках тишина. В 0.5.1 вернули дефолтные 100% и сохранение громкости между сессиями.

Финальный босс (0.5.2): почему soloist ест 27 МБ, а наш «лёгкий» SwiftUI — 342 МБ и 94% ядра?

Когда в 0.5.1 вся цепочка заработала как часы, мы открыли top, vmmap и sample, чтобы полюбоваться на сэкономленную память.

За картину со стороны soloist можно было только порадоваться: 24,7–27,3 МиБ физического футпринта и 0,7–1,06% одного ядра CPU во время воспроизведения.

А вот сам процесс Kinrox.app в режиме музыки внезапно показал 342 МБ памяти с постоянно мигающим плюсиком (342M+ в top) и на первых замерах отъедал до 93,96% одного ядра CPU (а после первой заплатки на таймер — стабильные 7–10% CPU даже в свёрнутом виде)!

Интересно. В top на macOS суффикс + рядом с памятью (342M+) часто путают с бесконечной утечкой памяти. На самом деле наш 20-минутный прогон под leaks показал ровное плато в 341,9-342,5 МБ и всего 224 байта утечек на 2 миллиона аллокаций. Плюсик в top означает лишь то, что за последнюю секунду между двумя сэмплами аллокатор чуть прирос из‑за постоянного пересоздания временных буферов SwiftUI, которые сборщик тут же освобождает. Но сам этот круговорот (churn) на ровном месте жрал процессор.

Мы сняли профили через sample и heap и нашли две прекрасные причины, о которые спотыкаются многие, кто пишет десктоп под macOS на SwiftUI.

Причина 1. База на 717 КБ превращается в 755 262 ноды AttributeGraph

Смотрим на диск: вся наша SQLite‑база медиатеки (837 альбомов, 880 артистов, 1099 треков и 27 плейлистов) весит 717 КБ (около 2–3 МБ в декодированных структурах Swift). Откуда тогда в куче (Malloc + AttributeGraph) лишние 225 МБ и 2 003 806 живых аллокаций?!

Виновник нашёлся в одной строчке левой колонки SourceColumn.swift. Список источников и всех 880 артистов лежал внутри ScrollView в обычном не‑ленивом VStack вместо LazyVStack!

Что сделал SwiftUI при открытии окна:

  • Жадным образом инстанцировал все 880 строк артистов одновременно, хотя в сайдбаре шириной 220 точек видно от силы 20 штук.

  • В каждой строке артиста было по две кнопки (Button выбора и сердечко лайка MusicHeart) и по два модификатора всплывающей подсказки .help(…).

  • В результате один этот VStack породил 1760 из 1965 всех кнопок Button во всём приложении (89,6%), 84 342 ноды HelpStyleConfiguration и 1760 трекинг‑зон AppKit (_NSTrackingAreaAKViewHelper)!

  • Вдобавок в таблице треков каждая строка MusicTrackRow заворачивала себя в собственный GeometryReader, безусловно вешала .popover(LikePopover) и регистрировала .dropDestination даже там, где перетаскивание запрещено.

Причина 2. Четыре тика в секунду ради секундного таймера

Почему при этом горел CPU? В нижней деке (DeckView) крутился цикл, который каждые 250 мс (4 Гц) обновлял дробное свойство clock.position у @Observable‑модели.

При этом текстовый индикатор времени на экране показывает целые секунды (1:04 → 1:05)! То есть 3 из 4 тиков каждую секунду впустую дёргали SwiftUI ради абсолютно той же строки текста, попутно пересоздавая словари AccessibilityAttachment и заставляя AppKit‑обёртку стандартного слайдера (NSSlider внутри SwiftUI.Slider) инвалидировать layout. А поскольку у текстового таймера не было жёстко зафиксированных границ ширины и высоты, каждый тик раз в 250 мс помечал грязным корневой GeometryReader всего окна и заставлял SwiftUI/AppKit обходить тот самый раздутый граф из 755 262 нод и 2084 трекинг‑зон! И всё это продолжало молотить даже тогда, когда окно плеера было полностью перекрыто другими программами или свёрнуто.

В релизе 0.5.2 мы вылечили оба узла:

  1. Заменили VStack на LazyVStack в колонке источников, убрали лишние .help со строк, вынесли GeometryReader из строк таблицы на уровень контейнера, а .popover и .dropDestination стали подключать только к активной строке.

  2. Заменили тяжёлый SwiftUI.Slider (на базе AppKit NSSlider) на собственный лёгкий слайдер из нативных Capsule + Circle.

  3. Квантовали часы деки до целых секунд (1 Гц вместо 4 Гц), заперли текстовые таймеры в фиксированные фреймы и подписались на NSWindow.didChangeOcclusionStateNotification: когда окно закрыто другими окнами или свёрнуто, UI‑таймер спит совсем (0 пробуждений).

В итоге потребление памяти упало примерно на 150 МБ (до тех самых ~180-196 МБ в рабочем режиме против 2 ГБ у официального клиента), количество живых нод в куче сократилось с 2 миллионов до менее чем 350 тысяч, а нагрузка на CPU упала до <1% одного ядра при открытом окне и ~0% в фоне.

И конечно, куда же без скринсейвера «Матрицы»!

Spotify на 196 МБ вместо 2 ГБ, видеоплеер с пультом на iPhone и скринсейвер «Матрица»: что бывает, когда остались токены

Вы же знаете главное негласное правило разработки: если ты пишешь своё приложение и тебя никто не может остановить, в нём обязан быть скринсейвер «Матрицы». Поэтому в Kinrox (начиная с версии 0.3.0) он есть и на Маке (Screensaver → Matrix), и отдельной кнопкой прямо в пульте на iPhone.

Работает это так: смотришь ты фильм на проекторе, решил отойти за чаем — жмёшь на айфоне Matrix. Плеер сначала дожидается от libmpv аппаратного подтверждения паузы, плавно гасит подсказку о выходе через 3 секунды (причём шевеление мышки подсказку обратно не возвращает, чтобы ничто не портило картинку на стене), и поверх кадра на SwiftUI Canvas в 30 fps начинают бежать зелёные потоки символов с яркими головными глифами. Нажал Esc на Маке или Stop на iPhone — вернулся ровно в тот же кадр на паузе, не вылетая из полноэкранного режима.

Даже со скринсейвером не обошлось без забавного бага: изначально анимация MatrixRainView опиралась на стандартный TimelineView и зависела от scenePhase. Когда мы запустили проверку в окне, вложенном в NSHostingView с фоновым scenePhase (типичная ситуация, когда окно висит на втором экране или проекторе, а ты кликнул в другое приложение), зелёный дождь просто замёрз на первом кадре! Пришлось отвязать таймлайн Canvas от scenePhase, написать регрессионный тест, который рендерит два кадра подряд в фоновом окне и побайтово проверяет, что капли реально сдвинулись, а заодно научить PlayerWindowController перехватывать первый Esc до того, как AppKit успеет свернуть нативный фулскрин.

Замеры и цифры: что получилось в версии 0.5.3

Сведём все замеры в одну таблицу. Окружение: MacBookPro18,2 (Apple Silicon, 32 ГБ RAM, macOS 26.5.2), релизные сборки Kinrox 0.5.0-0.5.3 и Spotify Soloist 1.3.10.36.

Показатель

До / Альтернатива

В Kinrox (0.5.2 — 0.5.3)

Уровень уверенности

Память Spotify.app против связки Kinrox + Soloist

~2000 МБ (Spotify.app)

~180-196 МБ (Kinrox) + 24,7–27,3 МиБ (soloist) + 0,94 МиБ (reaper)

Твёрдо (30-минутный soak‑тест и vmmap)

Нагрузка на CPU при игре музыки

93,96% ядра (первый UI‑прогон) / 7–10% (до 0.5.2)

<1% у Kinrox на экране (~0% в фоне) + 0,95–1,06% у soloist

Твёрдо (sample + proc_pid_rusage)

Потребление soloist на длинной паузе (>20 с)

Вылет с exit code 1 на 60-й секунде

0 МБ RAM, 0% CPU (процесс припаркован, позиция сохранена)

Твёрдо

Фоновые запросы к Web API при игре и на паузе

1800 запросов/ч (игра) и 360 запросов/ч (пауза)

0 периодических запросов (локальные часы + 1 проверка в конце трека)

Твёрдо

Открытие кэшированной медиатеки (10 000 треков)

Полный последовательный обход по сети

40,98 мс (p95) с диска из SQLite, 0 HTTP‑запросов

Твёрдо (синтетический стенд)

Выборка плейлиста на 10 000 треков в памяти

138,61 мс (O(N²) линейный поиск)

2,08 мс (O(1) индекс в MusicCatalog)

Твёрдо

Команды с физического iPhone по LAN (200 циклов, 1003 команды)

9–11 HTTP‑запросов на каждый Next в 0.4.0

Pause p50 72,5 мс, Resume 87,0 мс, Seek 83,0 мс, Next p50 2,10 с (p95 3,095 с, 0 ошибок)

Твёрдо (phone-200.xcresult)

Локализация интерфейса и сайта

Только EN/RU на старте

30 языков (включая RTL: арабский, иврит, персидский, урду)

Твёрдо (автопроверка 348 ключей и 120 страниц)

Что из этого следует: три инженерных паттерна, которые можно унести к себе

Зачем мы вообще так упоролись с генерациями эпох, vmmap и сишным сторожем в домашнем pet‑проекте? Чтобы написать систему, которую понятно поддерживать через два года и которая не разваливается под реальной нагрузкой. Из наших шишек складываются три универсальных правила, которые работают в любом десктопном приложении на macOS:

  1. Если ваш SwiftUI‑интерфейс показывает посекундный таймер или прогресс‑бар, никогда не кормите @Observable‑модель дробными значениями с частотой 4–60 Гц и не оставляйте текстовые счётчики без фиксированного frame(width:height:). Квантуйте обновления по границе реально отображаемой единицы (раз в секунду) и гасите UI‑цикл по NSWindow.didChangeOcclusionStateNotification, когда окно закрыто другими программами.

  2. Когда управляете внешним асинхронным движком (libmpv, Web API или локальным WebSocket‑демоном), никогда не верьте ответу HTTP 204 или факту отправки команды как доказательству перехода в новое состояние. Храните монотонный счётчик эпох (sessionEpoch / commandID) и переводите UI только после подтверждённого события от самого движка, отбрасывая ответы из прошлых эпох.

  3. Если приложение спавнит дочерний демон, обычного terminationHandler недостаточно — он не спасёт при SIGKILL родителя. Маленький бинарь‑сторож на C без секретов, следящий за закрытием унаследованного pipe FD и проверяющий proc_pidpath + время старта перед SIGTERM, решает проблему процессов‑сирот раз и навсегда.

Где это не сработает и какие есть ограничения

Чтобы никто не строил ложных иллюзий, сразу обозначим границы и компромиссы текущей версии (0.5.3):

  • Для музыки нужен Spotify Premium, свой Client ID и ключ Soloist. Из‑за лимитов Spotify Development Mode мы не можем раздать один общий Client ID на всех пользователей. В настройках нужно вставить свой Client ID из кабинета разработчика Spotify, получить персональный ключ на странице Spotify Soloist и один раз спарить движок с телефоном. Видеоплеер и пульт для фильмов работают вообще без интернета и без единого ключа.

  • Сборка Soloist живёт 90 дней. Это ограничение самой Spotify: через 90 дней после сборки бинарь завершается с exit code 10. Kinrox отслеживает этот код и предлагает обновить движок в один клик прямо из настроек.

  • Переключение трека (Next) занимает около 2 секунд (p50 = 2,1 с, p95 = 3,1 с). Поскольку ради защиты от автоплея мы запускаем отдельный —single-track процесс на каждый элемент очереди и каждый раз честно проверяем цифровую подпись бинаря перед стартом, бесшовного gapless‑перехода между треками в первой версии нет.

  • В режиме музыки принципиально нет обложек альбомов. Это осознанное решение в стиле старого Winamp: три плотные текстовые колонки, мгновенная навигация с клавиатуры, сортировка по дате добавления (Added в 0.5.2) и поповеры с метаданными артистов и альбомов по клику.

  • Что с приложением для iPhone: пока проверка в App Store у Apple растягивается на 2–3 недели, готовый подписанный и нотаризованный .dmg выложен только для Mac (Apple Silicon, macOS 14+). Но весь исходный код пульта RemoteiOS лежит в том же репозитории — его можно за пару минут собрать в Xcode и залить на свой iPhone (плюс для тестирования даже без Мака в приложении есть демо‑режим по флагу -KinroxDemo).

ИТОГ.

Это только первая версия (0.5.3), выросшая из желания не вставать с дивана к проектору и сделать наконец такой клиент для Spotify, которым приятно пользоваться самому — быстрый, нативный, со скринсейвером «Матрицы» и без двух гигабайт балласта в оперативке.

Проект открытый (Mac‑плеер под GPL-3.0-only, код iPhone‑пульта открыт для личного некоммерческого использования и форков):

  • Сайт и скачивание DMG для Mac: https://kinrox.app/

  • Исходники, документация и все расследования в docs/RESEARCHES: https://github.com/awaik/Kinrox

Берите, пользуйтесь, форкайте, модифицируйте под себя или закидывайте issue и pull request’ы в репозиторий. А в комментариях расскажите: чем вы сами управляете домашним кинотеатром с Мака и сколько гигабайт у вас прямо сейчас отъедает фоновая музыка?

Ссылка на основную публикацию