xima.music - персональный музыкальный плеер для Android. Полностью офлайн: без аккаунтов, серверов и сетевых запросов. Музыка лежит в памяти телефона, а не внутри APK.
Главное архитектурное решение видно сразу: в вебе нет ни одного <audio>.
Звук, нотификация, экран блокировки и Bluetooth - целиком на стороне Android.
Три слоя и жесткие границы
SolidJS UI
| Tauri IPC (invoke / events)
Rust Core (domain / application / infrastructure)
| tauri-plugin-player
Kotlin / Android (Media3, ExoPlayer, MediaSessionService)| Слой | Технологии |
|---|---|
| Оболочка | Tauri 2 (Android) |
| UI | SolidJS, TypeScript strict, Tailwind v4, HeroUI, Kobalte |
| Ядро | Rust: домен, сервисы, репозитории |
| БД | SQLite через sqlx, FTS5, версионированные миграции |
| Звук | Kotlin, Media3/ExoPlayer, MediaSessionService |
Кто чем владеет:
- Kotlin и только Kotlin владеет воспроизведением: play, pause, seek, next, prev, shuffle, repeat, очередь на уровне плеера, нотификация, аудиофокус.
- Rust владеет данными: библиотека, плейлисты, история, статистика, поиск.
- Solid владеет только представлением и локальным состоянием интерфейса. Источник истины по треку и прогрессу - нативный плеер, он пробрасывает события.
Прямо запрещено: <audio>, HTMLAudioElement и Web Audio API для
воспроизведения библиотеки; SQL-запросы из фронтенда напрямую; хранение
библиотеки в Tauri Store; складывать mp3 в ассеты APK.
В цифрах слои выглядят так: 64 файла на Rust, 60 компонентов на Solid, 15 файлов
на Kotlin в отдельном плагине tauri-plugin-player.
Что умеет
Библиотека собирается из общей медиатеки (MediaStore) и своих папок (SAF): песни, альбомы, исполнители, жанры, папки, 11 сортировок, виртуализованные списки. Очередь с «играть следующим», перестановкой и восстановлением после перезапуска.
Умные плейлисты - самая интересная часть: правила хранятся как данные и компилируются в SQL. 11 типов правил, 7 пресетов из коробки. Поиск через SQLite FTS5, история прослушиваний, счетчики, избранное.
Отдельная деталь: если у альбома нет обложки, рисуется детерминированный
glow-градиент - один и тот же для одного альбома. Палитра взята из
color.xima.work.
Тема только темная. Статистики, Android Auto, сон-таймера, эквалайзера и текстов песен нет сознательно.
Баги, которые построили этот проект
В репозитории лежит docs/BUGS.md, разделенный на три части: починенное,
открытое и непроверенное - то есть места, где багов еще никто не искал. Такая
третья категория встречается редко, а честности в ней больше, чем в первых двух.
Приложение не запускалось, а все проверки были зелеными
Функция run() в src-tauri/src/lib.rs не была помечена
#[cfg_attr(mobile, tauri::mobile_entry_point)]. На Android приложение стартует
из JVM, а не из main, и без этого макроса в .so нет JNI-точки входа, которую
грузит MainActivity.
Коварство в том, что cargo check, cargo test и cargo clippy проходили
полностью зелеными, и библиотека собиралась под aarch64-linux-android без
единого предупреждения. Ошибка вылезала только при упаковке APK.
Комментарий на одну строку съел половину файла
Строка в Kotlin выглядела безобидно:
/** `audio/*`, но не музыка: плейлисты. */Блочные комментарии в Kotlin вложенные. Последовательность /* внутри
текста открыла вложенный комментарий, закрывающая */ закрыла только его, а
внешний остался открытым до конца файла - и проглотил две функции и три
константы. Итог: 7 ошибок «unresolved reference» в трех разных файлах из одной
опечатки.
Обратные кавычки не спасают, лексер их не смотрит. Лечение одно: не писать пару
символов / и * внутри блочного комментария. Проверка баланса по всем
Kotlin-файлам делается одной строкой на awk.
Кириллица в пути к проекту
Проект лежал в папке с русским названием, и это дало три разных симптома одной
причины. Tauri CLI падал с паникой, читая собственный путь: кириллица приезжала
в Rust невалидным UTF-8, и current_exe().unwrap() паниковал. tauri android init не мог определить путь к себе и писал в Gradle-таску node tauri вместо
абсолютного пути. Экспорт LANG и LC_ALL не помогает: искажение
происходит в мосте Node в Rust внутри самого CLI, а не в шелле.
Текущий обход - симлинк с латинским именем, из которого выполняются все команды. Обход, а не решение.
Конфликт версий Kotlin
Сгенерированный Tauri Android-проект пинит Kotlin Gradle plugin 1.9.25, а
kotlinx-coroutines-android тянет kotlin-stdlib до 2.2.10. Компилятор 1.9 не
читает метаданные 2.2, поэтому «не найденными» оказывались trim, takeIf,
emptyList, let - весь stdlib разом.
Папка, которая перезаписывается
src-tauri/gen/android/ живет в .gitignore и перезаписывается при каждом
tauri android init. При этом в ней лежат две правки, без которых сборка не
проходит. То есть после любой регенерации сборка ломается двумя разными
способами, и оба выглядят как загадочные ошибки Kotlin.
По этой же причине подпись APK вынесена отдельным шагом, а не в signingConfig
Gradle: конфиг пришлось бы держать в папке, которая перезаписывается.
Про ключ подписи
Самая дорогая строчка в документации проекта: хранилище ключей и пароль в репозиторий не попадают и восстановлению не подлежат. Потеряли пароль - обновить установленное приложение уже нечем. Новый ключ означает новую подпись, поверх старой сборки она не встает, и пользователям придется удалять приложение вместе с плейлистами и историей.
Это уже случилось между версиями 1.0.1 и 1.1.0.
Сборка
Нужны Node 22+, Rust с таргетом aarch64-linux-android, Android SDK, NDK 27 и
JDK 21.
npm install
npm run typecheck # tsc --noEmit
npm run rust:test # тесты ядра
npm run rust:lint # clippy -D warnings
npm run android:dev # дев-сборка с hot reload на устройстве
npm run android:build -- --apk # release, все ABI, без подписи
npm run release:sign # выравнивание, подпись, проверкаИконка приложения собирается из одного xima.music.png в корне, все остальное
производное. После генерации фон адаптивной иконки приходится возвращать руками:
шаблон Tauri кладет туда белый.
Документация как часть проекта
| Файл | О чем |
|---|---|
AGENTS.md |
как писать код в этом проекте |
docs/CONTRACTS.md |
типы, команды, события, схема БД - источник истины |
docs/FEATURES.md |
что реализовано и в каком статусе |
docs/BUGS.md |
починенное, открытое и непроверенное |
docs/PROPOSALS.md |
что делать дальше |
Разделение на «контракты» и «фичи» тут не формальность. Контракты - это то, о чем договорились три слоя между собой, и менять их в одном слое молча нельзя.
