scaffold sMusic: Tauri v2 player core (audio/library/tags/discord/tray) + cover relay + CI
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
33
.github/workflows/ci.yml
vendored
Normal 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
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
||||||
|
/node_modules/
|
||||||
|
/src-tauri/target/
|
||||||
|
/src-tauri/gen/
|
||||||
|
.claude/
|
||||||
|
*.env
|
||||||
|
.env.*
|
||||||
23
README.md
Normal 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
|
|
@ -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"},
|
||||||
|
)
|
||||||
19
cover-relay/nginx-smusic-cover.conf
Normal 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;
|
||||||
|
}
|
||||||
2
cover-relay/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
15
cover-relay/smusic-cover.service
Normal 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
|
|
@ -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
|
|
@ -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
24
src-tauri/Cargo.toml
Normal 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
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
16
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 607 B |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 820 B |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
src-tauri/icons/source.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
103
src-tauri/src/audio.rs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/fonts/JetBrainsMono-cyrillic-400.woff2
Normal file
BIN
src/fonts/JetBrainsMono-latin-400.woff2
Normal file
101
src/index.html
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||