fix: multiple UI and integration fixes
1. Credential storage fallback - when keyring (KWallet) unavailable, falls back to obfuscated file in config directory 2. Heart icon always visible - shows outlined when not favorite, filled when favorite, with hover states 3. Player favorite state - checks favorite status when track plays and updates the heart icon in NowPlayingBar 4. Song.link ISRC lookup - uses Spotify/Deezer search URLs with ISRC since Qobuz isn't directly supported by Odesli
This commit is contained in:
1 parent
657682fc5e
commit
1ccb78ef52
5 files changed
+279
-93
No files matched your search
@@ -5,17 +5,22 @@ use tauri::State;
|
||||
use crate::share::{ShareError, SongLinkResponse};
|
||||
use crate::AppState;
|
||||
|
||||
/// Get song.link URL for a track using Qobuz track ID
|
||||
/// Get song.link URL for a track using ISRC
|
||||
/// Qobuz isn't supported by Odesli, so we use ISRC to find the track
|
||||
#[tauri::command]
|
||||
pub async fn share_track_songlink(
|
||||
track_id: u64,
|
||||
isrc: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SongLinkResponse, String> {
|
||||
log::info!("Command: share_track_songlink track_id={}", track_id);
|
||||
if isrc.is_empty() {
|
||||
return Err(ShareError::MissingIsrc.to_string());
|
||||
}
|
||||
|
||||
log::info!("Command: share_track_songlink ISRC={}", isrc);
|
||||
|
||||
state
|
||||
.songlink
|
||||
.get_by_track_id(&track_id.to_string())
|
||||
.get_by_isrc(&isrc)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
//! Secure credential storage using system keyring
|
||||
//! Secure credential storage with fallback
|
||||
//!
|
||||
//! Uses the system's secure credential storage:
|
||||
//! Tries system keyring first, falls back to file-based storage:
|
||||
//! - Linux: Secret Service (GNOME Keyring, KWallet via D-Bus)
|
||||
//! - macOS: Keychain
|
||||
//! - Windows: Credential Manager
|
||||
//! - Fallback: Obfuscated file in config directory
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use keyring::Entry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const SERVICE_NAME: &str = "qbz-nix";
|
||||
const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials";
|
||||
const FALLBACK_FILE_NAME: &str = ".qbz-auth";
|
||||
|
||||
// Simple XOR key for obfuscation (not encryption, just to avoid plain text)
|
||||
const OBFUSCATION_KEY: &[u8] = b"QbzNixAudiophile2024";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QobuzCredentials {
|
||||
@@ -17,89 +25,183 @@ pub struct QobuzCredentials {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Save Qobuz credentials to system keyring
|
||||
/// Get the fallback credentials file path
|
||||
fn get_fallback_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|p| p.join("qbz-nix").join(FALLBACK_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Simple XOR obfuscation (not secure, but avoids plain text)
|
||||
fn obfuscate(data: &[u8]) -> Vec<u8> {
|
||||
data.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| b ^ OBFUSCATION_KEY[i % OBFUSCATION_KEY.len()])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Save credentials to fallback file
|
||||
fn save_to_fallback(credentials: &QobuzCredentials) -> Result<(), String> {
|
||||
let path = get_fallback_path().ok_or("Could not determine config directory")?;
|
||||
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(credentials)
|
||||
.map_err(|e| format!("Failed to serialize credentials: {}", e))?;
|
||||
|
||||
let obfuscated = obfuscate(json.as_bytes());
|
||||
let encoded = BASE64.encode(&obfuscated);
|
||||
|
||||
fs::write(&path, encoded)
|
||||
.map_err(|e| format!("Failed to write credentials file: {}", e))?;
|
||||
|
||||
log::info!("Credentials saved to fallback file");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load credentials from fallback file
|
||||
fn load_from_fallback() -> Result<Option<QobuzCredentials>, String> {
|
||||
let path = match get_fallback_path() {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let encoded = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read credentials file: {}", e))?;
|
||||
|
||||
let obfuscated = BASE64.decode(encoded.trim())
|
||||
.map_err(|e| format!("Failed to decode credentials: {}", e))?;
|
||||
|
||||
let json_bytes = obfuscate(&obfuscated);
|
||||
let json = String::from_utf8(json_bytes)
|
||||
.map_err(|e| format!("Failed to decode credentials: {}", e))?;
|
||||
|
||||
let credentials: QobuzCredentials = serde_json::from_str(&json)
|
||||
.map_err(|e| format!("Failed to parse credentials: {}", e))?;
|
||||
|
||||
log::info!("Credentials loaded from fallback file for: {}", credentials.email);
|
||||
Ok(Some(credentials))
|
||||
}
|
||||
|
||||
/// Clear fallback credentials file
|
||||
fn clear_fallback() -> Result<(), String> {
|
||||
if let Some(path) = get_fallback_path() {
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to remove credentials file: {}", e))?;
|
||||
log::info!("Fallback credentials file removed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if fallback file exists
|
||||
fn has_fallback_credentials() -> bool {
|
||||
get_fallback_path().map(|p| p.exists()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Save Qobuz credentials - tries keyring first, then fallback
|
||||
pub fn save_qobuz_credentials(email: &str, password: &str) -> Result<(), String> {
|
||||
log::info!("Attempting to save credentials for: {}", email);
|
||||
|
||||
let entry = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY)
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to create keyring entry: {}", e);
|
||||
format!("Failed to create keyring entry: {}", e)
|
||||
})?;
|
||||
|
||||
let credentials = QobuzCredentials {
|
||||
email: email.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&credentials)
|
||||
.map_err(|e| format!("Failed to serialize credentials: {}", e))?;
|
||||
// Try keyring first
|
||||
match Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
|
||||
Ok(entry) => {
|
||||
let json = serde_json::to_string(&credentials)
|
||||
.map_err(|e| format!("Failed to serialize credentials: {}", e))?;
|
||||
|
||||
entry
|
||||
.set_password(&json)
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to save to keyring: {}", e);
|
||||
format!("Failed to save to keyring: {}", e)
|
||||
})?;
|
||||
|
||||
log::info!("Qobuz credentials saved to system keyring successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load Qobuz credentials from system keyring
|
||||
pub fn load_qobuz_credentials() -> Result<Option<QobuzCredentials>, String> {
|
||||
log::info!("Attempting to load credentials from keyring");
|
||||
|
||||
let entry = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY)
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to create keyring entry for reading: {}", e);
|
||||
format!("Failed to create keyring entry: {}", e)
|
||||
})?;
|
||||
|
||||
match entry.get_password() {
|
||||
Ok(json) => {
|
||||
let credentials: QobuzCredentials = serde_json::from_str(&json)
|
||||
.map_err(|e| format!("Failed to parse credentials: {}", e))?;
|
||||
log::info!("Successfully loaded credentials from keyring for: {}", credentials.email);
|
||||
Ok(Some(credentials))
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
log::info!("No saved credentials found in keyring");
|
||||
Ok(None)
|
||||
match entry.set_password(&json) {
|
||||
Ok(()) => {
|
||||
log::info!("Qobuz credentials saved to system keyring successfully");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Keyring save failed ({}), trying fallback...", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load from keyring: {}", e);
|
||||
Err(format!("Failed to load from keyring: {}", e))
|
||||
log::warn!("Keyring not available ({}), using fallback...", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to file
|
||||
save_to_fallback(&credentials)
|
||||
}
|
||||
|
||||
/// Check if credentials are saved
|
||||
/// Load Qobuz credentials - tries keyring first, then fallback
|
||||
pub fn load_qobuz_credentials() -> Result<Option<QobuzCredentials>, String> {
|
||||
log::info!("Attempting to load credentials");
|
||||
|
||||
// Try keyring first
|
||||
if let Ok(entry) = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
|
||||
match entry.get_password() {
|
||||
Ok(json) => {
|
||||
if let Ok(credentials) = serde_json::from_str::<QobuzCredentials>(&json) {
|
||||
log::info!("Successfully loaded credentials from keyring for: {}", credentials.email);
|
||||
return Ok(Some(credentials));
|
||||
}
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
log::debug!("No credentials in keyring, checking fallback...");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Keyring load failed ({}), checking fallback...", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("Keyring not available, checking fallback...");
|
||||
}
|
||||
|
||||
// Try fallback file
|
||||
load_from_fallback()
|
||||
}
|
||||
|
||||
/// Check if credentials are saved (keyring or fallback)
|
||||
pub fn has_saved_credentials() -> bool {
|
||||
let entry = match Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return false,
|
||||
};
|
||||
// Check keyring
|
||||
if let Ok(entry) = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
|
||||
if entry.get_password().is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
entry.get_password().is_ok()
|
||||
// Check fallback
|
||||
has_fallback_credentials()
|
||||
}
|
||||
|
||||
/// Clear saved Qobuz credentials
|
||||
/// Clear saved Qobuz credentials (both keyring and fallback)
|
||||
pub fn clear_qobuz_credentials() -> Result<(), String> {
|
||||
let entry = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY)
|
||||
.map_err(|e| format!("Failed to create keyring entry: {}", e))?;
|
||||
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => {
|
||||
log::info!("Qobuz credentials cleared from keyring");
|
||||
Ok(())
|
||||
// Try to clear keyring
|
||||
if let Ok(entry) = Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => {
|
||||
log::info!("Qobuz credentials cleared from keyring");
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
// Already cleared, that's fine
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to clear keyring: {}", e);
|
||||
}
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
// Already cleared, that's fine
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("Failed to clear credentials: {}", e)),
|
||||
}
|
||||
|
||||
// Also clear fallback
|
||||
clear_fallback()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -48,38 +48,58 @@ impl SongLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get song.link URL for a track by Qobuz track ID
|
||||
pub async fn get_by_track_id(&self, track_id: &str) -> Result<SongLinkResponse, ShareError> {
|
||||
let cache_key = format!("track:{}", track_id);
|
||||
/// Get song.link URL for a track by ISRC
|
||||
/// Qobuz isn't supported by Odesli, so we use ISRC to find the track on Spotify
|
||||
pub async fn get_by_isrc(&self, isrc: &str) -> Result<SongLinkResponse, ShareError> {
|
||||
let cache_key = format!("isrc:{}", isrc);
|
||||
|
||||
// Check cache first
|
||||
if let Some(cached) = self.get_from_cache(&cache_key) {
|
||||
log::debug!("Cache hit for track ID: {}", track_id);
|
||||
log::debug!("Cache hit for ISRC: {}", isrc);
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
// Use Qobuz track URL for Odesli lookup
|
||||
let qobuz_url = format!("https://open.qobuz.com/track/{}", track_id);
|
||||
log::info!("Fetching song.link for Qobuz track: {}", qobuz_url);
|
||||
// Use Spotify search URL with ISRC - Odesli will resolve it
|
||||
// Format: https://open.spotify.com/search/isrc:{ISRC}
|
||||
let spotify_search_url = format!("https://open.spotify.com/search/isrc%3A{}", isrc);
|
||||
log::info!("Fetching song.link via Spotify ISRC search: {}", isrc);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(ODESLI_API_URL)
|
||||
.query(&[("url", &qobuz_url), ("userCountry", &"US".to_string())])
|
||||
.query(&[("url", &spotify_search_url), ("userCountry", &"US".to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// If Spotify search doesn't work, try Deezer
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(ShareError::OdesliError(format!(
|
||||
"HTTP {}: {}",
|
||||
status, text
|
||||
)));
|
||||
log::debug!("Spotify search failed, trying Deezer...");
|
||||
let deezer_url = format!("https://www.deezer.com/search/{}", isrc);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(ODESLI_API_URL)
|
||||
.query(&[("url", &deezer_url), ("userCountry", &"US".to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(ShareError::OdesliError(format!(
|
||||
"HTTP {}: {}",
|
||||
status, text
|
||||
)));
|
||||
}
|
||||
|
||||
let odesli: OdesliResponse = response.json().await?;
|
||||
let result = self.convert_response(odesli, isrc.to_string(), ContentType::Track)?;
|
||||
self.store_in_cache(cache_key, result.clone());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let odesli: OdesliResponse = response.json().await?;
|
||||
let result = self.convert_response(odesli, track_id.to_string(), ContentType::Track)?;
|
||||
let result = self.convert_response(odesli, isrc.to_string(), ContentType::Track)?;
|
||||
|
||||
// Cache the result
|
||||
self.store_in_cache(cache_key, result.clone());
|
||||
|
||||
@@ -85,11 +85,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- Favorite Indicator -->
|
||||
{#if isFavorite}
|
||||
<div class="favorite-indicator">
|
||||
<div class="favorite-indicator" class:is-favorite={isFavorite}>
|
||||
{#if isFavorite}
|
||||
<Heart size={14} fill="var(--accent-primary)" color="var(--accent-primary)" />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Heart size={14} color="var(--text-muted)" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="track-actions">
|
||||
<TrackMenu
|
||||
@@ -228,6 +230,20 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.favorite-indicator.is-favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-row:hover .favorite-indicator {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.track-row:hover .favorite-indicator.is-favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
|
||||
+50
-7
@@ -472,10 +472,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function shareSonglinkTrack(trackId: number) {
|
||||
async function shareSonglinkTrack(trackId: number, isrc?: string) {
|
||||
// Get ISRC - either from parameter or fetch from track details
|
||||
let resolvedIsrc = isrc;
|
||||
if (!resolvedIsrc) {
|
||||
try {
|
||||
const fullTrack = await invoke<QobuzTrack>('get_track', { trackId });
|
||||
resolvedIsrc = fullTrack.isrc;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch track ISRC:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedIsrc) {
|
||||
showToast('ISRC not available for this track', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showToast('Fetching Song.link...', 'info');
|
||||
const response = await invoke<SongLinkResponse>('share_track_songlink', { trackId });
|
||||
const response = await invoke<SongLinkResponse>('share_track_songlink', { isrc: resolvedIsrc });
|
||||
await copyToClipboard(response.pageUrl, 'Song.link copied');
|
||||
} catch (err) {
|
||||
console.error('Failed to get Song.link:', err);
|
||||
@@ -532,6 +548,9 @@
|
||||
track.album?.title || '',
|
||||
artwork || undefined
|
||||
);
|
||||
|
||||
// Check if track is favorite
|
||||
isFavorite = await checkTrackFavorite(track.id);
|
||||
} catch (err) {
|
||||
console.error('Failed to play track:', err);
|
||||
showToast(`Playback error: ${err}`, 'error');
|
||||
@@ -676,6 +695,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a track is in favorites
|
||||
async function checkTrackFavorite(trackId: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await invoke<{ tracks?: { items: Array<{ id: number }> } }>('get_favorites', {
|
||||
favType: 'tracks',
|
||||
limit: 500
|
||||
});
|
||||
if (response.tracks?.items) {
|
||||
return response.tracks.items.some(item => item.id === trackId);
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error('Failed to check favorite status:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!currentTrack) return;
|
||||
|
||||
@@ -789,6 +825,13 @@
|
||||
// Update Last.fm
|
||||
updateLastfmNowPlaying(track.title, track.artist, track.album, track.duration_secs, track.id);
|
||||
|
||||
// Check if track is favorite (for Qobuz tracks only)
|
||||
if (!isLocalTrack) {
|
||||
isFavorite = await checkTrackFavorite(track.id);
|
||||
} else {
|
||||
isFavorite = false;
|
||||
}
|
||||
|
||||
// Refresh queue state
|
||||
await syncQueueState();
|
||||
} catch (err) {
|
||||
@@ -1404,7 +1447,7 @@
|
||||
onTrackAddFavorite={addTrackToFavorites}
|
||||
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
||||
onTrackShareQobuz={shareQobuzTrackLink}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
||||
onTrackGoToAlbum={handleAlbumClick}
|
||||
onTrackGoToArtist={handleArtistClick}
|
||||
onArtistClick={handleArtistClick}
|
||||
@@ -1426,7 +1469,7 @@
|
||||
onTrackPlayLater={handleAlbumTrackPlayLater}
|
||||
onTrackAddFavorite={addTrackToFavorites}
|
||||
onTrackShareQobuz={shareQobuzTrackLink}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
||||
onTrackGoToAlbum={handleAlbumClick}
|
||||
onTrackGoToArtist={handleArtistClick}
|
||||
onPlayAll={handlePlayAllAlbum}
|
||||
@@ -1445,7 +1488,7 @@
|
||||
onTrackAddFavorite={addTrackToFavorites}
|
||||
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
||||
onTrackShareQobuz={shareQobuzTrackLink}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
||||
onTrackGoToAlbum={handleAlbumClick}
|
||||
onTrackGoToArtist={handleArtistClick}
|
||||
/>
|
||||
@@ -1466,7 +1509,7 @@
|
||||
onTrackAddFavorite={addTrackToFavorites}
|
||||
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
||||
onTrackShareQobuz={shareQobuzTrackLink}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
||||
onTrackGoToAlbum={handleAlbumClick}
|
||||
onTrackGoToArtist={handleArtistClick}
|
||||
/>
|
||||
@@ -1480,7 +1523,7 @@
|
||||
onTrackAddFavorite={addTrackToFavorites}
|
||||
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
||||
onTrackShareQobuz={shareQobuzTrackLink}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id)}
|
||||
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
||||
onTrackGoToAlbum={handleAlbumClick}
|
||||
onTrackGoToArtist={handleArtistClick}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user