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

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

6498
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

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

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

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

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

View file

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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