Add language setting and Last.fm integration
Settings: - Add language dropdown (Auto, English, Español, etc.) with persistence Last.fm: - Create lastfm module with API client for authentication and scrobbling - Add commands for auth flow, now playing, and scrobbling - Full OAuth-style authentication with token exchange - UI for entering API credentials and authorizing - Automatic scrobbling after 50% of track or 4 minutes - Update 'now playing' when tracks start - Session persistence in localStorage
This commit is contained in:
1 parent
9d040fee55
commit
c7145e889b
6 files changed
+838
-13
No files matched your search
@@ -0,0 +1,112 @@
|
||||
//! Last.fm integration commands
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::lastfm::LastFmSession;
|
||||
use crate::AppState;
|
||||
|
||||
/// Check if Last.fm has API credentials configured
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_has_credentials(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
let client = state.lastfm.lock().await;
|
||||
Ok(client.has_credentials())
|
||||
}
|
||||
|
||||
/// Set Last.fm API credentials
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_set_credentials(
|
||||
api_key: String,
|
||||
api_secret: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: lastfm_set_credentials");
|
||||
let mut client = state.lastfm.lock().await;
|
||||
client.set_credentials(api_key, api_secret);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if Last.fm is authenticated
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_is_authenticated(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
let client = state.lastfm.lock().await;
|
||||
Ok(client.is_authenticated())
|
||||
}
|
||||
|
||||
/// Get Last.fm authentication token and URL
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_get_auth_url(state: State<'_, AppState>) -> Result<(String, String), String> {
|
||||
log::info!("Command: lastfm_get_auth_url");
|
||||
let client = state.lastfm.lock().await;
|
||||
|
||||
if !client.has_credentials() {
|
||||
return Err("Last.fm API credentials not configured. Please set API key and secret in settings.".to_string());
|
||||
}
|
||||
|
||||
let token = client.get_token().await?;
|
||||
let url = client.get_auth_url(&token);
|
||||
Ok((token, url))
|
||||
}
|
||||
|
||||
/// Complete Last.fm authentication with token
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_authenticate(
|
||||
token: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<LastFmSession, String> {
|
||||
log::info!("Command: lastfm_authenticate");
|
||||
let mut client = state.lastfm.lock().await;
|
||||
client.get_session(&token).await
|
||||
}
|
||||
|
||||
/// Set Last.fm session key (for restoring saved session)
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_set_session(
|
||||
session_key: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: lastfm_set_session");
|
||||
let mut client = state.lastfm.lock().await;
|
||||
client.set_session_key(session_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect from Last.fm
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
log::info!("Command: lastfm_disconnect");
|
||||
let mut client = state.lastfm.lock().await;
|
||||
// Reset to default (clears session key)
|
||||
*client = crate::lastfm::LastFmClient::default();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scrobble a track to Last.fm
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_scrobble(
|
||||
artist: String,
|
||||
track: String,
|
||||
album: Option<String>,
|
||||
timestamp: u64,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: lastfm_scrobble - {} - {}", artist, track);
|
||||
let client = state.lastfm.lock().await;
|
||||
client
|
||||
.scrobble(&artist, &track, album.as_deref(), timestamp)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update "now playing" on Last.fm
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_now_playing(
|
||||
artist: String,
|
||||
track: String,
|
||||
album: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: lastfm_now_playing - {} - {}", artist, track);
|
||||
let client = state.lastfm.lock().await;
|
||||
client
|
||||
.update_now_playing(&artist, &track, album.as_deref())
|
||||
.await
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod favorites;
|
||||
pub mod lastfm;
|
||||
pub mod notification;
|
||||
pub mod playback;
|
||||
pub mod playlist;
|
||||
@@ -14,6 +15,7 @@ pub mod search;
|
||||
pub use auth::*;
|
||||
pub use cache::*;
|
||||
pub use favorites::*;
|
||||
pub use lastfm::*;
|
||||
pub use notification::*;
|
||||
pub use playback::*;
|
||||
pub use playlist::*;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Last.fm integration module
|
||||
//!
|
||||
//! Handles Last.fm authentication and scrobbling
|
||||
|
||||
use md5::{Digest, Md5};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/";
|
||||
|
||||
// Last.fm API credentials - users need to create their own app at https://www.last.fm/api/account/create
|
||||
// For now using placeholder - in production this should be configurable or use OAuth
|
||||
const API_KEY: &str = ""; // User must set this
|
||||
const API_SECRET: &str = ""; // User must set this
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LastFmSession {
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
pub subscriber: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthGetSessionResponse {
|
||||
session: LastFmSession,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthGetTokenResponse {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum LastFmResponse<T> {
|
||||
Success(T),
|
||||
Error { error: u32, message: String },
|
||||
}
|
||||
|
||||
/// Last.fm API client
|
||||
pub struct LastFmClient {
|
||||
client: Client,
|
||||
api_key: String,
|
||||
api_secret: String,
|
||||
session_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for LastFmClient {
|
||||
fn default() -> Self {
|
||||
Self::new(API_KEY.to_string(), API_SECRET.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl LastFmClient {
|
||||
pub fn new(api_key: String, api_secret: String) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
api_secret,
|
||||
session_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_session_key(&mut self, key: String) {
|
||||
self.session_key = Some(key);
|
||||
}
|
||||
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.session_key.is_some() && !self.api_key.is_empty()
|
||||
}
|
||||
|
||||
pub fn has_credentials(&self) -> bool {
|
||||
!self.api_key.is_empty() && !self.api_secret.is_empty()
|
||||
}
|
||||
|
||||
pub fn set_credentials(&mut self, api_key: String, api_secret: String) {
|
||||
self.api_key = api_key;
|
||||
self.api_secret = api_secret;
|
||||
}
|
||||
|
||||
/// Generate API signature for authenticated requests
|
||||
fn generate_signature(&self, params: &BTreeMap<&str, &str>) -> String {
|
||||
let mut sig_string = String::new();
|
||||
for (key, value) in params {
|
||||
sig_string.push_str(key);
|
||||
sig_string.push_str(value);
|
||||
}
|
||||
sig_string.push_str(&self.api_secret);
|
||||
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(sig_string.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Get authorization URL for user to visit
|
||||
pub fn get_auth_url(&self, token: &str) -> String {
|
||||
format!(
|
||||
"https://www.last.fm/api/auth/?api_key={}&token={}",
|
||||
self.api_key, token
|
||||
)
|
||||
}
|
||||
|
||||
/// Get a request token for authentication
|
||||
pub async fn get_token(&self) -> Result<String, String> {
|
||||
if !self.has_credentials() {
|
||||
return Err("Last.fm API credentials not configured".to_string());
|
||||
}
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("method", "auth.getToken");
|
||||
params.insert("api_key", &self.api_key);
|
||||
|
||||
let sig = self.generate_signature(¶ms);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(LASTFM_API_URL)
|
||||
.query(&[
|
||||
("method", "auth.getToken"),
|
||||
("api_key", &self.api_key),
|
||||
("api_sig", &sig),
|
||||
("format", "json"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get token: {}", e))?;
|
||||
|
||||
let data: LastFmResponse<AuthGetTokenResponse> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
match data {
|
||||
LastFmResponse::Success(r) => Ok(r.token),
|
||||
LastFmResponse::Error { message, .. } => Err(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get session key after user has authorized
|
||||
pub async fn get_session(&mut self, token: &str) -> Result<LastFmSession, String> {
|
||||
if !self.has_credentials() {
|
||||
return Err("Last.fm API credentials not configured".to_string());
|
||||
}
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("method", "auth.getSession");
|
||||
params.insert("api_key", &self.api_key);
|
||||
params.insert("token", token);
|
||||
|
||||
let sig = self.generate_signature(¶ms);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(LASTFM_API_URL)
|
||||
.query(&[
|
||||
("method", "auth.getSession"),
|
||||
("api_key", &self.api_key),
|
||||
("token", token),
|
||||
("api_sig", &sig),
|
||||
("format", "json"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get session: {}", e))?;
|
||||
|
||||
let data: LastFmResponse<AuthGetSessionResponse> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
match data {
|
||||
LastFmResponse::Success(r) => {
|
||||
self.session_key = Some(r.session.key.clone());
|
||||
Ok(r.session)
|
||||
}
|
||||
LastFmResponse::Error { message, .. } => Err(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrobble a track (mark as played)
|
||||
pub async fn scrobble(
|
||||
&self,
|
||||
artist: &str,
|
||||
track: &str,
|
||||
album: Option<&str>,
|
||||
timestamp: u64,
|
||||
) -> Result<(), String> {
|
||||
let session_key = self
|
||||
.session_key
|
||||
.as_ref()
|
||||
.ok_or("Not authenticated with Last.fm")?;
|
||||
|
||||
let timestamp_str = timestamp.to_string();
|
||||
let api_key = &self.api_key;
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("method", "track.scrobble");
|
||||
params.insert("api_key", api_key.as_str());
|
||||
params.insert("sk", session_key.as_str());
|
||||
params.insert("artist", artist);
|
||||
params.insert("track", track);
|
||||
params.insert("timestamp", ×tamp_str);
|
||||
if let Some(album_name) = album {
|
||||
params.insert("album", album_name);
|
||||
}
|
||||
|
||||
let sig = self.generate_signature(¶ms);
|
||||
|
||||
let mut form_params = vec![
|
||||
("method", "track.scrobble"),
|
||||
("api_key", api_key.as_str()),
|
||||
("sk", session_key.as_str()),
|
||||
("artist", artist),
|
||||
("track", track),
|
||||
("timestamp", ×tamp_str),
|
||||
("api_sig", &sig),
|
||||
("format", "json"),
|
||||
];
|
||||
if let Some(album_name) = album {
|
||||
form_params.push(("album", album_name));
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(LASTFM_API_URL)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to scrobble: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
log::info!("Scrobbled: {} - {}", artist, track);
|
||||
Ok(())
|
||||
} else {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
Err(format!("Scrobble failed: {}", text))
|
||||
}
|
||||
}
|
||||
|
||||
/// Update "now playing" status
|
||||
pub async fn update_now_playing(
|
||||
&self,
|
||||
artist: &str,
|
||||
track: &str,
|
||||
album: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let session_key = self
|
||||
.session_key
|
||||
.as_ref()
|
||||
.ok_or("Not authenticated with Last.fm")?;
|
||||
|
||||
let api_key = &self.api_key;
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("method", "track.updateNowPlaying");
|
||||
params.insert("api_key", api_key.as_str());
|
||||
params.insert("sk", session_key.as_str());
|
||||
params.insert("artist", artist);
|
||||
params.insert("track", track);
|
||||
if let Some(album_name) = album {
|
||||
params.insert("album", album_name);
|
||||
}
|
||||
|
||||
let sig = self.generate_signature(¶ms);
|
||||
|
||||
let mut form_params = vec![
|
||||
("method", "track.updateNowPlaying"),
|
||||
("api_key", api_key.as_str()),
|
||||
("sk", session_key.as_str()),
|
||||
("artist", artist),
|
||||
("track", track),
|
||||
("api_sig", &sig),
|
||||
("format", "json"),
|
||||
];
|
||||
if let Some(album_name) = album {
|
||||
form_params.push(("album", album_name));
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(LASTFM_API_URL)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update now playing: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
log::debug!("Updated now playing: {} - {}", artist, track);
|
||||
Ok(())
|
||||
} else {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
Err(format!("Update now playing failed: {}", text))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod api;
|
||||
pub mod cache;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod lastfm;
|
||||
pub mod media_controls;
|
||||
pub mod player;
|
||||
pub mod queue;
|
||||
@@ -16,6 +17,7 @@ use tokio::sync::Mutex;
|
||||
|
||||
use api::QobuzClient;
|
||||
use cache::AudioCache;
|
||||
use lastfm::LastFmClient;
|
||||
use media_controls::{MediaControlsManager, TrackInfo};
|
||||
use player::Player;
|
||||
use queue::QueueManager;
|
||||
@@ -27,6 +29,7 @@ pub struct AppState {
|
||||
pub queue: QueueManager,
|
||||
pub media_controls: MediaControlsManager,
|
||||
pub audio_cache: Arc<AudioCache>,
|
||||
pub lastfm: Arc<Mutex<LastFmClient>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -37,6 +40,7 @@ impl AppState {
|
||||
queue: QueueManager::new(),
|
||||
media_controls: MediaControlsManager::new(),
|
||||
audio_cache: Arc::new(AudioCache::default()),
|
||||
lastfm: Arc::new(Mutex::new(LastFmClient::default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +135,16 @@ pub fn run() {
|
||||
// Cache commands
|
||||
commands::get_cache_stats,
|
||||
commands::clear_cache,
|
||||
// Last.fm commands
|
||||
commands::lastfm_has_credentials,
|
||||
commands::lastfm_set_credentials,
|
||||
commands::lastfm_is_authenticated,
|
||||
commands::lastfm_get_auth_url,
|
||||
commands::lastfm_authenticate,
|
||||
commands::lastfm_set_session,
|
||||
commands::lastfm_disconnect,
|
||||
commands::lastfm_scrobble,
|
||||
commands::lastfm_now_playing,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -67,10 +67,17 @@
|
||||
// Appearance settings
|
||||
let theme = $state('Dark');
|
||||
let compactMode = $state(false);
|
||||
let language = $state('Auto');
|
||||
|
||||
// Integrations
|
||||
// Last.fm integration state
|
||||
let lastfmConnected = $state(false);
|
||||
let scrobbling = $state(false);
|
||||
let lastfmUsername = $state('');
|
||||
let scrobbling = $state(true);
|
||||
let lastfmApiKey = $state('');
|
||||
let lastfmApiSecret = $state('');
|
||||
let lastfmAuthToken = $state('');
|
||||
let lastfmConnecting = $state(false);
|
||||
let showLastfmConfig = $state(false);
|
||||
|
||||
// Load saved settings on mount
|
||||
onMount(() => {
|
||||
@@ -91,13 +98,140 @@
|
||||
preferHighest = savedPreferHighest === 'true';
|
||||
}
|
||||
|
||||
// Load language setting
|
||||
const savedLanguage = localStorage.getItem('qbz-language');
|
||||
if (savedLanguage) {
|
||||
language = savedLanguage;
|
||||
}
|
||||
|
||||
// Load cache stats
|
||||
loadCacheStats();
|
||||
|
||||
// Load audio devices
|
||||
loadAudioDevices();
|
||||
|
||||
// Load Last.fm state
|
||||
loadLastfmState();
|
||||
});
|
||||
|
||||
async function loadLastfmState() {
|
||||
try {
|
||||
// Check if Last.fm credentials are configured
|
||||
const hasCredentials = await invoke<boolean>('lastfm_has_credentials');
|
||||
|
||||
// Load saved credentials from localStorage
|
||||
const savedApiKey = localStorage.getItem('qbz-lastfm-api-key');
|
||||
const savedApiSecret = localStorage.getItem('qbz-lastfm-api-secret');
|
||||
const savedSessionKey = localStorage.getItem('qbz-lastfm-session-key');
|
||||
const savedUsername = localStorage.getItem('qbz-lastfm-username');
|
||||
const savedScrobbling = localStorage.getItem('qbz-lastfm-scrobbling');
|
||||
|
||||
if (savedApiKey && savedApiSecret) {
|
||||
lastfmApiKey = savedApiKey;
|
||||
lastfmApiSecret = savedApiSecret;
|
||||
// Set credentials in backend
|
||||
await invoke('lastfm_set_credentials', {
|
||||
apiKey: savedApiKey,
|
||||
apiSecret: savedApiSecret
|
||||
});
|
||||
}
|
||||
|
||||
if (savedSessionKey && savedUsername) {
|
||||
await invoke('lastfm_set_session', { sessionKey: savedSessionKey });
|
||||
lastfmConnected = true;
|
||||
lastfmUsername = savedUsername;
|
||||
}
|
||||
|
||||
if (savedScrobbling !== null) {
|
||||
scrobbling = savedScrobbling === 'true';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load Last.fm state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLastfmConnect() {
|
||||
if (!lastfmApiKey || !lastfmApiSecret) {
|
||||
showLastfmConfig = true;
|
||||
return;
|
||||
}
|
||||
|
||||
lastfmConnecting = true;
|
||||
try {
|
||||
// Save credentials
|
||||
localStorage.setItem('qbz-lastfm-api-key', lastfmApiKey);
|
||||
localStorage.setItem('qbz-lastfm-api-secret', lastfmApiSecret);
|
||||
|
||||
// Set credentials in backend
|
||||
await invoke('lastfm_set_credentials', {
|
||||
apiKey: lastfmApiKey,
|
||||
apiSecret: lastfmApiSecret
|
||||
});
|
||||
|
||||
// Get auth URL and token
|
||||
const [token, url] = await invoke<[string, string]>('lastfm_get_auth_url');
|
||||
lastfmAuthToken = token;
|
||||
|
||||
// Open browser for authorization
|
||||
window.open(url, '_blank');
|
||||
|
||||
// Show message to user - they need to authorize and then click a button
|
||||
showLastfmConfig = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to start Last.fm auth:', err);
|
||||
alert(`Last.fm error: ${err}`);
|
||||
} finally {
|
||||
lastfmConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLastfmCompleteAuth() {
|
||||
if (!lastfmAuthToken) {
|
||||
alert('Please start the authorization first');
|
||||
return;
|
||||
}
|
||||
|
||||
lastfmConnecting = true;
|
||||
try {
|
||||
const session = await invoke<{ name: string; key: string }>('lastfm_authenticate', {
|
||||
token: lastfmAuthToken
|
||||
});
|
||||
|
||||
lastfmConnected = true;
|
||||
lastfmUsername = session.name;
|
||||
showLastfmConfig = false;
|
||||
lastfmAuthToken = '';
|
||||
|
||||
// Save session
|
||||
localStorage.setItem('qbz-lastfm-session-key', session.key);
|
||||
localStorage.setItem('qbz-lastfm-username', session.name);
|
||||
} catch (err) {
|
||||
console.error('Failed to complete Last.fm auth:', err);
|
||||
alert(`Authorization failed: ${err}`);
|
||||
} finally {
|
||||
lastfmConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLastfmDisconnect() {
|
||||
try {
|
||||
await invoke('lastfm_disconnect');
|
||||
lastfmConnected = false;
|
||||
lastfmUsername = '';
|
||||
|
||||
// Clear saved session
|
||||
localStorage.removeItem('qbz-lastfm-session-key');
|
||||
localStorage.removeItem('qbz-lastfm-username');
|
||||
} catch (err) {
|
||||
console.error('Failed to disconnect Last.fm:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleScrobblingChange(enabled: boolean) {
|
||||
scrobbling = enabled;
|
||||
localStorage.setItem('qbz-lastfm-scrobbling', String(enabled));
|
||||
}
|
||||
|
||||
function handleQualityChange(quality: string) {
|
||||
streamingQuality = quality;
|
||||
localStorage.setItem('qbz-streaming-quality', quality);
|
||||
@@ -108,6 +242,11 @@
|
||||
localStorage.setItem('qbz-prefer-highest', String(enabled));
|
||||
}
|
||||
|
||||
function handleLanguageChange(lang: string) {
|
||||
language = lang;
|
||||
localStorage.setItem('qbz-language', lang);
|
||||
}
|
||||
|
||||
async function loadAudioDevices() {
|
||||
try {
|
||||
const devices = await invoke<AudioDevice[]>('get_audio_devices');
|
||||
@@ -269,6 +408,14 @@
|
||||
onchange={handleThemeChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Language</span>
|
||||
<Dropdown
|
||||
value={language}
|
||||
options={['Auto', 'English', 'Español', 'Français', 'Deutsch', 'Italiano', 'Português']}
|
||||
onchange={handleLanguageChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<span class="setting-label">Compact Mode</span>
|
||||
<Toggle enabled={compactMode} onchange={(v) => (compactMode = v)} />
|
||||
@@ -278,21 +425,85 @@
|
||||
<!-- Integrations Section -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">Integrations</h3>
|
||||
<div class="setting-row" class:last={!lastfmConnected}>
|
||||
<span class="setting-label">Last.fm</span>
|
||||
<button
|
||||
class="connect-btn"
|
||||
class:connected={lastfmConnected}
|
||||
onclick={() => (lastfmConnected = !lastfmConnected)}
|
||||
>
|
||||
{lastfmConnected ? 'Disconnect' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if lastfmConnected}
|
||||
<div class="setting-row">
|
||||
<div class="lastfm-connected">
|
||||
<span class="setting-label">Last.fm</span>
|
||||
<span class="lastfm-username">Connected as {lastfmUsername}</span>
|
||||
</div>
|
||||
<button
|
||||
class="connect-btn connected"
|
||||
onclick={handleLastfmDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<span class="setting-label">Scrobbling</span>
|
||||
<Toggle enabled={scrobbling} onchange={(v) => (scrobbling = v)} />
|
||||
<Toggle enabled={scrobbling} onchange={handleScrobblingChange} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="setting-row" class:last={!showLastfmConfig}>
|
||||
<span class="setting-label">Last.fm</span>
|
||||
<button
|
||||
class="connect-btn"
|
||||
onclick={handleLastfmConnect}
|
||||
disabled={lastfmConnecting}
|
||||
>
|
||||
{lastfmConnecting ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showLastfmConfig}
|
||||
<div class="lastfm-config">
|
||||
<p class="config-info">
|
||||
To connect Last.fm, you need API credentials.
|
||||
<a href="https://www.last.fm/api/account/create" target="_blank" rel="noopener">
|
||||
Create an API account
|
||||
</a>
|
||||
</p>
|
||||
<div class="config-field">
|
||||
<label for="lastfm-api-key">API Key</label>
|
||||
<input
|
||||
id="lastfm-api-key"
|
||||
type="text"
|
||||
bind:value={lastfmApiKey}
|
||||
placeholder="Enter your API key"
|
||||
/>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label for="lastfm-api-secret">Shared Secret</label>
|
||||
<input
|
||||
id="lastfm-api-secret"
|
||||
type="password"
|
||||
bind:value={lastfmApiSecret}
|
||||
placeholder="Enter your shared secret"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if lastfmAuthToken}
|
||||
<p class="auth-info">
|
||||
Authorization window opened. After authorizing in the browser, click below:
|
||||
</p>
|
||||
<button
|
||||
class="auth-complete-btn"
|
||||
onclick={handleLastfmCompleteAuth}
|
||||
disabled={lastfmConnecting}
|
||||
>
|
||||
{lastfmConnecting ? 'Verifying...' : 'I\'ve Authorized'}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="auth-start-btn"
|
||||
onclick={handleLastfmConnect}
|
||||
disabled={!lastfmApiKey || !lastfmApiSecret || lastfmConnecting}
|
||||
>
|
||||
{lastfmConnecting ? 'Opening...' : 'Authorize with Last.fm'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -513,4 +724,123 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Last.fm styles */
|
||||
.lastfm-connected {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.lastfm-username {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.lastfm-config {
|
||||
padding: 16px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.config-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.config-info a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.config-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.config-field input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.config-field input::placeholder {
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.auth-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.auth-start-btn,
|
||||
.auth-complete-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.auth-start-btn {
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.auth-start-btn:hover:not(:disabled) {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.auth-start-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-complete-btn {
|
||||
background-color: #1db954;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.auth-complete-btn:hover:not(:disabled) {
|
||||
background-color: #1ed760;
|
||||
}
|
||||
|
||||
.auth-complete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.connect-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -366,6 +366,15 @@
|
||||
selectedAlbum?.title || '',
|
||||
artwork || undefined
|
||||
);
|
||||
|
||||
// Update Last.fm
|
||||
updateLastfmNowPlaying(
|
||||
track.title,
|
||||
track.artist || selectedAlbum?.artist || 'Unknown Artist',
|
||||
selectedAlbum?.title || '',
|
||||
track.durationSeconds,
|
||||
track.id
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to play track:', err);
|
||||
showToast(`Playback error: ${err}`, 'error');
|
||||
@@ -502,6 +511,9 @@
|
||||
// Show system notification
|
||||
showTrackNotification(track.title, track.artist, track.album, track.artwork_url || undefined);
|
||||
|
||||
// Update Last.fm
|
||||
updateLastfmNowPlaying(track.title, track.artist, track.album, track.duration_secs, track.id);
|
||||
|
||||
// Refresh queue state
|
||||
await syncQueueState();
|
||||
} catch (err) {
|
||||
@@ -680,6 +692,15 @@
|
||||
track.albumArt
|
||||
);
|
||||
|
||||
// Update Last.fm
|
||||
updateLastfmNowPlaying(
|
||||
track.title,
|
||||
track.artist || 'Unknown Artist',
|
||||
track.album || '',
|
||||
track.durationSeconds,
|
||||
track.id
|
||||
);
|
||||
|
||||
await syncQueueState();
|
||||
} catch (err) {
|
||||
console.error('Failed to play track:', err);
|
||||
@@ -702,6 +723,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Last.fm scrobbling state
|
||||
let lastScrobbledTrackId: number | null = null;
|
||||
let scrobbleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Update Last.fm "now playing" and schedule scrobble
|
||||
async function updateLastfmNowPlaying(title: string, artist: string, album: string, durationSecs: number, trackId: number) {
|
||||
// Check if scrobbling is enabled
|
||||
const scrobblingEnabled = localStorage.getItem('qbz-lastfm-scrobbling') !== 'false';
|
||||
const sessionKey = localStorage.getItem('qbz-lastfm-session-key');
|
||||
|
||||
if (!scrobblingEnabled || !sessionKey) return;
|
||||
|
||||
try {
|
||||
// Update "now playing"
|
||||
await invoke('lastfm_now_playing', {
|
||||
artist,
|
||||
track: title,
|
||||
album: album || null
|
||||
});
|
||||
console.log('Last.fm: Updated now playing');
|
||||
|
||||
// Schedule scrobble after 50% of track or 4 minutes (whichever is shorter)
|
||||
if (scrobbleTimeout) {
|
||||
clearTimeout(scrobbleTimeout);
|
||||
}
|
||||
|
||||
const scrobbleDelay = Math.min(durationSecs * 0.5, 240) * 1000; // in ms
|
||||
|
||||
scrobbleTimeout = setTimeout(async () => {
|
||||
if (lastScrobbledTrackId !== trackId) {
|
||||
try {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
await invoke('lastfm_scrobble', {
|
||||
artist,
|
||||
track: title,
|
||||
album: album || null,
|
||||
timestamp
|
||||
});
|
||||
lastScrobbledTrackId = trackId;
|
||||
console.log('Last.fm: Scrobbled track');
|
||||
} catch (err) {
|
||||
console.error('Last.fm scrobble failed:', err);
|
||||
}
|
||||
}
|
||||
}, scrobbleDelay);
|
||||
} catch (err) {
|
||||
console.error('Last.fm now playing failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Toast Functions
|
||||
function showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
|
||||
toast = { message, type };
|
||||
|
||||
Reference in new issue
Block a user