scaffold sMusic: Tauri v2 player core (audio/library/tags/discord/tray) + cover relay + CI

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-20 14:36:43 +05:00
commit c6d313db17
43 changed files with 9016 additions and 0 deletions

33
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: CI
on:
push:
pull_request:
jobs:
check:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: cargo check --manifest-path src-tauri/Cargo.toml
- run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
- run: cargo test --manifest-path src-tauri/Cargo.toml --lib
- run: cargo build --manifest-path src-tauri/Cargo.toml

34
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx tauri build
- uses: actions/upload-artifact@v4
with:
name: smusic-installer
path: src-tauri/target/release/bundle/nsis/*.exe

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/node_modules/
/src-tauri/target/
/src-tauri/gen/
.claude/
*.env
.env.*

23
README.md Normal file
View file

@ -0,0 +1,23 @@
# sMusic (Tauri)
Локальный плеер под FLAC/WAV/MP3, замена AppleMusic + отдельного `applemusic-discord-rpc` моста. На Tauri (Rust + голый HTML/CSS/JS, без фреймворков и билд-шага), чтобы не жрать проц как Electron.
Фичи:
- играет свою музыку (сам закидываешь папку с файлами), без стриминга и скачивания
- реальное качество звука — без пересжатия/downsampling
- Discord Rich Presence: "слушает", название трека без автора, обложка, прогресс-бар
- сворачивается в трей
- дизайн в стиле btwincel.pw — чёрный минимализм, JetBrains Mono, зерно, анимации
## Разработка
```
npm install
npm run tauri dev
```
## Структура
- `src/` — фронт, голый JS без сборки
- `src-tauri/` — бэкенд на Rust: аудио-движок (rodio), теги (lofty), трей, Discord RPC
- `cover-relay/` — маленький FastAPI-сервис для обложек в Discord (деплоится отдельно на финка2)

59
cover-relay/main.py Normal file
View file

@ -0,0 +1,59 @@
import os
import time
from pathlib import Path
from fastapi import FastAPI, Header, HTTPException, Request, Response
SECRET = os.environ.get("SMUSIC_COVER_SECRET", "")
DATA_FILE = Path(os.environ.get("SMUSIC_COVER_FILE", "/opt/smusic-cover/cover.jpg"))
RATE_LIMIT_PER_MINUTE = 20
app = FastAPI()
current_cover_bytes = b""
put_history = {}
if DATA_FILE.exists():
current_cover_bytes = DATA_FILE.read_bytes()
def check_rate_limit(ip):
now = time.time()
hits = [t for t in put_history.get(ip, []) if now - t < 60]
hits.append(now)
put_history[ip] = hits
if len(hits) > RATE_LIMIT_PER_MINUTE:
raise HTTPException(status_code=429, detail="too many uploads")
@app.put("/cover")
async def upload_cover(request: Request, x_smusic_key: str = Header(default="")):
if not SECRET or x_smusic_key != SECRET:
raise HTTPException(status_code=401, detail="bad key")
client_ip = request.client.host if request.client else "unknown"
check_rate_limit(client_ip)
body = await request.body()
if len(body) == 0 or len(body) > 5 * 1024 * 1024:
raise HTTPException(status_code=400, detail="bad image size")
global current_cover_bytes
current_cover_bytes = body
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
DATA_FILE.write_bytes(body)
return {"ok": True}
@app.get("/cover")
async def get_cover():
if not current_cover_bytes:
raise HTTPException(status_code=404, detail="no cover yet")
return Response(
content=current_cover_bytes,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400, immutable"},
)

View file

@ -0,0 +1,19 @@
server {
listen 443 ssl;
server_name rpc.savsis.xyz;
ssl_certificate /etc/letsencrypt/live/rpc.savsis.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rpc.savsis.xyz/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
server {
listen 80;
server_name rpc.savsis.xyz;
return 301 https://$host$request_uri;
}

View file

@ -0,0 +1,2 @@
fastapi
uvicorn

View file

@ -0,0 +1,15 @@
[Unit]
Description=sMusic cover art relay
After=network.target
[Service]
Type=simple
User=smusiccover
WorkingDirectory=/opt/smusic-cover
Environment=SMUSIC_COVER_SECRET=CHANGE_ME
Environment=SMUSIC_COVER_FILE=/opt/smusic-cover/cover.jpg
ExecStart=/opt/smusic-cover/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8090
Restart=on-failure
[Install]
WantedBy=multi-user.target

255
package-lock.json generated Normal file
View file

@ -0,0 +1,255 @@
{
"name": "smusic-tauri",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smusic-tauri",
"version": "0.1.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.5.tgz",
"integrity": "sha512-YbeJ6tctNoo40purO4u3Eeo3RVqmsjJr0NoKQRu+4pvO4gMLzkjta7x0k96SxSLmx+rQ+8um1ThOlw+HLpgFug==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.5",
"@tauri-apps/cli-darwin-x64": "2.11.5",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.5",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.5",
"@tauri-apps/cli-linux-arm64-musl": "2.11.5",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.5",
"@tauri-apps/cli-linux-x64-gnu": "2.11.5",
"@tauri-apps/cli-linux-x64-musl": "2.11.5",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.5",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.5",
"@tauri-apps/cli-win32-x64-msvc": "2.11.5"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.5.tgz",
"integrity": "sha512-tggOiVOohjIHdiElbjeBfB41s6cmfsq+ZQ0PX0fEtCdsDdZ9cCi54e5gNK7tdPbR6JbdDe7RA4kbQdyyBUUk/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.5.tgz",
"integrity": "sha512-SrO+KCbqvG1IvVPdlzQX4GEGZRQDISrmDY9bGcW68DLVFBCC06H+Loz6oyUZETUxsSMR6B4oDfvbQVgD4hxsqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.5.tgz",
"integrity": "sha512-qLrOaCa8hLD5QVPQRjAgOXvhlTFKXmTliVF773GgvADYcWZSdmncnF5zBFAkdu1uQK8KfQbDlBUwqQYZqBfBRQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.5.tgz",
"integrity": "sha512-Tq32xpQjiQEdIrvwDq7lwHhXemn070CXWdbCvr9rdi9rsv9NFTbQ6izVSIfNE+59QaerxuwtUtZ5IxDNWaoYBw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.5.tgz",
"integrity": "sha512-+OdWKj5Aq+C8HXypQ4t926V8cpVnc0ZcJIdhTXWjVAJHkaNuo8EuQR6KRZKHWIbJJgKdMPCDosvwzIFaBBqHKQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.5.tgz",
"integrity": "sha512-Q0V2hjpllyDW8dWQZSsITuQDfcY9BpBZuhXkMa2T5AFLZAn+FJPXQSyvnxWNVxRowdQ59Xn0QVtGm6HEHNUEvw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.5.tgz",
"integrity": "sha512-Xz1s87gFjZJZMQKAhLa77YeiCJM6yVog3f9OxDgVd/KxDIoB1dA5V2xOnMM+SRkOGk9juwIBqesVAjV0kUB0DA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.5.tgz",
"integrity": "sha512-6A88wqAZXZHgMaBrGiy/Bpa4GgUnftXM7uquKOeOXCuK78/XdBxEECicK/1oHgrT179XBFOaA6lQEHysjRmGig==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.5.tgz",
"integrity": "sha512-sRGgF/ObRfLcS3PtUdyTbJtkGq3E+PNZTknvAURwMNUtHw9DU/BF6S5si43y9lS5yEO9bWJCA+GGYvnn6cphQw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.5.tgz",
"integrity": "sha512-bHOv/yxqfKpI7ioIFQi5wOfZ7jkbPzJD/UhXY52I/hrmE3qNh71cohIPyqZB5GQn3LZeJWdCUOqmIg/w6XX+Wg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.5.tgz",
"integrity": "sha512-vaaOmavqdCeS7oMib2banBZemgrC5v54tSkwoBV+bxrXQZhQZrOwE2EO2i4x+CHNtvQUM/6bSs3iymZMAsycWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.3.tgz",
"integrity": "sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
}
}
}

16
package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "smusic-tauri",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2"
}
}

6498
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

24
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "smusic-tauri"
version = "0.1.0"
edition = "2021"
[lib]
name = "smusic_tauri_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "protocol-asset"] }
tauri-plugin-dialog = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = "=0.22.2"
lofty = "0.25"
image = "0.25"
walkdir = "2"
blake3 = "1"
ureq = "2"

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "sMusic main window capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-minimize",
"core:window:allow-close",
"core:window:allow-set-focus",
"core:window:allow-is-visible",
"core:window:allow-start-dragging"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

BIN
src-tauri/icons/source.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

103
src-tauri/src/audio.rs Normal file
View file

@ -0,0 +1,103 @@
use std::fs::File;
use std::io::BufReader;
use std::time::Duration;
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player};
use crate::state::PlayerState;
pub fn create_device() -> MixerDeviceSink {
DeviceSinkBuilder::open_default_sink().expect("open default audio device")
}
pub fn load_and_play(state: &mut PlayerState, index: usize) {
let track = match state.queue.get(index) {
Some(t) => t.clone(),
None => return,
};
let file = match File::open(&track.path) {
Ok(f) => f,
Err(_) => return,
};
let decoder = match Decoder::new(BufReader::new(file)) {
Ok(d) => d,
Err(_) => return,
};
let device = match state.device.as_ref() {
Some(d) => d,
None => return,
};
let player = Player::connect_new(device.mixer());
player.set_volume(state.volume);
player.append(decoder);
state.player = Some(player);
state.current_track = Some(track);
state.queue_index = Some(index);
}
pub fn pause(state: &PlayerState) {
if let Some(player) = &state.player {
player.pause();
}
}
pub fn resume(state: &PlayerState) {
if let Some(player) = &state.player {
player.play();
}
}
pub fn seek(state: &PlayerState, position_ms: u64) {
if let Some(player) = &state.player {
let _ = player.try_seek(Duration::from_millis(position_ms));
}
}
pub fn set_volume(state: &mut PlayerState, volume: f32) {
state.volume = volume;
if let Some(player) = &state.player {
player.set_volume(volume);
}
}
pub fn is_finished(state: &PlayerState) -> bool {
state.current_track.is_some() && state.player.as_ref().is_some_and(|p| p.empty())
}
pub fn advance(state: &mut PlayerState) {
match state.queue_index {
Some(i) if i + 1 < state.queue.len() => {
load_and_play(state, i + 1);
}
_ => {
state.player = None;
state.current_track = None;
state.queue_index = None;
}
}
}
pub fn previous(state: &mut PlayerState) {
let position_ms = state
.player
.as_ref()
.map_or(0, |p| p.get_pos().as_millis() as u64);
if position_ms > 3000 {
if let Some(i) = state.queue_index {
load_and_play(state, i);
}
return;
}
match state.queue_index {
Some(i) if i > 0 => load_and_play(state, i - 1),
Some(i) => load_and_play(state, i),
None => {}
}
}

181
src-tauri/src/commands.rs Normal file
View file

@ -0,0 +1,181 @@
use tauri::{AppHandle, Emitter, Manager, State};
use crate::state::{AppState, PlaybackState, Track};
use crate::{audio, config, discord, library};
#[tauri::command]
pub fn get_state(state: State<AppState>) -> PlaybackState {
state.player.lock().unwrap().snapshot()
}
#[tauri::command]
pub fn get_library(state: State<AppState>) -> serde_json::Value {
let player = state.player.lock().unwrap();
serde_json::json!({
"tracks": player.tracks,
"folders": player.library_folders,
})
}
#[tauri::command]
pub fn pick_library_folder(app: AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
app.dialog()
.file()
.blocking_pick_folder()
.map(|p| p.to_string())
}
#[tauri::command]
pub fn add_library_folder(app: AppHandle, state: State<AppState>, path: String) {
{
let mut player = state.player.lock().unwrap();
if !player.library_folders.contains(&path) {
player.library_folders.push(path);
}
}
persist(&app, &state);
}
#[tauri::command]
pub fn remove_library_folder(app: AppHandle, state: State<AppState>, path: String) {
{
let mut player = state.player.lock().unwrap();
player.library_folders.retain(|f| f != &path);
}
persist(&app, &state);
}
#[tauri::command]
pub fn rescan_library(app: AppHandle, state: State<AppState>) -> Vec<Track> {
let covers_dir = app.path().app_data_dir().unwrap().join("covers");
let _ = std::fs::create_dir_all(&covers_dir);
let folders = state.player.lock().unwrap().library_folders.clone();
let tracks = library::scan(&folders, &covers_dir);
{
let mut player = state.player.lock().unwrap();
player.tracks = tracks.clone();
}
persist(&app, &state);
tracks
}
#[tauri::command]
pub fn play_track(app: AppHandle, state: State<AppState>, track_id: String) {
{
let mut player = state.player.lock().unwrap();
if !player.queue.iter().any(|t| t.id == track_id) {
player.queue = player.tracks.clone();
}
if let Some(index) = player.queue.iter().position(|t| t.id == track_id) {
audio::load_and_play(&mut player, index);
}
}
broadcast(&app, &state);
persist(&app, &state);
}
#[tauri::command]
pub fn play(app: AppHandle, state: State<AppState>) {
audio::resume(&state.player.lock().unwrap());
broadcast(&app, &state);
}
#[tauri::command]
pub fn pause(app: AppHandle, state: State<AppState>) {
audio::pause(&state.player.lock().unwrap());
broadcast(&app, &state);
}
#[tauri::command]
pub fn next_track(app: AppHandle, state: State<AppState>) {
{
let mut player = state.player.lock().unwrap();
audio::advance(&mut player);
}
broadcast(&app, &state);
}
#[tauri::command]
pub fn previous_track(app: AppHandle, state: State<AppState>) {
{
let mut player = state.player.lock().unwrap();
audio::previous(&mut player);
}
broadcast(&app, &state);
}
#[tauri::command]
pub fn seek(app: AppHandle, state: State<AppState>, position_ms: u64) {
audio::seek(&state.player.lock().unwrap(), position_ms);
broadcast(&app, &state);
}
#[tauri::command]
pub fn set_volume(app: AppHandle, state: State<AppState>, volume: f32) {
{
let mut player = state.player.lock().unwrap();
audio::set_volume(&mut player, volume);
}
broadcast(&app, &state);
persist(&app, &state);
}
#[tauri::command]
pub fn set_queue(state: State<AppState>, track_ids: Vec<String>) {
let mut player = state.player.lock().unwrap();
let tracks = player.tracks.clone();
player.queue = track_ids
.iter()
.filter_map(|id| tracks.iter().find(|t| &t.id == id).cloned())
.collect();
}
#[tauri::command]
pub fn set_discord_enabled(app: AppHandle, state: State<AppState>, enabled: bool) {
{
let mut player = state.player.lock().unwrap();
player.discord_enabled = enabled;
}
if !enabled {
discord::clear(&state.discord);
}
persist(&app, &state);
broadcast(&app, &state);
}
#[tauri::command]
pub fn hide_window(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
pub fn broadcast(app: &AppHandle, state: &State<AppState>) {
let (snapshot, discord_enabled) = {
let player = state.player.lock().unwrap();
(player.snapshot(), player.discord_enabled)
};
let _ = app.emit("playback-state-changed", &snapshot);
if discord_enabled {
discord::update(&state.discord, &snapshot);
}
}
fn persist(app: &AppHandle, state: &State<AppState>) {
let player = state.player.lock().unwrap();
let cfg = config::Config {
library_folders: player.library_folders.clone(),
tracks: player.tracks.clone(),
volume: player.volume,
last_track_id: player.current_track.as_ref().map(|t| t.id.clone()),
discord_enabled: player.discord_enabled,
};
if let Ok(app_data_dir) = app.path().app_data_dir() {
config::save(&app_data_dir, &cfg);
}
}

46
src-tauri/src/config.rs Normal file
View file

@ -0,0 +1,46 @@
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::state::Track;
#[derive(Serialize, Deserialize)]
pub struct Config {
pub library_folders: Vec<String>,
pub tracks: Vec<Track>,
pub volume: f32,
pub last_track_id: Option<String>,
pub discord_enabled: bool,
}
impl Default for Config {
fn default() -> Self {
Config {
library_folders: Vec::new(),
tracks: Vec::new(),
volume: 1.0,
last_track_id: None,
discord_enabled: true,
}
}
}
fn config_path(app_data_dir: &Path) -> PathBuf {
app_data_dir.join("config.json")
}
pub fn load(app_data_dir: &Path) -> Config {
let path = config_path(app_data_dir);
match fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
Err(_) => Config::default(),
}
}
pub fn save(app_data_dir: &Path, config: &Config) {
let path = config_path(app_data_dir);
if let Ok(text) = serde_json::to_string_pretty(config) {
let _ = fs::write(path, text);
}
}

177
src-tauri/src/discord.rs Normal file
View file

@ -0,0 +1,177 @@
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use crate::state::PlaybackState;
const CLIENT_ID: &str = "1543000586209853490";
const FALLBACK_IMAGE: &str = "https://mail.savsis.xyz/discord-rpc/appicon.png";
const PLAY_ICON: &str = "https://mail.savsis.xyz/discord-rpc/play_bw.png";
const PAUSE_ICON: &str = "https://mail.savsis.xyz/discord-rpc/pause_bw.png";
const RECONNECT_INTERVAL: Duration = Duration::from_secs(15);
struct Connection {
pipe: Option<File>,
last_attempt: Instant,
}
pub struct DiscordHandle {
conn: Mutex<Connection>,
}
impl DiscordHandle {
pub fn new() -> Self {
DiscordHandle {
conn: Mutex::new(Connection {
pipe: None,
last_attempt: Instant::now() - RECONNECT_INTERVAL,
}),
}
}
}
fn try_connect() -> Option<File> {
for i in 0..10 {
let path = format!(r"\\.\pipe\discord-ipc-{}", i);
let mut file = match OpenOptions::new().read(true).write(true).open(&path) {
Ok(f) => f,
Err(_) => continue,
};
let handshake = json!({ "v": 1, "client_id": CLIENT_ID });
if send_frame(&mut file, 0, &handshake).is_err() {
continue;
}
match read_frame(&mut file) {
Ok(response) if response.get("evt").and_then(|v| v.as_str()) == Some("READY") => {
return Some(file);
}
_ => continue,
}
}
None
}
fn send_frame(file: &mut File, opcode: i32, payload: &Value) -> std::io::Result<()> {
let body = serde_json::to_vec(payload)?;
let mut header = Vec::with_capacity(8);
header.extend_from_slice(&opcode.to_le_bytes());
header.extend_from_slice(&(body.len() as i32).to_le_bytes());
file.write_all(&header)?;
file.write_all(&body)?;
file.flush()?;
Ok(())
}
fn read_frame(file: &mut File) -> std::io::Result<Value> {
let mut header = [0u8; 8];
file.read_exact(&mut header)?;
let length = i32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize;
let mut body = vec![0u8; length];
file.read_exact(&mut body)?;
Ok(serde_json::from_slice(&body).unwrap_or(Value::Null))
}
fn ensure_connected(conn: &mut Connection) -> bool {
if conn.pipe.is_some() {
return true;
}
if conn.last_attempt.elapsed() < RECONNECT_INTERVAL {
return false;
}
conn.last_attempt = Instant::now();
conn.pipe = try_connect();
conn.pipe.is_some()
}
fn truncate(value: &str) -> String {
if value.is_empty() {
return " ".to_string();
}
if value.chars().count() <= 128 {
value.to_string()
} else {
value.chars().take(128).collect()
}
}
fn build_activity(state: &PlaybackState) -> Option<Value> {
let track = state.current_track.as_ref()?;
let large_text = if track.album.is_empty() {
&track.title
} else {
&track.album
};
let mut activity = json!({
"type": 2,
"details": truncate(&track.title),
"assets": {
"large_image": FALLBACK_IMAGE,
"large_text": truncate(large_text),
"small_image": if state.is_playing { PLAY_ICON } else { PAUSE_ICON },
"small_text": if state.is_playing { "Playing" } else { "Paused" },
}
});
if state.is_playing && track.duration_ms > 0 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let elapsed_secs = (state.position_ms / 1000) as i64;
let total_secs = (track.duration_ms / 1000) as i64;
let start = now - elapsed_secs;
let end = start + total_secs;
activity["timestamps"] = json!({ "start": start, "end": end });
}
Some(activity)
}
fn make_nonce() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{:x}", nanos)
}
fn send_activity(conn: &mut Connection, activity: Value) {
let payload = json!({
"cmd": "SET_ACTIVITY",
"args": { "pid": std::process::id(), "activity": activity },
"nonce": make_nonce(),
});
if let Some(file) = conn.pipe.as_mut() {
if send_frame(file, 1, &payload).is_err() || read_frame(file).is_err() {
conn.pipe = None;
}
}
}
pub fn update(handle: &DiscordHandle, state: &PlaybackState) {
let mut conn = handle.conn.lock().unwrap();
if !ensure_connected(&mut conn) {
return;
}
let activity = match build_activity(state) {
Some(a) => a,
None => Value::Null,
};
send_activity(&mut conn, activity);
}
pub fn clear(handle: &DiscordHandle) {
let mut conn = handle.conn.lock().unwrap();
if conn.pipe.is_none() {
return;
}
send_activity(&mut conn, Value::Null);
}

181
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,181 @@
mod audio;
mod commands;
mod config;
mod discord;
mod library;
mod state;
mod tags;
mod tray;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{Manager, State};
use state::{AppState, PlayerState};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let app_handle = app.handle().clone();
let app_data_dir = app.path().app_data_dir().expect("app data dir");
std::fs::create_dir_all(&app_data_dir).ok();
std::fs::create_dir_all(app_data_dir.join("covers")).ok();
let cfg = config::load(&app_data_dir);
let device = audio::create_device();
let player_state = PlayerState {
device: Some(device),
player: None,
current_track: None,
queue: Vec::new(),
queue_index: None,
volume: cfg.volume,
discord_enabled: cfg.discord_enabled,
library_folders: cfg.library_folders,
tracks: cfg.tracks,
};
app.manage(AppState {
player: Mutex::new(player_state),
discord: discord::DiscordHandle::new(),
});
tray::build(app)?;
let poll_handle = app_handle.clone();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_millis(250));
let state: State<AppState> = poll_handle.state();
let finished = {
let player = state.player.lock().unwrap();
audio::is_finished(&player)
};
if finished {
{
let mut player = state.player.lock().unwrap();
audio::advance(&mut player);
}
commands::broadcast(&poll_handle, &state);
}
});
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.invoke_handler(tauri::generate_handler![
commands::get_state,
commands::get_library,
commands::pick_library_folder,
commands::add_library_folder,
commands::remove_library_folder,
commands::rescan_library,
commands::play_track,
commands::play,
commands::pause,
commands::next_track,
commands::previous_track,
commands::seek,
commands::set_volume,
commands::set_queue,
commands::set_discord_enabled,
commands::hide_window,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod smoke_tests {
use crate::{audio, library, state, tags};
fn write_test_wav(path: &std::path::Path) {
let sample_rate: u32 = 8000;
let num_samples = sample_rate;
let mut data = Vec::with_capacity(num_samples as usize * 2);
for i in 0..num_samples {
let t = i as f32 / sample_rate as f32;
let sample = (t * 440.0 * 2.0 * std::f32::consts::PI).sin() * 8000.0;
data.extend_from_slice(&(sample as i16).to_le_bytes());
}
let byte_rate = sample_rate * 2;
let data_len = data.len() as u32;
let mut wav = Vec::new();
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36 + data_len).to_le_bytes());
wav.extend_from_slice(b"WAVE");
wav.extend_from_slice(b"fmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&sample_rate.to_le_bytes());
wav.extend_from_slice(&byte_rate.to_le_bytes());
wav.extend_from_slice(&2u16.to_le_bytes());
wav.extend_from_slice(&16u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_len.to_le_bytes());
wav.extend_from_slice(&data);
std::fs::write(path, wav).unwrap();
}
#[test]
fn scan_reads_tags_and_plays() {
let dir = std::env::temp_dir().join("smusic_smoke_test");
std::fs::create_dir_all(&dir).unwrap();
let wav_path = dir.join("tone.wav");
write_test_wav(&wav_path);
let track = tags::read_track(&wav_path).expect("read_track should succeed on a valid wav");
assert_eq!(track.title, "tone");
assert!(track.duration_ms >= 900 && track.duration_ms <= 1100);
let covers_dir = dir.join("covers");
std::fs::create_dir_all(&covers_dir).unwrap();
let tracks = library::scan(&[dir.to_string_lossy().to_string()], &covers_dir);
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0].title, "tone");
let device = audio::create_device();
let mut player_state = state::PlayerState {
device: Some(device),
player: None,
current_track: None,
queue: tracks.clone(),
queue_index: None,
volume: 0.0,
discord_enabled: false,
library_folders: Vec::new(),
tracks: tracks.clone(),
};
audio::load_and_play(&mut player_state, 0);
assert!(
player_state.player.is_some(),
"player should be created after load_and_play"
);
std::thread::sleep(std::time::Duration::from_millis(200));
let pos = player_state.player.as_ref().unwrap().get_pos();
assert!(pos.as_millis() > 0, "playback position should advance");
std::fs::remove_dir_all(&dir).ok();
}
}

63
src-tauri/src/library.rs Normal file
View file

@ -0,0 +1,63 @@
use std::path::Path;
use walkdir::WalkDir;
use crate::state::Track;
use crate::tags;
fn is_audio_file(path: &Path) -> bool {
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
{
Some(ext) => ext == "flac" || ext == "wav" || ext == "mp3",
None => false,
}
}
pub fn scan(folders: &[String], covers_dir: &Path) -> Vec<Track> {
let mut tracks = Vec::new();
for folder in folders {
for entry in WalkDir::new(folder).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() || !is_audio_file(path) {
continue;
}
let scanned = match tags::read_track(path) {
Some(s) => s,
None => continue,
};
let id = blake3::hash(path.to_string_lossy().as_bytes())
.to_hex()
.to_string();
let cover_path = match &scanned.cover_bytes {
Some(bytes) => {
let out_path = covers_dir.join(format!("{}.jpg", id));
if tags::save_cover(bytes, &out_path) {
Some(out_path.to_string_lossy().to_string())
} else {
None
}
}
None => None,
};
tracks.push(Track {
id,
path: path.to_string_lossy().to_string(),
title: scanned.title,
artist: scanned.artist,
album: scanned.album,
duration_ms: scanned.duration_ms,
cover_path,
});
}
}
tracks
}

5
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
smusic_tauri_lib::run();
}

62
src-tauri/src/state.rs Normal file
View file

@ -0,0 +1,62 @@
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::discord::DiscordHandle;
#[derive(Clone, Serialize, Deserialize)]
pub struct Track {
pub id: String,
pub path: String,
pub title: String,
pub artist: String,
pub album: String,
pub duration_ms: u64,
pub cover_path: Option<String>,
}
#[derive(Clone, Serialize)]
pub struct PlaybackState {
pub current_track: Option<Track>,
pub is_playing: bool,
pub position_ms: u64,
pub volume: f32,
pub queue: Vec<Track>,
pub queue_index: Option<usize>,
}
pub struct PlayerState {
pub device: Option<rodio::MixerDeviceSink>,
pub player: Option<rodio::Player>,
pub current_track: Option<Track>,
pub queue: Vec<Track>,
pub queue_index: Option<usize>,
pub volume: f32,
pub discord_enabled: bool,
pub library_folders: Vec<String>,
pub tracks: Vec<Track>,
}
impl PlayerState {
pub fn snapshot(&self) -> PlaybackState {
let is_playing = self.player.as_ref().is_some_and(|p| !p.is_paused());
let position_ms = self
.player
.as_ref()
.map_or(0, |p| p.get_pos().as_millis() as u64);
PlaybackState {
current_track: self.current_track.clone(),
is_playing,
position_ms,
volume: self.volume,
queue: self.queue.clone(),
queue_index: self.queue_index,
}
}
}
pub struct AppState {
pub player: Mutex<PlayerState>,
pub discord: DiscordHandle,
}

60
src-tauri/src/tags.rs Normal file
View file

@ -0,0 +1,60 @@
use std::path::{Path, PathBuf};
use lofty::prelude::*;
use lofty::probe::Probe;
pub struct ScannedTrack {
pub title: String,
pub artist: String,
pub album: String,
pub duration_ms: u64,
pub cover_bytes: Option<Vec<u8>>,
}
pub fn read_track(path: &Path) -> Option<ScannedTrack> {
let tagged_file = Probe::open(path).ok()?.read().ok()?;
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
let fallback_title = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let title = tag
.and_then(|t| t.title())
.map(|c| c.to_string())
.filter(|s| !s.is_empty())
.unwrap_or(fallback_title);
let artist = tag
.and_then(|t| t.artist())
.map(|c| c.to_string())
.unwrap_or_default();
let album = tag
.and_then(|t| t.album())
.map(|c| c.to_string())
.unwrap_or_default();
let duration_ms = tagged_file.properties().duration().as_millis() as u64;
let cover_bytes = tag
.and_then(|t| t.pictures().first())
.map(|pic| pic.data().to_vec());
Some(ScannedTrack {
title,
artist,
album,
duration_ms,
cover_bytes,
})
}
pub fn save_cover(cover_bytes: &[u8], out_path: &PathBuf) -> bool {
let img = match image::load_from_memory(cover_bytes) {
Ok(i) => i,
Err(_) => return false,
};
let resized = img.thumbnail(600, 600);
resized
.save_with_format(out_path, image::ImageFormat::Jpeg)
.is_ok()
}

41
src-tauri/src/tray.rs Normal file
View file

@ -0,0 +1,41 @@
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager,
};
pub fn build(app: &tauri::App) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Показать", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().cloned().expect("app icon missing"))
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"quit" => app.exit(0),
"show" => show_main_window(app),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}

43
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,43 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "sMusic",
"version": "0.1.0",
"identifier": "xyz.savsis.smusic",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "sMusic",
"width": 420,
"height": 720,
"minWidth": 360,
"minHeight": 560,
"resizable": true,
"decorations": false,
"transparent": false,
"visible": true
}
],
"security": {
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/covers/*"]
}
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

78
src/animations.js Normal file
View file

@ -0,0 +1,78 @@
function setCoverArt(url) {
const a = document.getElementById("cover-a");
const b = document.getElementById("cover-b");
const front = a.classList.contains("is-front") ? a : b;
const back = front === a ? b : a;
back.src = url || "";
back.classList.add("is-front");
front.classList.remove("is-front");
}
function setPlayIconState(isPlaying) {
const btn = document.getElementById("btn-play");
if (isPlaying) {
btn.classList.add("is-playing");
} else {
btn.classList.remove("is-playing");
}
}
function staggerIn(elements, baseDelayMs, stepMs, maxStaggered) {
elements.forEach((el, i) => {
if (i < maxStaggered) {
el.style.animationDelay = baseDelayMs + i * stepMs + "ms";
} else {
el.style.animationDelay = "0ms";
}
});
}
const IDLE_LINES = ["ничего не играет", "выбери трек в библиотеке", "тишина"];
let idleTypewriterTimer = null;
function startIdleTypewriter() {
const el = document.getElementById("idle-typewriter");
if (!el) return;
stopIdleTypewriter();
let lineIndex = 0;
let charIndex = 0;
let deleting = false;
function tick() {
const current = IDLE_LINES[lineIndex];
if (!deleting) {
charIndex++;
el.textContent = current.slice(0, charIndex);
if (charIndex === current.length) {
idleTypewriterTimer = setTimeout(() => {
deleting = true;
tick();
}, 1600);
return;
}
idleTypewriterTimer = setTimeout(tick, 55);
} else {
charIndex--;
el.textContent = current.slice(0, charIndex);
if (charIndex === 0) {
deleting = false;
lineIndex = (lineIndex + 1) % IDLE_LINES.length;
idleTypewriterTimer = setTimeout(tick, 400);
return;
}
idleTypewriterTimer = setTimeout(tick, 30);
}
}
tick();
}
function stopIdleTypewriter() {
if (idleTypewriterTimer) {
clearTimeout(idleTypewriterTimer);
idleTypewriterTimer = null;
}
}

Binary file not shown.

Binary file not shown.

101
src/index.html Normal file
View file

@ -0,0 +1,101 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>sMusic</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<svg class="grain">
<filter id="noise">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch" result="n" />
<feColorMatrix in="n" type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.05 0" />
</filter>
<rect width="100%" height="100%" fill="transparent" filter="url(#noise)" />
</svg>
<div class="bg-grid"></div>
<div class="corner corner-tl"></div>
<div class="corner corner-tr"></div>
<div class="corner corner-bl"></div>
<div class="corner corner-br"></div>
<div class="titlebar" data-tauri-drag-region>
<span class="titlebar-label" data-tauri-drag-region>sMusic</span>
<div class="titlebar-buttons">
<button id="btn-hide" title="Свернуть в трей">–</button>
</div>
</div>
<nav class="tabs">
<button class="tab active" data-view="now-playing">СЕЙЧАС ИГРАЕТ</button>
<button class="tab" data-view="library">БИБЛИОТЕКА</button>
<button class="tab" data-view="settings">НАСТРОЙКИ</button>
</nav>
<main>
<section id="view-now-playing" class="view active">
<div class="cover-stack">
<img id="cover-a" class="cover-layer" src="" alt="" />
<img id="cover-b" class="cover-layer" src="" alt="" />
</div>
<div class="now-playing-info">
<div id="track-title" class="track-title">ничего не играет</div>
<div id="track-artist" class="track-artist"></div>
</div>
<div class="progress">
<span id="time-elapsed" class="time">0:00</span>
<div id="progress-track" class="progress-track">
<div id="progress-fill" class="progress-fill"></div>
<div id="progress-thumb" class="progress-thumb"></div>
</div>
<span id="time-remaining" class="time">-0:00</span>
</div>
<div class="transport">
<button id="btn-prev" class="transport-btn" title="Предыдущий">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h2v14H6zM20 5v14l-11-7z" /></svg>
</button>
<button id="btn-play" class="transport-btn transport-btn-main" title="Пуск/пауза">
<svg class="play-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4l14 8-14 8z" /></svg>
<svg class="pause-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z" /></svg>
</button>
<button id="btn-next" class="transport-btn" title="Следующий">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 5h2v14h-2zM4 5v14l11-7z" /></svg>
</button>
</div>
<div class="volume">
<input id="volume-slider" type="range" min="0" max="100" value="100" />
</div>
</section>
<section id="view-library" class="view">
<div class="library-toolbar">
<button id="btn-add-folder">+ ПАПКА</button>
<button id="btn-rescan">ОБНОВИТЬ</button>
<input id="library-search" type="text" placeholder="поиск" />
</div>
<div id="library-list" class="library-list"></div>
<div id="library-empty" class="library-empty active">
<span><span id="idle-typewriter" class="term"></span><span class="term-cursor">_</span></span>
</div>
</section>
<section id="view-settings" class="view">
<div class="settings-row">
<label>Discord Rich Presence</label>
<input id="toggle-discord" type="checkbox" checked />
</div>
<div class="settings-row" style="align-items: flex-start; flex-direction: column; gap: 8px">
<label>Папки библиотеки</label>
<div id="folder-list" style="width: 100%"></div>
</div>
</section>
</main>
<script src="tauri-bridge.js"></script>
<script src="player.js"></script>
<script src="library.js"></script>
<script src="views.js"></script>
<script src="animations.js"></script>
<script src="main.js"></script>
</body>
</html>

92
src/library.js Normal file
View file

@ -0,0 +1,92 @@
let allTracks = [];
let libraryFolders = [];
let currentSearch = "";
function matchesSearch(track, query) {
if (!query) return true;
const haystack = (track.title + " " + track.artist + " " + track.album).toLowerCase();
return haystack.includes(query.toLowerCase());
}
function renderLibraryList() {
const listEl = document.getElementById("library-list");
const emptyEl = document.getElementById("library-empty");
const filtered = allTracks.filter((t) => matchesSearch(t, currentSearch));
listEl.innerHTML = "";
if (filtered.length === 0) {
emptyEl.classList.add("active");
if (allTracks.length === 0) startIdleTypewriter();
return;
}
emptyEl.classList.remove("active");
stopIdleTypewriter();
const rows = filtered.map((track) => {
const row = document.createElement("div");
row.className = "library-row";
if (latestState && latestState.current_track && latestState.current_track.id === track.id) {
row.classList.add("is-current");
}
row.innerHTML =
'<span class="library-row-title"></span>' +
'<span class="library-row-artist"></span>' +
'<span class="library-row-duration"></span>';
row.querySelector(".library-row-title").textContent = track.title;
row.querySelector(".library-row-artist").textContent = track.artist || "";
row.querySelector(".library-row-duration").textContent = formatTime(track.duration_ms);
row.addEventListener("click", () => {
Bridge.setQueue(filtered.map((t) => t.id)).then(() => Bridge.playTrack(track.id));
});
listEl.appendChild(row);
return row;
});
staggerIn(rows, 0, 25, 30);
}
function renderFolderList() {
const el = document.getElementById("folder-list");
el.innerHTML = "";
libraryFolders.forEach((folder) => {
const row = document.createElement("div");
row.className = "folder-row";
row.innerHTML = "<span></span><button>убрать</button>";
row.querySelector("span").textContent = folder;
row.querySelector("button").addEventListener("click", () => {
Bridge.removeLibraryFolder(folder).then(refreshLibrary);
});
el.appendChild(row);
});
}
function refreshLibrary() {
return Bridge.getLibrary().then((result) => {
allTracks = result.tracks;
libraryFolders = result.folders;
renderLibraryList();
renderFolderList();
});
}
function wireLibraryControls() {
document.getElementById("btn-add-folder").addEventListener("click", () => {
Bridge.pickLibraryFolder().then((path) => {
if (!path) return;
Bridge.addLibraryFolder(path)
.then(() => Bridge.rescanLibrary())
.then(refreshLibrary);
});
});
document.getElementById("btn-rescan").addEventListener("click", () => {
Bridge.rescanLibrary().then(refreshLibrary);
});
document.getElementById("library-search").addEventListener("input", (e) => {
currentSearch = e.target.value;
renderLibraryList();
});
}

12
src/main.js Normal file
View file

@ -0,0 +1,12 @@
function boot() {
wirePlayerControls();
wireLibraryControls();
wireViewControls();
Bridge.onStateChanged(applyState);
Bridge.getState().then(applyState);
refreshLibrary();
}
document.addEventListener("DOMContentLoaded", boot);

114
src/player.js Normal file
View file

@ -0,0 +1,114 @@
let latestState = null;
let latestStateAt = 0;
let shownTrackId = null;
let seeking = false;
function formatTime(ms) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes + ":" + String(seconds).padStart(2, "0");
}
function applyState(state) {
latestState = state;
latestStateAt = Date.now();
const track = state.current_track;
const titleEl = document.getElementById("track-title");
const artistEl = document.getElementById("track-artist");
if (track) {
titleEl.textContent = track.title;
artistEl.textContent = track.artist || "";
} else {
titleEl.textContent = "ничего не играет";
artistEl.textContent = "";
}
const trackId = track ? track.id : null;
if (trackId !== shownTrackId) {
shownTrackId = trackId;
const coverUrl = track && track.cover_path ? window.__TAURI__.core.convertFileSrc(track.cover_path) : "";
setCoverArt(coverUrl);
}
setPlayIconState(state.is_playing);
const volumeSlider = document.getElementById("volume-slider");
if (document.activeElement !== volumeSlider) {
volumeSlider.value = Math.round(state.volume * 100);
}
renderProgress(state.position_ms, track ? track.duration_ms : 0);
}
function renderProgress(positionMs, durationMs) {
const fill = document.getElementById("progress-fill");
const thumb = document.getElementById("progress-thumb");
const elapsedEl = document.getElementById("time-elapsed");
const remainingEl = document.getElementById("time-remaining");
const pct = durationMs > 0 ? Math.min(100, (positionMs / durationMs) * 100) : 0;
fill.style.width = pct + "%";
thumb.style.left = pct + "%";
elapsedEl.textContent = formatTime(positionMs);
remainingEl.textContent = "-" + formatTime(Math.max(0, durationMs - positionMs));
}
function progressLoop() {
requestAnimationFrame(progressLoop);
if (!latestState || !latestState.is_playing || seeking) return;
const track = latestState.current_track;
if (!track) return;
const elapsedSinceEvent = Date.now() - latestStateAt;
const interpolatedPosition = latestState.position_ms + elapsedSinceEvent;
renderProgress(Math.min(interpolatedPosition, track.duration_ms), track.duration_ms);
}
function seekFromClientX(clientX) {
const track = latestState ? latestState.current_track : null;
if (!track) return;
const rect = document.getElementById("progress-track").getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
const targetMs = Math.round(ratio * track.duration_ms);
renderProgress(targetMs, track.duration_ms);
return targetMs;
}
function wirePlayerControls() {
document.getElementById("btn-play").addEventListener("click", () => {
if (latestState && latestState.is_playing) {
Bridge.pause();
} else {
Bridge.play();
}
});
document.getElementById("btn-prev").addEventListener("click", () => Bridge.previousTrack());
document.getElementById("btn-next").addEventListener("click", () => Bridge.nextTrack());
const progressTrack = document.getElementById("progress-track");
progressTrack.addEventListener("mousedown", (e) => {
seeking = true;
seekFromClientX(e.clientX);
});
window.addEventListener("mousemove", (e) => {
if (seeking) seekFromClientX(e.clientX);
});
window.addEventListener("mouseup", (e) => {
if (!seeking) return;
seeking = false;
const targetMs = seekFromClientX(e.clientX);
if (targetMs !== undefined) Bridge.seek(targetMs);
});
document.getElementById("volume-slider").addEventListener("input", (e) => {
Bridge.setVolume(Number(e.target.value) / 100);
});
requestAnimationFrame(progressLoop);
}

573
src/style.css Normal file
View file

@ -0,0 +1,573 @@
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("fonts/JetBrainsMono-latin-400.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("fonts/JetBrainsMono-cyrillic-400.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--bg: #000;
--fg: #fff;
--dim: #666;
--line: rgba(255, 255, 255, 0.15);
--line-faint: rgba(255, 255, 255, 0.05);
--line-bright: rgba(255, 255, 255, 0.3);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
background: var(--bg);
color: var(--fg);
height: 100%;
overflow: hidden;
user-select: none;
}
body {
background: radial-gradient(ellipse at 50% 35%, #0e0e0e 0%, #000 65%);
font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 13px;
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
}
.grain {
position: fixed;
inset: 0;
pointer-events: none;
mix-blend-mode: overlay;
opacity: 0.6;
z-index: 5;
}
.bg-grid {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
background-image: linear-gradient(var(--line-faint) 1px, transparent 1px),
linear-gradient(90deg, var(--line-faint) 1px, transparent 1px);
background-size: 44px 44px;
}
.corner {
position: fixed;
width: 14px;
height: 14px;
border: 1px solid var(--line-bright);
pointer-events: none;
z-index: 6;
}
.corner-tl {
top: 10px;
left: 10px;
border-right: none;
border-bottom: none;
}
.corner-tr {
top: 10px;
right: 10px;
border-left: none;
border-bottom: none;
}
.corner-bl {
bottom: 10px;
left: 10px;
border-right: none;
border-top: none;
}
.corner-br {
bottom: 10px;
right: 10px;
border-left: none;
border-top: none;
}
.titlebar {
position: relative;
z-index: 10;
height: 36px;
min-height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid var(--line-faint);
}
.titlebar-label {
font-size: 10px;
letter-spacing: 0.15em;
color: var(--dim);
text-transform: uppercase;
}
.titlebar-buttons {
display: flex;
gap: 6px;
}
.titlebar-buttons button {
width: 22px;
height: 22px;
border: 1px solid var(--line);
background: transparent;
color: var(--dim);
cursor: pointer;
font-family: inherit;
font-size: 13px;
line-height: 1;
transition: color 0.15s ease, border-color 0.15s ease;
}
.titlebar-buttons button:hover {
color: var(--fg);
border-color: var(--line-bright);
}
.tabs {
position: relative;
z-index: 10;
display: flex;
gap: 4px;
padding: 10px 16px 0;
}
.tab {
background: transparent;
border: none;
color: var(--dim);
font-family: inherit;
font-size: 10px;
letter-spacing: 0.15em;
text-transform: uppercase;
padding: 6px 2px;
cursor: pointer;
border-bottom: 1px solid transparent;
transition: color 0.15s ease, border-color 0.15s ease;
margin-right: 14px;
}
.tab:hover {
color: var(--fg);
}
.tab.active {
color: var(--fg);
border-bottom-color: var(--fg);
}
main {
position: relative;
z-index: 10;
flex: 1;
overflow: hidden;
}
.view {
display: none;
height: 100%;
flex-direction: column;
padding: 24px 20px;
}
.view.active {
display: flex;
}
.cover-stack {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border: 1px solid var(--line);
background: #0a0a0a;
opacity: 0;
transform: translateY(6px);
animation: fadeUp 0.6s ease forwards;
animation-delay: 0.05s;
}
.cover-layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transform: scale(1);
transition: opacity 0.45s ease, transform 0.45s ease;
}
.cover-layer.is-front {
opacity: 1;
transform: scale(1.02);
}
.now-playing-info {
margin-top: 18px;
text-align: center;
opacity: 0;
transform: translateY(6px);
animation: fadeUp 0.6s ease forwards;
animation-delay: 0.15s;
}
.track-title {
font-size: 15px;
letter-spacing: 0.02em;
}
.track-artist {
margin-top: 4px;
font-size: 11px;
color: var(--dim);
}
.progress {
margin-top: 22px;
display: flex;
align-items: center;
gap: 10px;
opacity: 0;
transform: translateY(6px);
animation: fadeUp 0.6s ease forwards;
animation-delay: 0.25s;
}
.time {
font-size: 10px;
color: var(--dim);
width: 34px;
flex-shrink: 0;
}
.time:last-child {
text-align: right;
}
.progress-track {
position: relative;
flex: 1;
height: 12px;
cursor: pointer;
display: flex;
align-items: center;
}
.progress-track::before {
content: "";
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--line);
}
.progress-fill {
position: absolute;
left: 0;
height: 1px;
background: var(--fg);
width: 0%;
}
.progress-thumb {
position: absolute;
left: 0%;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fg);
transform: translateX(-50%) scale(0);
transition: transform 0.15s ease;
}
.progress-track:hover .progress-thumb {
transform: translateX(-50%) scale(1);
}
.transport {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 22px;
opacity: 0;
transform: translateY(6px);
animation: fadeUp 0.6s ease forwards;
animation-delay: 0.35s;
}
.transport-btn {
background: transparent;
border: none;
color: var(--fg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
transition: transform 0.1s ease, opacity 0.15s ease;
}
.transport-btn:hover {
opacity: 0.7;
}
.transport-btn:active {
transform: scale(0.88);
}
.transport-btn svg {
width: 16px;
height: 16px;
}
.transport-btn-main svg {
width: 22px;
height: 22px;
}
.play-icon,
.pause-icon {
position: absolute;
inset: 0;
opacity: 0;
transform: scale(0.85);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.transport-btn-main {
position: relative;
width: 30px;
height: 30px;
}
.transport-btn-main .play-icon {
opacity: 1;
transform: scale(1);
}
.transport-btn-main.is-playing .play-icon {
opacity: 0;
transform: scale(0.85);
}
.transport-btn-main.is-playing .pause-icon {
opacity: 1;
transform: scale(1);
}
.volume {
margin-top: 18px;
display: flex;
justify-content: center;
opacity: 0;
transform: translateY(6px);
animation: fadeUp 0.6s ease forwards;
animation-delay: 0.4s;
}
.volume input[type="range"] {
width: 120px;
accent-color: var(--fg);
}
.library-toolbar {
display: flex;
gap: 10px;
align-items: center;
}
.library-toolbar button {
background: transparent;
border: 1px solid var(--line);
color: var(--dim);
font-family: inherit;
font-size: 10px;
letter-spacing: 0.1em;
padding: 7px 10px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
}
.library-toolbar button:hover {
color: var(--fg);
border-color: var(--line-bright);
}
.library-toolbar input[type="text"] {
flex: 1;
background: transparent;
border: 1px solid var(--line);
color: var(--fg);
font-family: inherit;
font-size: 11px;
padding: 7px 10px;
}
.library-toolbar input[type="text"]::placeholder {
color: var(--dim);
}
.library-list {
margin-top: 16px;
flex: 1;
overflow-y: auto;
}
.library-row {
display: flex;
align-items: baseline;
gap: 10px;
padding: 9px 4px;
border-bottom: 1px solid var(--line-faint);
cursor: pointer;
opacity: 0;
transform: translateY(6px);
animation: fadeUp 0.4s ease forwards;
transition: transform 0.15s ease;
}
.library-row:hover {
transform: translateY(-2px);
}
.library-row.is-current .library-row-title {
color: var(--fg);
}
.library-row.is-current .library-row-title::before {
content: "▸ ";
}
.library-row-title {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-row-artist {
font-size: 10px;
color: var(--dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 35%;
}
.library-row-duration {
font-size: 10px;
color: var(--dim);
flex-shrink: 0;
}
.library-empty {
display: none;
flex: 1;
align-items: center;
justify-content: center;
color: var(--dim);
font-size: 12px;
}
.library-empty.active {
display: flex;
}
.term-cursor {
animation: blink 1s step-start infinite;
}
.settings-row {
padding: 14px 0;
border-bottom: 1px solid var(--line-faint);
display: flex;
align-items: center;
justify-content: space-between;
}
.settings-row label {
font-size: 11px;
color: var(--dim);
letter-spacing: 0.05em;
}
.settings-row input[type="checkbox"] {
accent-color: var(--fg);
}
.folder-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 11px;
padding: 6px 0;
color: var(--dim);
}
.folder-row button {
background: transparent;
border: none;
color: var(--dim);
cursor: pointer;
font-family: inherit;
}
.folder-row button:hover {
color: var(--fg);
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes reveal {
from {
opacity: 0;
transform: scale(0.7) rotate(-10deg);
}
to {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
@keyframes blink {
50% {
opacity: 0;
}
}

56
src/tauri-bridge.js Normal file
View file

@ -0,0 +1,56 @@
const invoke = window.__TAURI__.core.invoke;
const tauriListen = window.__TAURI__.event.listen;
const Bridge = {
getState() {
return invoke("get_state");
},
getLibrary() {
return invoke("get_library");
},
pickLibraryFolder() {
return invoke("pick_library_folder");
},
addLibraryFolder(path) {
return invoke("add_library_folder", { path });
},
removeLibraryFolder(path) {
return invoke("remove_library_folder", { path });
},
rescanLibrary() {
return invoke("rescan_library");
},
playTrack(trackId) {
return invoke("play_track", { trackId });
},
play() {
return invoke("play");
},
pause() {
return invoke("pause");
},
nextTrack() {
return invoke("next_track");
},
previousTrack() {
return invoke("previous_track");
},
seek(positionMs) {
return invoke("seek", { positionMs });
},
setVolume(volume) {
return invoke("set_volume", { volume });
},
setQueue(trackIds) {
return invoke("set_queue", { trackIds });
},
setDiscordEnabled(enabled) {
return invoke("set_discord_enabled", { enabled });
},
hideWindow() {
return invoke("hide_window");
},
onStateChanged(handler) {
return tauriListen("playback-state-changed", (event) => handler(event.payload));
},
};

25
src/views.js Normal file
View file

@ -0,0 +1,25 @@
function switchView(viewName) {
document.querySelectorAll(".tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.view === viewName);
});
document.querySelectorAll(".view").forEach((view) => {
view.classList.toggle("active", view.id === "view-" + viewName);
});
if (viewName === "library") {
renderLibraryList();
}
}
function wireViewControls() {
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => switchView(tab.dataset.view));
});
document.getElementById("btn-hide").addEventListener("click", () => {
Bridge.hideWindow();
});
document.getElementById("toggle-discord").addEventListener("change", (e) => {
Bridge.setDiscordEnabled(e.target.checked);
});
}