feat(oauth): persist session token for automatic re-auth on restart
- Save user_auth_token after successful OAuth login - On bootstrap, restore session via saved token if no email/password creds - If token is expired, clear it so user sees login screen normally - Clear OAuth token on v2_clear_saved_credentials (logout) - Add login_with_token() to api client (reuses OAuth /user/login step) Also rename t->translateStore in NowPlayingBar.svelte (ADR-001 fix from Codex) and improve clean-dev-runtime.sh to kill all stale processes and ports
This commit is contained in:
5 files changed
+271
-32
No files matched your search
@@ -4,9 +4,25 @@ set -euo pipefail
|
|||||||
# Kill stale dev processes that commonly leave Vite/Svelte CSS pipeline in a bad state.
|
# Kill stale dev processes that commonly leave Vite/Svelte CSS pipeline in a bad state.
|
||||||
pkill -f '[n]ode .*vite dev' || true
|
pkill -f '[n]ode .*vite dev' || true
|
||||||
pkill -f '[n]ode .*tauri dev' || true
|
pkill -f '[n]ode .*tauri dev' || true
|
||||||
|
pkill -f '[c]argo run' || true
|
||||||
|
pkill -f '[t]arget/debug/qbz' || true
|
||||||
pkill -f '[t]arget/debug/qbz-nix' || true
|
pkill -f '[t]arget/debug/qbz-nix' || true
|
||||||
|
|
||||||
# Clean generated frontend caches.
|
# Force-free dev ports in case a process survives pattern-based kill.
|
||||||
rm -rf .svelte-kit .vite node_modules/.vite
|
for port in 1420 1421; do
|
||||||
|
pids="$(lsof -ti :"${port}" 2>/dev/null || true)"
|
||||||
|
if [[ -n "${pids}" ]]; then
|
||||||
|
kill ${pids} 2>/dev/null || true
|
||||||
|
sleep 0.2
|
||||||
|
# Escalate only if still alive after TERM.
|
||||||
|
still_alive="$(lsof -ti :"${port}" 2>/dev/null || true)"
|
||||||
|
if [[ -n "${still_alive}" ]]; then
|
||||||
|
kill -9 ${still_alive} 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "[qbz] Dev runtime cleaned (.svelte-kit/.vite + stale vite/tauri/qbz processes)."
|
# Clean generated frontend caches.
|
||||||
|
rm -rf .svelte-kit .vite node_modules/.vite build dist .cache
|
||||||
|
|
||||||
|
echo "[qbz] Dev runtime cleaned (processes + ports 1420/1421 + cache directories)."
|
||||||
@@ -280,6 +280,63 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-authenticate using a previously obtained `user_auth_token`.
|
||||||
|
///
|
||||||
|
/// Used at bootstrap when OAuth credentials are stored but email/password are not.
|
||||||
|
/// Calls `POST /user/login` with `X-User-Auth-Token` header — the same as step 2
|
||||||
|
/// of the OAuth code exchange flow. Returns an error if the token has expired.
|
||||||
|
pub async fn login_with_token(&self, token: &str) -> Result<UserSession> {
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
|
|
||||||
|
let tokens = self.tokens.read().await;
|
||||||
|
let app_id = tokens
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?
|
||||||
|
.app_id
|
||||||
|
.clone();
|
||||||
|
drop(tokens);
|
||||||
|
|
||||||
|
let user_login_url = endpoints::build_url(paths::USER_LOGIN);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"X-App-Id",
|
||||||
|
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
"X-User-Auth-Token",
|
||||||
|
HeaderValue::from_str(token).map_err(|_| {
|
||||||
|
ApiError::AuthenticationError("Invalid token format".into())
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
|
||||||
|
log::info!("[OAuth] Restoring session from saved token");
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&user_login_url)
|
||||||
|
.headers(headers)
|
||||||
|
.header("Content-Type", "text/plain;charset=UTF-8")
|
||||||
|
.body("extra=partner")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match resp.status() {
|
||||||
|
StatusCode::OK => {
|
||||||
|
let json: Value = resp.json().await?;
|
||||||
|
let session = parse_login_response(&json)?;
|
||||||
|
*self.session.write().await = Some(session.clone());
|
||||||
|
log::info!("[OAuth] Session restored for user {}", session.user_id);
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
|
||||||
|
"OAuth token expired or invalid".to_string(),
|
||||||
|
)),
|
||||||
|
status => Err(ApiError::ApiResponse(format!(
|
||||||
|
"Token re-auth failed: {}",
|
||||||
|
status
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get current user info (display name, subscription, and expiry if available)
|
/// Get current user info (display name, subscription, and expiry if available)
|
||||||
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
|
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
|
||||||
self.session.read().await.as_ref().map(|s| {
|
self.session.read().await.as_ref().map(|s| {
|
||||||
|
|||||||
@@ -598,6 +598,90 @@ pub async fn runtime_bootstrap(
|
|||||||
// Not a fatal error - user can login manually
|
// Not a fatal error - user can login manually
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if let Ok(Some(oauth_token)) = crate::credentials::load_oauth_token() {
|
||||||
|
// No email/password credentials but there IS a saved OAuth token.
|
||||||
|
// Try to restore the session by calling /user/login with the stored token.
|
||||||
|
log::info!("[Runtime] No email/password credentials, trying saved OAuth token...");
|
||||||
|
|
||||||
|
let client = app_state.client.read().await;
|
||||||
|
match client.login_with_token(&oauth_token).await {
|
||||||
|
Ok(session) => {
|
||||||
|
log::info!(
|
||||||
|
"[Runtime] OAuth token re-auth successful for user {}",
|
||||||
|
session.user_id
|
||||||
|
);
|
||||||
|
manager.set_legacy_auth(true, Some(session.user_id)).await;
|
||||||
|
let _ = app.emit(
|
||||||
|
"runtime:event",
|
||||||
|
RuntimeEvent::AuthChanged {
|
||||||
|
logged_in: true,
|
||||||
|
user_id: Some(session.user_id),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for CoreBridge, then inject session
|
||||||
|
let cb_start = std::time::Instant::now();
|
||||||
|
let cb_timeout = std::time::Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
if core_bridge.try_get().await.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if cb_start.elapsed() > cb_timeout {
|
||||||
|
log::error!("[Runtime] CoreBridge not available after 10s (OAuth restore)");
|
||||||
|
manager.set_bootstrap_in_progress(false).await;
|
||||||
|
return Err(RuntimeError::V2NotInitialized);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(bridge) = core_bridge.try_get().await {
|
||||||
|
let core_session: qbz_models::UserSession = match serde_json::to_value(&session)
|
||||||
|
.and_then(serde_json::from_value)
|
||||||
|
{
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[Runtime] OAuth session conversion failed: {}", e);
|
||||||
|
manager.set_bootstrap_in_progress(false).await;
|
||||||
|
return Err(RuntimeError::Internal(e.to_string()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match bridge.login_with_session(core_session).await {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!("[Runtime] CoreBridge session restored via OAuth token");
|
||||||
|
manager.set_corebridge_auth(true).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[Runtime] CoreBridge OAuth restore failed: {}", e);
|
||||||
|
manager.set_bootstrap_in_progress(false).await;
|
||||||
|
return Err(RuntimeError::V2AuthFailed(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) =
|
||||||
|
crate::session_lifecycle::activate_session(&app, session.user_id).await
|
||||||
|
{
|
||||||
|
log::error!("[Runtime] Session activation failed (OAuth restore): {}", e);
|
||||||
|
manager.set_legacy_auth(false, None).await;
|
||||||
|
manager.set_corebridge_auth(false).await;
|
||||||
|
let reason = DegradedReason::SessionActivationFailed(e.clone());
|
||||||
|
manager.set_degraded(reason.clone()).await;
|
||||||
|
manager.set_bootstrap_in_progress(false).await;
|
||||||
|
let _ = app.emit(
|
||||||
|
"runtime:event",
|
||||||
|
RuntimeEvent::RuntimeDegraded {
|
||||||
|
reason: reason.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Err(RuntimeError::RuntimeDegraded(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Token expired — clear it and let user re-login via OAuth
|
||||||
|
log::warn!("[Runtime] OAuth token expired, clearing: {}", e);
|
||||||
|
let _ = crate::credentials::clear_oauth_token();
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!("[Runtime] No saved credentials, staying in InitializedNoAuth");
|
log::info!("[Runtime] No saved credentials, staying in InitializedNoAuth");
|
||||||
}
|
}
|
||||||
@@ -1254,6 +1338,12 @@ pub async fn v2_start_oauth_login(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the OAuth token so bootstrap can restore the session on next launch.
|
||||||
|
// Non-fatal: if saving fails, the user just has to re-login via OAuth.
|
||||||
|
if let Err(e) = crate::credentials::save_oauth_token(&session.user_auth_token) {
|
||||||
|
log::warn!("[V2] Failed to persist OAuth token: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"runtime:event",
|
"runtime:event",
|
||||||
RuntimeEvent::RuntimeReady {
|
RuntimeEvent::RuntimeReady {
|
||||||
@@ -2663,7 +2753,8 @@ pub fn v2_save_credentials(email: String, password: String) -> Result<(), String
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn v2_clear_saved_credentials() -> Result<(), String> {
|
pub fn v2_clear_saved_credentials() -> Result<(), String> {
|
||||||
crate::credentials::clear_qobuz_credentials()
|
crate::credentials::clear_qobuz_credentials()?;
|
||||||
|
crate::credentials::clear_oauth_token()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const SERVICE_NAME: &str = "qbz";
|
|||||||
const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials";
|
const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials";
|
||||||
const FALLBACK_FILE_NAME: &str = ".qbz-auth";
|
const FALLBACK_FILE_NAME: &str = ".qbz-auth";
|
||||||
const LEGACY_FALLBACK_FILE_NAME: &str = ".qbz-auth.legacy";
|
const LEGACY_FALLBACK_FILE_NAME: &str = ".qbz-auth.legacy";
|
||||||
|
const OAUTH_TOKEN_FILE_NAME: &str = ".qbz-oauth-token";
|
||||||
|
|
||||||
// Salt for key derivation (app-specific, not secret but adds uniqueness)
|
// Salt for key derivation (app-specific, not secret but adds uniqueness)
|
||||||
const KEY_DERIVATION_SALT: &[u8] = b"QbzNixCredentialEncryption2024";
|
const KEY_DERIVATION_SALT: &[u8] = b"QbzNixCredentialEncryption2024";
|
||||||
@@ -417,6 +418,80 @@ pub fn clear_qobuz_credentials() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── OAuth token persistence ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// OAuth login produces a `user_auth_token` instead of email+password.
|
||||||
|
// We persist it encrypted the same way as regular credentials so the user
|
||||||
|
// doesn't have to re-authenticate via browser on every app start.
|
||||||
|
// The token is re-used at bootstrap via `POST /user/login` with the
|
||||||
|
// `X-User-Auth-Token` header. If it has expired Qobuz returns a 4xx and
|
||||||
|
// we clear the stored token so the user sees the login screen normally.
|
||||||
|
|
||||||
|
fn get_oauth_token_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("qbz").join(OAUTH_TOKEN_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the OAuth `user_auth_token` to an AES-256-GCM encrypted file.
|
||||||
|
pub fn save_oauth_token(token: &str) -> Result<(), String> {
|
||||||
|
let path = get_oauth_token_path().ok_or("Could not determine config directory")?;
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-use the same encryption machinery: wrap the token as the "email" field
|
||||||
|
// of a throwaway QobuzCredentials so we don't duplicate the crypto code.
|
||||||
|
let placeholder = QobuzCredentials {
|
||||||
|
email: token.to_string(),
|
||||||
|
password: String::new(),
|
||||||
|
};
|
||||||
|
let encrypted = encrypt_credentials(&placeholder)?;
|
||||||
|
fs::write(&path, encrypted)
|
||||||
|
.map_err(|e| format!("Failed to write OAuth token file: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("[Credentials] OAuth token saved");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a previously saved OAuth `user_auth_token`, or `None` if absent.
|
||||||
|
pub fn load_oauth_token() -> Result<Option<String>, String> {
|
||||||
|
let path = match get_oauth_token_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("Failed to read OAuth token file: {}", e))?;
|
||||||
|
|
||||||
|
match decrypt_credentials(&content) {
|
||||||
|
Ok(placeholder) => {
|
||||||
|
log::info!("[Credentials] OAuth token loaded");
|
||||||
|
Ok(Some(placeholder.email))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[Credentials] Failed to decrypt OAuth token: {}", e);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the stored OAuth token (called on logout or token expiry).
|
||||||
|
pub fn clear_oauth_token() -> Result<(), String> {
|
||||||
|
if let Some(path) = get_oauth_token_path() {
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.map_err(|e| format!("Failed to remove OAuth token file: {}", e))?;
|
||||||
|
log::info!("[Credentials] OAuth token cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
import QualityBadge from './QualityBadge.svelte';
|
import QualityBadge from './QualityBadge.svelte';
|
||||||
import AudioOutputBadges from './AudioOutputBadges.svelte';
|
import AudioOutputBadges from './AudioOutputBadges.svelte';
|
||||||
import StackIcon from './StackIcon.svelte';
|
import StackIcon from './StackIcon.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t as translateStore } from '$lib/i18n';
|
||||||
import {
|
import {
|
||||||
subscribe as subscribeOffline,
|
subscribe as subscribeOffline,
|
||||||
isOffline as checkIsOffline,
|
isOffline as checkIsOffline,
|
||||||
@@ -145,13 +145,13 @@
|
|||||||
function getOfflineReasonText(reason: OfflineReason | null): string {
|
function getOfflineReasonText(reason: OfflineReason | null): string {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'no_network':
|
case 'no_network':
|
||||||
return $t('offline.noNetwork');
|
return $translateStore('offline.noNetwork');
|
||||||
case 'not_logged_in':
|
case 'not_logged_in':
|
||||||
return $t('offline.notLoggedIn');
|
return $translateStore('offline.notLoggedIn');
|
||||||
case 'manual_override':
|
case 'manual_override':
|
||||||
return $t('offline.manualMode');
|
return $translateStore('offline.manualMode');
|
||||||
default:
|
default:
|
||||||
return $t('offline.indicator');
|
return $translateStore('offline.indicator');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
class:disabled={controlsDisabled}
|
class:disabled={controlsDisabled}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
onclick={controlsDisabled ? undefined : onToggleShuffle}
|
onclick={controlsDisabled ? undefined : onToggleShuffle}
|
||||||
title={$t('player.shuffle')}
|
title={$translateStore('player.shuffle')}
|
||||||
>
|
>
|
||||||
<Shuffle size={16} />
|
<Shuffle size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
class:disabled={controlsDisabled}
|
class:disabled={controlsDisabled}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
onclick={controlsDisabled ? undefined : onSkipBack}
|
onclick={controlsDisabled ? undefined : onSkipBack}
|
||||||
title={$t('player.previous')}
|
title={$translateStore('player.previous')}
|
||||||
>
|
>
|
||||||
<SkipBack size={18} />
|
<SkipBack size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -271,7 +271,7 @@
|
|||||||
class:disabled={controlsDisabled}
|
class:disabled={controlsDisabled}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
onclick={controlsDisabled ? undefined : onTogglePlay}
|
onclick={controlsDisabled ? undefined : onTogglePlay}
|
||||||
title={isPlaying ? $t('player.pause') : $t('player.play')}
|
title={isPlaying ? $translateStore('player.pause') : $translateStore('player.play')}
|
||||||
>
|
>
|
||||||
{#if isPlaying}
|
{#if isPlaying}
|
||||||
<Pause size={20} />
|
<Pause size={20} />
|
||||||
@@ -285,7 +285,7 @@
|
|||||||
class:disabled={controlsDisabled}
|
class:disabled={controlsDisabled}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
onclick={controlsDisabled ? undefined : onSkipForward}
|
onclick={controlsDisabled ? undefined : onSkipForward}
|
||||||
title={$t('player.next')}
|
title={$translateStore('player.next')}
|
||||||
>
|
>
|
||||||
<SkipForward size={18} />
|
<SkipForward size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
class:disabled={controlsDisabled}
|
class:disabled={controlsDisabled}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
onclick={controlsDisabled ? undefined : onToggleRepeat}
|
onclick={controlsDisabled ? undefined : onToggleRepeat}
|
||||||
title={repeatMode === 'off' ? $t('player.repeat') : repeatMode === 'all' ? $t('player.repeatAll') : $t('player.repeatOne')}
|
title={repeatMode === 'off' ? $translateStore('player.repeat') : repeatMode === 'all' ? $translateStore('player.repeatAll') : $translateStore('player.repeatOne')}
|
||||||
>
|
>
|
||||||
{#if repeatMode === 'one'}
|
{#if repeatMode === 'one'}
|
||||||
<Repeat1 size={16} />
|
<Repeat1 size={16} />
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
class:disabled={controlsDisabled}
|
class:disabled={controlsDisabled}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
onclick={controlsDisabled ? undefined : onAddToPlaylist}
|
onclick={controlsDisabled ? undefined : onAddToPlaylist}
|
||||||
title={$t('actions.addToPlaylist')}
|
title={$translateStore('actions.addToPlaylist')}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
class:disabled={controlsDisabled}
|
class:disabled={controlsDisabled}
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
onclick={controlsDisabled ? undefined : onToggleFavorite}
|
onclick={controlsDisabled ? undefined : onToggleFavorite}
|
||||||
title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')}
|
title={isFavorite ? $translateStore('actions.removeFromFavorites') : $translateStore('actions.addToFavorites')}
|
||||||
>
|
>
|
||||||
<Heart size={16} fill={isFavorite ? 'currentColor' : 'none'} />
|
<Heart size={16} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
@@ -352,11 +352,11 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="song-info">
|
<div class="song-info">
|
||||||
<button class="song-title" title={$t('actions.trackInfo')} onclick={onTrackClick}>{trackTitle}</button>
|
<button class="song-title" title={$translateStore('actions.trackInfo')} onclick={onTrackClick}>{trackTitle}</button>
|
||||||
<div class="song-meta">
|
<div class="song-meta">
|
||||||
<StackIcon size={12} class="stack-icon" onClick={onContextClick} />
|
<StackIcon size={12} class="stack-icon" onClick={onContextClick} />
|
||||||
{#if artist}
|
{#if artist}
|
||||||
<button class="meta-link" onclick={onArtistClick} title={$t('actions.goToArtist')}>
|
<button class="meta-link" onclick={onArtistClick} title={$translateStore('actions.goToArtist')}>
|
||||||
{artist}
|
{artist}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
<span class="meta-separator">·</span>
|
<span class="meta-separator">·</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if album}
|
{#if album}
|
||||||
<button class="meta-link" onclick={onAlbumClick} title={$t('actions.goToAlbum')}>
|
<button class="meta-link" onclick={onAlbumClick} title={$translateStore('actions.goToAlbum')}>
|
||||||
{album}
|
{album}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<span>{$t('player.noTrackPlaying')}</span>
|
<span>{$translateStore('player.noTrackPlaying')}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -402,7 +402,7 @@
|
|||||||
class="control-btn"
|
class="control-btn"
|
||||||
class:cast-active={isCastConnected}
|
class:cast-active={isCastConnected}
|
||||||
onclick={onCast}
|
onclick={onCast}
|
||||||
title={isCastConnected ? $t('player.castingManage') : $t('player.castToDevice')}
|
title={isCastConnected ? $translateStore('player.castingManage') : $translateStore('player.castToDevice')}
|
||||||
>
|
>
|
||||||
<Cast size={16} />
|
<Cast size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -413,19 +413,19 @@
|
|||||||
class:disabled={isOffline}
|
class:disabled={isOffline}
|
||||||
onclick={isOffline ? undefined : onToggleLyrics}
|
onclick={isOffline ? undefined : onToggleLyrics}
|
||||||
disabled={isOffline}
|
disabled={isOffline}
|
||||||
title={isOffline ? $t('offline.featureDisabled') : $t('player.lyrics')}
|
title={isOffline ? $translateStore('offline.featureDisabled') : $translateStore('player.lyrics')}
|
||||||
aria-label={isOffline ? $t('offline.featureDisabled') : $t('player.lyrics')}
|
aria-label={isOffline ? $translateStore('offline.featureDisabled') : $translateStore('player.lyrics')}
|
||||||
>
|
>
|
||||||
<Mic2 size={16} aria-hidden="true" />
|
<Mic2 size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if onOpenMiniPlayer}
|
{#if onOpenMiniPlayer}
|
||||||
<button class="control-btn" onclick={onOpenMiniPlayer} title={$t('player.miniPlayer')}>
|
<button class="control-btn" onclick={onOpenMiniPlayer} title={$translateStore('player.miniPlayer')}>
|
||||||
<PictureInPicture2 size={16} />
|
<PictureInPicture2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button class="control-btn" onclick={onOpenFullScreen} title={$t('player.fullScreen')}>
|
<button class="control-btn" onclick={onOpenFullScreen} title={$translateStore('player.fullScreen')}>
|
||||||
<Maximize2 size={16} />
|
<Maximize2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -435,10 +435,10 @@
|
|||||||
class:norm-enabled={normalizationEnabled && (normalizationGain === null || normalizationGain === 1.0)}
|
class:norm-enabled={normalizationEnabled && (normalizationGain === null || normalizationGain === 1.0)}
|
||||||
onclick={onToggleNormalization}
|
onclick={onToggleNormalization}
|
||||||
title={!normalizationEnabled
|
title={!normalizationEnabled
|
||||||
? $t('player.normalizationOff')
|
? $translateStore('player.normalizationOff')
|
||||||
: normalizationGain !== null && normalizationGain !== 1.0
|
: normalizationGain !== null && normalizationGain !== 1.0
|
||||||
? $t('player.normalizationApplied')
|
? $translateStore('player.normalizationApplied')
|
||||||
: $t('player.normalizationOn')}
|
: $translateStore('player.normalizationOn')}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="norm-icon"
|
class="norm-icon"
|
||||||
@@ -453,7 +453,7 @@
|
|||||||
<button
|
<button
|
||||||
class="control-btn volume-btn"
|
class="control-btn volume-btn"
|
||||||
onclick={() => toggleMute()}
|
onclick={() => toggleMute()}
|
||||||
title={volume === 0 ? $t('player.unmute') : $t('player.mute')}
|
title={volume === 0 ? $translateStore('player.unmute') : $translateStore('player.mute')}
|
||||||
>
|
>
|
||||||
{#if volume === 0}
|
{#if volume === 0}
|
||||||
<VolumeX size={16} />
|
<VolumeX size={16} />
|
||||||
@@ -483,7 +483,7 @@
|
|||||||
<button
|
<button
|
||||||
class="control-btn volume-step-btn"
|
class="control-btn volume-step-btn"
|
||||||
onclick={() => onVolumeChange?.(Math.max(0, volume - 5))}
|
onclick={() => onVolumeChange?.(Math.max(0, volume - 5))}
|
||||||
title={$t('player.volumeDown')}
|
title={$translateStore('player.volumeDown')}
|
||||||
>
|
>
|
||||||
<Minus size={14} />
|
<Minus size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -491,7 +491,7 @@
|
|||||||
<button
|
<button
|
||||||
class="control-btn volume-step-btn"
|
class="control-btn volume-step-btn"
|
||||||
onclick={() => onVolumeChange?.(Math.min(100, volume + 5))}
|
onclick={() => onVolumeChange?.(Math.min(100, volume + 5))}
|
||||||
title={$t('player.volumeUp')}
|
title={$translateStore('player.volumeUp')}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -502,7 +502,7 @@
|
|||||||
class="control-btn queue-btn"
|
class="control-btn queue-btn"
|
||||||
class:active={queueOpen}
|
class:active={queueOpen}
|
||||||
onclick={onOpenQueue}
|
onclick={onOpenQueue}
|
||||||
title={$t('player.queue')}
|
title={$translateStore('player.queue')}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 256 256" class="queue-icon" class:open={queueOpen}>
|
<svg width="18" height="18" viewBox="0 0 256 256" class="queue-icon" class:open={queueOpen}>
|
||||||
<path class="queue-play" d="M240,160l-64,40V120Z"/>
|
<path class="queue-play" d="M240,160l-64,40V120Z"/>
|
||||||
|
|||||||
Reference in new issue
Block a user