scaffold sMusic: Tauri v2 player core (audio/library/tags/discord/tray) + cover relay + CI
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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"
|
||||
]
|
||||
}
|
||||
}
|
||||