feat(radio): add Qobuz artist/track radio with submenu selection
Add /radio/artist and /radio/track Qobuz API endpoints alongside the existing /radio/album. TrackMenu now shows a radio submenu with QBZ Radio (RadioPoolBuilder) and Qobuz Radio options when both are available. Artist header radio button opens a dropdown to choose between the two engines. Backend: get_radio_artist(), get_radio_track() in client.rs, v2_create_qobuz_artist_radio and v2_create_qobuz_track_radio commands. Frontend: TrackMenu radio submenu with mutual exclusion against share/download submenus, ArtistDetailView dropdown, LabelView support. i18n keys added to all four locale files.
This commit is contained in:
11 files changed
+571
-19
No files matched your search
@@ -1082,6 +1082,74 @@ impl QobuzClient {
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Fetch radio tracks for an artist (`/radio/artist`).
|
||||
pub async fn get_radio_artist(
|
||||
&self,
|
||||
artist_id: &str,
|
||||
) -> Result<RadioResponse> {
|
||||
let url = endpoints::build_url(paths::RADIO_ARTIST);
|
||||
|
||||
let http_response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.authenticated_headers().await?)
|
||||
.query(&[("artist_id", artist_id)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = http_response.status();
|
||||
log::debug!("[API] get_radio_artist({}) status={}", artist_id, status);
|
||||
|
||||
if !status.is_success() {
|
||||
log::error!(
|
||||
"[API] get_radio_artist({}) unexpected status={}",
|
||||
artist_id,
|
||||
status
|
||||
);
|
||||
return Err(ApiError::ApiResponse(format!(
|
||||
"get_radio_artist({}) status {}",
|
||||
artist_id, status
|
||||
)));
|
||||
}
|
||||
|
||||
let response: Value = http_response.json().await?;
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Fetch radio tracks for a track (`/radio/track`).
|
||||
pub async fn get_radio_track(
|
||||
&self,
|
||||
track_id: &str,
|
||||
) -> Result<RadioResponse> {
|
||||
let url = endpoints::build_url(paths::RADIO_TRACK);
|
||||
|
||||
let http_response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.authenticated_headers().await?)
|
||||
.query(&[("track_id", track_id)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = http_response.status();
|
||||
log::debug!("[API] get_radio_track({}) status={}", track_id, status);
|
||||
|
||||
if !status.is_success() {
|
||||
log::error!(
|
||||
"[API] get_radio_track({}) unexpected status={}",
|
||||
track_id,
|
||||
status
|
||||
);
|
||||
return Err(ApiError::ApiResponse(format!(
|
||||
"get_radio_track({}) status {}",
|
||||
track_id, status
|
||||
)));
|
||||
}
|
||||
|
||||
let response: Value = http_response.json().await?;
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get playlist by ID (paginates automatically to fetch all tracks)
|
||||
///
|
||||
/// After the first page, remaining pages are fetched concurrently
|
||||
|
||||
@@ -58,6 +58,8 @@ pub mod paths {
|
||||
|
||||
// Radio
|
||||
pub const RADIO_ALBUM: &str = "/radio/album";
|
||||
pub const RADIO_ARTIST: &str = "/radio/artist";
|
||||
pub const RADIO_TRACK: &str = "/radio/track";
|
||||
|
||||
// Discover (home page content)
|
||||
pub const DISCOVER_INDEX: &str = "/discover/index";
|
||||
|
||||
@@ -4381,6 +4381,140 @@ pub async fn v2_create_album_radio(
|
||||
Ok(context_id)
|
||||
}
|
||||
|
||||
/// Create artist radio using the Qobuz `/radio/artist` API endpoint.
|
||||
///
|
||||
/// Like album radio, this calls the Qobuz API directly — the endpoint returns
|
||||
/// recommended tracks in a single GET response.
|
||||
#[tauri::command]
|
||||
pub async fn v2_create_qobuz_artist_radio(
|
||||
artist_id: u64,
|
||||
artist_name: String,
|
||||
state: State<'_, AppState>,
|
||||
blacklist_state: State<'_, BlacklistState>,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<String, String> {
|
||||
runtime
|
||||
.manager()
|
||||
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let client = state.client.read().await.clone();
|
||||
let radio_response = client
|
||||
.get_radio_artist(&artist_id.to_string())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch artist radio: {}", e))?;
|
||||
|
||||
let track_ids: Vec<u64> = radio_response.tracks.items.iter().map(|track| track.id).collect();
|
||||
|
||||
if track_ids.is_empty() {
|
||||
return Err("No radio tracks returned for this artist".to_string());
|
||||
}
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
for track_id in track_ids {
|
||||
if let Ok(track) = client.get_track(track_id).await {
|
||||
if let Some(ref performer) = track.performer {
|
||||
if blacklist_state.is_blacklisted(performer.id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
tracks.push(track);
|
||||
}
|
||||
}
|
||||
|
||||
if tracks.is_empty() {
|
||||
return Err("Failed to fetch any radio tracks".to_string());
|
||||
}
|
||||
|
||||
let queue_tracks: Vec<CoreQueueTrack> =
|
||||
tracks.iter().map(track_to_queue_track_from_api).collect();
|
||||
let bridge = bridge.get().await;
|
||||
bridge.set_queue(queue_tracks, Some(0)).await;
|
||||
|
||||
let queue_track_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
|
||||
let context_id = format!("qobuz_artist_radio_{}_{}", artist_id, chrono::Utc::now().timestamp());
|
||||
let context = PlaybackContext::new(
|
||||
ContextType::Radio,
|
||||
context_id.clone(),
|
||||
artist_name,
|
||||
ContentSource::Qobuz,
|
||||
queue_track_ids,
|
||||
0,
|
||||
);
|
||||
state.context.set_context(context);
|
||||
|
||||
Ok(context_id)
|
||||
}
|
||||
|
||||
/// Create track radio using the Qobuz `/radio/track` API endpoint.
|
||||
///
|
||||
/// Like album radio, this calls the Qobuz API directly — the endpoint returns
|
||||
/// recommended tracks in a single GET response.
|
||||
#[tauri::command]
|
||||
pub async fn v2_create_qobuz_track_radio(
|
||||
track_id: u64,
|
||||
track_name: String,
|
||||
state: State<'_, AppState>,
|
||||
blacklist_state: State<'_, BlacklistState>,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<String, String> {
|
||||
runtime
|
||||
.manager()
|
||||
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let client = state.client.read().await.clone();
|
||||
let radio_response = client
|
||||
.get_radio_track(&track_id.to_string())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch track radio: {}", e))?;
|
||||
|
||||
let fetched_ids: Vec<u64> = radio_response.tracks.items.iter().map(|track| track.id).collect();
|
||||
|
||||
if fetched_ids.is_empty() {
|
||||
return Err("No radio tracks returned for this track".to_string());
|
||||
}
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
for next_id in fetched_ids {
|
||||
if let Ok(track) = client.get_track(next_id).await {
|
||||
if let Some(ref performer) = track.performer {
|
||||
if blacklist_state.is_blacklisted(performer.id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
tracks.push(track);
|
||||
}
|
||||
}
|
||||
|
||||
if tracks.is_empty() {
|
||||
return Err("Failed to fetch any radio tracks".to_string());
|
||||
}
|
||||
|
||||
let queue_tracks: Vec<CoreQueueTrack> =
|
||||
tracks.iter().map(track_to_queue_track_from_api).collect();
|
||||
let bridge = bridge.get().await;
|
||||
bridge.set_queue(queue_tracks, Some(0)).await;
|
||||
|
||||
let queue_track_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
|
||||
let context_id = format!("qobuz_track_radio_{}_{}", track_id, chrono::Utc::now().timestamp());
|
||||
let context = PlaybackContext::new(
|
||||
ContextType::Radio,
|
||||
context_id.clone(),
|
||||
track_name,
|
||||
ContentSource::Qobuz,
|
||||
queue_track_ids,
|
||||
0,
|
||||
);
|
||||
state.context.set_context(context);
|
||||
|
||||
Ok(context_id)
|
||||
}
|
||||
|
||||
fn track_to_queue_track_from_api(track: &crate::api::Track) -> CoreQueueTrack {
|
||||
let artwork_url = track
|
||||
.album
|
||||
|
||||
@@ -837,6 +837,8 @@ pub fn run() {
|
||||
commands_v2::v2_create_artist_radio,
|
||||
commands_v2::v2_create_track_radio,
|
||||
commands_v2::v2_create_album_radio,
|
||||
commands_v2::v2_create_qobuz_artist_radio,
|
||||
commands_v2::v2_create_qobuz_track_radio,
|
||||
commands_v2::v2_delete_playlist_folder,
|
||||
commands_v2::v2_reorder_playlist_folders,
|
||||
commands_v2::v2_move_playlist_to_folder,
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
onPlayFromHere?: () => void;
|
||||
onPlayNext?: () => void;
|
||||
onPlayLater?: () => void;
|
||||
onCreateRadio?: () => void;
|
||||
onCreateQbzRadio?: () => void;
|
||||
onCreateQobuzRadio?: () => void;
|
||||
onAddFavorite?: () => void;
|
||||
onAddToPlaylist?: () => void;
|
||||
onRemoveFromPlaylist?: () => void;
|
||||
@@ -48,6 +49,8 @@
|
||||
isTrackDownloaded?: boolean;
|
||||
onReDownload?: () => void;
|
||||
onRemoveDownload?: () => void;
|
||||
contextMenuPosition?: { x: number; y: number } | null;
|
||||
onContextMenuClosed?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -56,7 +59,8 @@
|
||||
onPlayFromHere,
|
||||
onPlayNext,
|
||||
onPlayLater,
|
||||
onCreateRadio,
|
||||
onCreateQbzRadio,
|
||||
onCreateQobuzRadio,
|
||||
onAddFavorite,
|
||||
onAddToPlaylist,
|
||||
onRemoveFromPlaylist,
|
||||
@@ -69,7 +73,9 @@
|
||||
onDownload,
|
||||
isTrackDownloaded = false,
|
||||
onReDownload,
|
||||
onRemoveDownload
|
||||
onRemoveDownload,
|
||||
contextMenuPosition = null,
|
||||
onContextMenuClosed
|
||||
}: Props = $props();
|
||||
|
||||
const menuId = allocateTrackMenuId();
|
||||
@@ -88,7 +94,15 @@
|
||||
let menuStyle = $state('');
|
||||
let submenuStyle = $state('');
|
||||
let downloadSubmenuStyle = $state('');
|
||||
let radioOpen = $state(false);
|
||||
let radioTriggerRef = $state<HTMLDivElement | null>(null);
|
||||
let radioSubmenuEl = $state<HTMLDivElement | null>(null);
|
||||
let radioSubmenuStyle = $state('');
|
||||
let isHoveringAnyMenu = $state(false);
|
||||
let isHoveringShareSubmenu = $state(false);
|
||||
let isHoveringDownloadSubmenu = $state(false);
|
||||
let isHoveringRadioSubmenu = $state(false);
|
||||
let contextMode = $state(false);
|
||||
|
||||
// Portal action - moves element to body to escape stacking context
|
||||
function portal(node: HTMLElement) {
|
||||
@@ -102,7 +116,8 @@
|
||||
};
|
||||
}
|
||||
|
||||
const hasPlayback = $derived(!!(onPlayNow || onPlayTrackOnly || onPlayFromHere || onPlayNext || onPlayLater || onCreateRadio));
|
||||
const hasRadio = $derived(!!(onCreateQbzRadio || onCreateQobuzRadio));
|
||||
const hasPlayback = $derived(!!(onPlayNow || onPlayTrackOnly || onPlayFromHere || onPlayNext || onPlayLater || hasRadio));
|
||||
const hasLibrary = $derived(!!(onAddFavorite || onAddToPlaylist || onRemoveFromPlaylist || onFindReplacement));
|
||||
const hasShare = $derived(!!(onShareQobuz || onShareSonglink));
|
||||
const hasDownload = $derived(!!onDownload || isTrackDownloaded);
|
||||
@@ -113,6 +128,14 @@
|
||||
isOpen = false;
|
||||
shareOpen = false;
|
||||
downloadOpen = false;
|
||||
radioOpen = false;
|
||||
isHoveringShareSubmenu = false;
|
||||
isHoveringDownloadSubmenu = false;
|
||||
isHoveringRadioSubmenu = false;
|
||||
if (contextMode) {
|
||||
contextMode = false;
|
||||
onContextMenuClosed?.();
|
||||
}
|
||||
if (options?.clearActive !== false && getActiveTrackMenuId() === menuId) {
|
||||
setActiveTrackMenuId(null);
|
||||
}
|
||||
@@ -123,13 +146,29 @@
|
||||
isOpen = true;
|
||||
shareOpen = false;
|
||||
downloadOpen = false;
|
||||
radioOpen = false;
|
||||
if (!contextMode) {
|
||||
await setMenuPosition();
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: Event) {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
// Check if click is outside both the trigger container and the menu (which is in portal)
|
||||
|
||||
// In context mode, ANY click anywhere dismisses the menu (standard OS behavior).
|
||||
if (contextMode) {
|
||||
const isInsideMenu = (menuEl && menuEl.contains(target))
|
||||
|| (submenuEl && submenuEl.contains(target))
|
||||
|| (downloadSubmenuEl && downloadSubmenuEl.contains(target))
|
||||
|| (radioSubmenuEl && radioSubmenuEl.contains(target));
|
||||
if (!isInsideMenu) {
|
||||
closeMenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger-button mode: check if click is outside both the trigger container and the menu (which is in portal)
|
||||
const isOutsideTrigger = menuRef && !menuRef.contains(target);
|
||||
const isOutsideMenu = menuEl && !menuEl.contains(target);
|
||||
const isOutsideSubmenu = submenuEl && !submenuEl.contains(target);
|
||||
@@ -181,6 +220,29 @@
|
||||
menuStyle = `left: ${left}px; top: ${top}px; --arrow-top: ${arrowTop}px;`;
|
||||
}
|
||||
|
||||
async function setMenuPositionAtPoint(x: number, y: number) {
|
||||
await tick();
|
||||
if (!menuEl) return;
|
||||
|
||||
const menuRect = menuEl.getBoundingClientRect();
|
||||
const padding = 8;
|
||||
|
||||
let left = x;
|
||||
let top = y;
|
||||
|
||||
if (left + menuRect.width > window.innerWidth - padding) {
|
||||
left = Math.max(padding, window.innerWidth - menuRect.width - padding);
|
||||
}
|
||||
if (left < padding) left = padding;
|
||||
|
||||
if (top + menuRect.height > window.innerHeight - padding) {
|
||||
top = Math.max(padding, window.innerHeight - menuRect.height - padding);
|
||||
}
|
||||
if (top < padding) top = padding;
|
||||
|
||||
menuStyle = `left: ${left}px; top: ${top}px;`;
|
||||
}
|
||||
|
||||
async function setSubmenuPosition() {
|
||||
await tick();
|
||||
if (!shareTriggerRef || !submenuEl) return;
|
||||
@@ -239,6 +301,35 @@
|
||||
downloadSubmenuStyle = `left: ${left}px; top: ${top}px;`;
|
||||
}
|
||||
|
||||
async function setRadioSubmenuPosition() {
|
||||
await tick();
|
||||
if (!radioTriggerRef || !radioSubmenuEl) return;
|
||||
|
||||
const triggerRect = radioTriggerRef.getBoundingClientRect();
|
||||
const submenuRect = radioSubmenuEl.getBoundingClientRect();
|
||||
const padding = 8;
|
||||
|
||||
const spaceRight = window.innerWidth - triggerRect.right;
|
||||
const openRight = spaceRight >= submenuRect.width + padding;
|
||||
|
||||
let left = openRight
|
||||
? triggerRect.right + 6
|
||||
: triggerRect.left - submenuRect.width - 6;
|
||||
let top = triggerRect.top - 6;
|
||||
|
||||
if (left < padding) left = padding;
|
||||
if (left + submenuRect.width > window.innerWidth - padding) {
|
||||
left = Math.max(padding, window.innerWidth - submenuRect.width - padding);
|
||||
}
|
||||
|
||||
if (top + submenuRect.height > window.innerHeight - padding) {
|
||||
top = window.innerHeight - submenuRect.height - padding;
|
||||
}
|
||||
if (top < padding) top = padding;
|
||||
|
||||
radioSubmenuStyle = `left: ${left}px; top: ${top}px;`;
|
||||
}
|
||||
|
||||
function handleAction(action?: () => void) {
|
||||
if (!action) return;
|
||||
action();
|
||||
@@ -254,11 +345,45 @@
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const pos = contextMenuPosition;
|
||||
if (pos && hasMenu) {
|
||||
contextMode = true;
|
||||
void openMenu().then(() => setMenuPositionAtPoint(pos.x, pos.y));
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-close Share submenu after 1s of inactivity (not hovering it).
|
||||
$effect(() => {
|
||||
if (!shareOpen) return;
|
||||
if (isHoveringShareSubmenu) return;
|
||||
const timer = setTimeout(() => { shareOpen = false; }, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
// Auto-close Download submenu after 1s of inactivity (not hovering it).
|
||||
$effect(() => {
|
||||
if (!downloadOpen) return;
|
||||
if (isHoveringDownloadSubmenu) return;
|
||||
const timer = setTimeout(() => { downloadOpen = false; }, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
// Auto-close Radio submenu after 1s of inactivity (not hovering it).
|
||||
$effect(() => {
|
||||
if (!radioOpen) return;
|
||||
if (isHoveringRadioSubmenu) return;
|
||||
const timer = setTimeout(() => { radioOpen = false; }, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
// Ensure position + openSide classes are applied even if the initial openMenu()
|
||||
// call returns early due to timing.
|
||||
if (!contextMode) {
|
||||
setMenuPosition();
|
||||
}
|
||||
|
||||
// Use capture phase so stopPropagation in other UI doesn't prevent close.
|
||||
// Register multiple event types for better cross-platform reliability.
|
||||
@@ -266,10 +391,12 @@
|
||||
document.addEventListener('pointerdown', handleClickOutside, outsideListenerOptions);
|
||||
document.addEventListener('mousedown', handleClickOutside, outsideListenerOptions);
|
||||
document.addEventListener('touchstart', handleClickOutside, outsideListenerOptions);
|
||||
document.addEventListener('contextmenu', handleClickOutside, outsideListenerOptions);
|
||||
|
||||
// Auto-close after inactivity when the cursor is not hovering any menu/submenu.
|
||||
// Context menus use half the idle timeout for snappier dismissal.
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const idleMs = MENU_INACTIVITY_TIMEOUT;
|
||||
const idleMs = contextMode ? MENU_INACTIVITY_TIMEOUT / 2 : MENU_INACTIVITY_TIMEOUT;
|
||||
|
||||
const scheduleIdleClose = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
@@ -296,11 +423,16 @@
|
||||
window.addEventListener('wheel', onAnyActivity, true);
|
||||
window.addEventListener('keydown', onAnyActivity, true);
|
||||
|
||||
const handleResize = () => setMenuPosition();
|
||||
const handleResize = () => {
|
||||
if (contextMode) { closeMenu(); return; }
|
||||
setMenuPosition();
|
||||
};
|
||||
const handleScroll = () => {
|
||||
if (contextMode) { closeMenu(); return; }
|
||||
setMenuPosition();
|
||||
if (shareOpen) setSubmenuPosition();
|
||||
if (downloadOpen) setDownloadSubmenuPosition();
|
||||
if (radioOpen) setRadioSubmenuPosition();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
@@ -309,6 +441,7 @@
|
||||
document.removeEventListener('pointerdown', handleClickOutside, outsideListenerOptions);
|
||||
document.removeEventListener('mousedown', handleClickOutside, outsideListenerOptions);
|
||||
document.removeEventListener('touchstart', handleClickOutside, outsideListenerOptions);
|
||||
document.removeEventListener('contextmenu', handleClickOutside, outsideListenerOptions);
|
||||
window.removeEventListener('pointermove', onAnyActivity, true);
|
||||
window.removeEventListener('wheel', onAnyActivity, true);
|
||||
window.removeEventListener('keydown', onAnyActivity, true);
|
||||
@@ -348,6 +481,7 @@
|
||||
class="menu"
|
||||
class:open-left={openSide === 'left'}
|
||||
class:open-right={openSide === 'right'}
|
||||
class:context-mode={contextMode}
|
||||
bind:this={menuEl}
|
||||
style={menuStyle}
|
||||
use:portal
|
||||
@@ -386,13 +520,53 @@
|
||||
<span>Add to queue</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onCreateRadio}
|
||||
<button class="menu-item" onclick={() => handleAction(onCreateRadio)}>
|
||||
{#if hasRadio}
|
||||
{#if onCreateQbzRadio && onCreateQobuzRadio}
|
||||
<div
|
||||
class="menu-item submenu-trigger"
|
||||
bind:this={radioTriggerRef}
|
||||
onmouseenter={() => {
|
||||
radioOpen = true;
|
||||
shareOpen = false;
|
||||
downloadOpen = false;
|
||||
setRadioSubmenuPosition();
|
||||
}}
|
||||
onclick={() => {
|
||||
radioOpen = !radioOpen;
|
||||
if (radioOpen) { shareOpen = false; downloadOpen = false; }
|
||||
if (radioOpen) setRadioSubmenuPosition();
|
||||
}}
|
||||
>
|
||||
<Radio size={14} />
|
||||
<span>Create radio</span>
|
||||
<ChevronRight size={14} class="chevron" />
|
||||
{#if radioOpen}
|
||||
<div
|
||||
class="submenu"
|
||||
bind:this={radioSubmenuEl}
|
||||
style={radioSubmenuStyle}
|
||||
onmouseenter={() => { isHoveringRadioSubmenu = true; }}
|
||||
onmouseleave={() => { isHoveringRadioSubmenu = false; }}
|
||||
>
|
||||
<button class="menu-item" onclick={() => handleAction(onCreateQbzRadio)}>
|
||||
<Radio size={14} />
|
||||
<span>QBZ Radio</span>
|
||||
</button>
|
||||
<button class="menu-item" onclick={() => handleAction(onCreateQobuzRadio)}>
|
||||
<Radio size={14} />
|
||||
<span>Qobuz Radio</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button class="menu-item" onclick={() => handleAction(onCreateQbzRadio || onCreateQobuzRadio)}>
|
||||
<Radio size={14} />
|
||||
<span>Create radio</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if hasPlayback && (hasLibrary || hasShare || hasNav || hasDownload)}
|
||||
<div class="separator"></div>
|
||||
@@ -435,10 +609,13 @@
|
||||
bind:this={shareTriggerRef}
|
||||
onmouseenter={() => {
|
||||
shareOpen = true;
|
||||
downloadOpen = false;
|
||||
radioOpen = false;
|
||||
setSubmenuPosition();
|
||||
}}
|
||||
onclick={() => {
|
||||
shareOpen = !shareOpen;
|
||||
if (shareOpen) { downloadOpen = false; radioOpen = false; }
|
||||
if (shareOpen) setSubmenuPosition();
|
||||
}}
|
||||
>
|
||||
@@ -446,7 +623,13 @@
|
||||
<span>Share</span>
|
||||
<ChevronRight size={14} class="chevron" />
|
||||
{#if shareOpen}
|
||||
<div class="submenu" bind:this={submenuEl} style={submenuStyle}>
|
||||
<div
|
||||
class="submenu"
|
||||
bind:this={submenuEl}
|
||||
style={submenuStyle}
|
||||
onmouseenter={() => { isHoveringShareSubmenu = true; }}
|
||||
onmouseleave={() => { isHoveringShareSubmenu = false; }}
|
||||
>
|
||||
{#if onShareQobuz}
|
||||
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>
|
||||
<Link size={14} />
|
||||
@@ -476,11 +659,12 @@
|
||||
onmouseenter={() => {
|
||||
downloadOpen = true;
|
||||
shareOpen = false;
|
||||
radioOpen = false;
|
||||
setDownloadSubmenuPosition();
|
||||
}}
|
||||
onclick={() => {
|
||||
downloadOpen = !downloadOpen;
|
||||
shareOpen = false;
|
||||
if (downloadOpen) { shareOpen = false; radioOpen = false; }
|
||||
if (downloadOpen) setDownloadSubmenuPosition();
|
||||
}}
|
||||
>
|
||||
@@ -488,7 +672,13 @@
|
||||
<span>Make available offline</span>
|
||||
<ChevronRight size={14} class="chevron" />
|
||||
{#if downloadOpen}
|
||||
<div class="submenu" bind:this={downloadSubmenuEl} style={downloadSubmenuStyle}>
|
||||
<div
|
||||
class="submenu"
|
||||
bind:this={downloadSubmenuEl}
|
||||
style={downloadSubmenuStyle}
|
||||
onmouseenter={() => { isHoveringDownloadSubmenu = true; }}
|
||||
onmouseleave={() => { isHoveringDownloadSubmenu = false; }}
|
||||
>
|
||||
{#if onReDownload}
|
||||
<button class="menu-item" onclick={() => handleAction(onReDownload)}>
|
||||
<RefreshCw size={14} />
|
||||
@@ -642,6 +832,11 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.menu.context-mode::after,
|
||||
.menu.context-mode::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
}
|
||||
|
||||
let isRadioLoading = $state(false);
|
||||
let radioDropdownOpen = $state(false);
|
||||
let artistIsBlacklisted = $state(false);
|
||||
let isBlacklistLoading = $state(false);
|
||||
let showHideDropdown = $state(false);
|
||||
@@ -629,6 +630,82 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createQobuzArtistRadio() {
|
||||
if (isRadioLoading) return;
|
||||
|
||||
isRadioLoading = true;
|
||||
radioJustCreated = false;
|
||||
|
||||
try {
|
||||
radioLoadingMessage = 'Creating Qobuz artist radio...';
|
||||
const sessionId = await invoke<string>('v2_create_qobuz_artist_radio', {
|
||||
artistId: artist.id,
|
||||
artistName: artist.name
|
||||
});
|
||||
console.log(`[Radio] Qobuz artist radio created: ${sessionId}`);
|
||||
|
||||
await getPlaybackContext();
|
||||
const firstTrack = await playQueueIndex(0);
|
||||
|
||||
if (firstTrack && onTrackPlay) {
|
||||
onTrackPlay({
|
||||
id: firstTrack.id,
|
||||
title: firstTrack.title,
|
||||
artist: firstTrack.artist,
|
||||
album: firstTrack.album,
|
||||
albumArt: firstTrack.artwork_url || '',
|
||||
duration: formatDuration(firstTrack.duration_secs),
|
||||
durationSeconds: firstTrack.duration_secs,
|
||||
hires: firstTrack.hires,
|
||||
bitDepth: firstTrack.bit_depth ?? undefined,
|
||||
samplingRate: firstTrack.sample_rate ?? undefined,
|
||||
albumId: firstTrack.album_id ?? undefined,
|
||||
artistId: firstTrack.artist_id ?? undefined,
|
||||
});
|
||||
|
||||
radioJustCreated = true;
|
||||
setTimeout(() => { radioJustCreated = false; }, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create Qobuz artist radio:', err);
|
||||
} finally {
|
||||
isRadioLoading = false;
|
||||
radioLoadingMessage = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function createQobuzTrackRadio(track: Track) {
|
||||
try {
|
||||
const sessionId = await invoke<string>('v2_create_qobuz_track_radio', {
|
||||
trackId: track.id,
|
||||
trackName: track.title
|
||||
});
|
||||
console.log(`[Radio] Qobuz track radio created: ${sessionId}`);
|
||||
|
||||
await getPlaybackContext();
|
||||
const firstTrack = await playQueueIndex(0);
|
||||
|
||||
if (firstTrack && onTrackPlay) {
|
||||
onTrackPlay({
|
||||
id: firstTrack.id,
|
||||
title: firstTrack.title,
|
||||
artist: firstTrack.artist,
|
||||
album: firstTrack.album,
|
||||
albumArt: firstTrack.artwork_url || '',
|
||||
duration: formatDuration(firstTrack.duration_secs),
|
||||
durationSeconds: firstTrack.duration_secs,
|
||||
hires: firstTrack.hires,
|
||||
bitDepth: firstTrack.bit_depth ?? undefined,
|
||||
samplingRate: firstTrack.sample_rate ?? undefined,
|
||||
albumId: firstTrack.album_id ?? undefined,
|
||||
artistId: firstTrack.artist_id ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create Qobuz track radio:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopTracks() {
|
||||
tracksLoading = true;
|
||||
try {
|
||||
@@ -1376,12 +1453,24 @@
|
||||
class="radio-btn"
|
||||
class:loading={isRadioLoading}
|
||||
class:glow={radioJustCreated}
|
||||
onclick={createArtistRadio}
|
||||
onclick={() => { radioDropdownOpen = !radioDropdownOpen; }}
|
||||
disabled={isRadioLoading}
|
||||
title="Start Artist Radio"
|
||||
>
|
||||
<Radio size={24} />
|
||||
</button>
|
||||
{#if radioDropdownOpen && !isRadioLoading}
|
||||
<div class="radio-dropdown" role="menu">
|
||||
<button class="radio-dropdown-item" onclick={() => { radioDropdownOpen = false; createArtistRadio(); }}>
|
||||
QBZ Radio
|
||||
</button>
|
||||
<button class="radio-dropdown-item" onclick={() => { radioDropdownOpen = false; createQobuzArtistRadio(); }}>
|
||||
Qobuz Radio
|
||||
</button>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="radio-dropdown-backdrop" onclick={() => radioDropdownOpen = false}></div>
|
||||
{/if}
|
||||
{#if isRadioLoading && radioLoadingMessage}
|
||||
{#key radioLoadingMessage}
|
||||
<span class="floating-message">{radioLoadingMessage}</span>
|
||||
@@ -1679,7 +1768,8 @@
|
||||
onPlayNow={() => handleTrackPlay(track, index)}
|
||||
onPlayNext={onTrackPlayNext ? () => onTrackPlayNext(track) : undefined}
|
||||
onPlayLater={onTrackPlayLater ? () => onTrackPlayLater(track) : undefined}
|
||||
onCreateRadio={() => createTrackRadio(track)}
|
||||
onCreateQbzRadio={() => createTrackRadio(track)}
|
||||
onCreateQobuzRadio={() => createQobuzTrackRadio(track)}
|
||||
onAddFavorite={onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined}
|
||||
onAddToPlaylist={onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined}
|
||||
onShareQobuz={onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined}
|
||||
@@ -2727,6 +2817,43 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.radio-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.radio-dropdown-item {
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.radio-dropdown-item:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.radio-dropdown-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.network-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -499,6 +499,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createQobuzTrackRadio(track: Track) {
|
||||
try {
|
||||
await invoke<string>('v2_create_qobuz_track_radio', {
|
||||
trackId: track.id,
|
||||
trackName: track.title
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to create Qobuz track radio:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePausePlayback(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
void togglePlay();
|
||||
@@ -894,7 +905,8 @@
|
||||
onPlayNow={() => handleTrackPlay(track, index)}
|
||||
onPlayNext={onTrackPlayNext ? () => onTrackPlayNext(track) : undefined}
|
||||
onPlayLater={onTrackPlayLater ? () => onTrackPlayLater(track) : undefined}
|
||||
onCreateRadio={() => createTrackRadio(track)}
|
||||
onCreateQbzRadio={() => createTrackRadio(track)}
|
||||
onCreateQobuzRadio={() => createQobuzTrackRadio(track)}
|
||||
onAddFavorite={onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined}
|
||||
onAddToPlaylist={onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined}
|
||||
onGoToAlbum={track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album!.id) : undefined}
|
||||
|
||||
@@ -1587,6 +1587,9 @@
|
||||
},
|
||||
"radio": {
|
||||
"albumRadio": "Album Radio",
|
||||
"creatingAlbumRadio": "Album-Radio wird erstellt..."
|
||||
"creatingAlbumRadio": "Album-Radio wird erstellt...",
|
||||
"createRadio": "Radio erstellen",
|
||||
"qbzRadio": "QBZ Radio",
|
||||
"qobuzRadio": "Qobuz Radio"
|
||||
}
|
||||
}
|
||||
@@ -1766,6 +1766,9 @@
|
||||
},
|
||||
"radio": {
|
||||
"albumRadio": "Album Radio",
|
||||
"creatingAlbumRadio": "Creating album radio..."
|
||||
"creatingAlbumRadio": "Creating album radio...",
|
||||
"createRadio": "Create radio",
|
||||
"qbzRadio": "QBZ Radio",
|
||||
"qobuzRadio": "Qobuz Radio"
|
||||
}
|
||||
}
|
||||
@@ -1766,6 +1766,9 @@
|
||||
},
|
||||
"radio": {
|
||||
"albumRadio": "Radio del Album",
|
||||
"creatingAlbumRadio": "Creando radio del album..."
|
||||
"creatingAlbumRadio": "Creando radio del album...",
|
||||
"createRadio": "Crear radio",
|
||||
"qbzRadio": "Radio QBZ",
|
||||
"qobuzRadio": "Radio Qobuz"
|
||||
}
|
||||
}
|
||||
@@ -1587,6 +1587,9 @@
|
||||
},
|
||||
"radio": {
|
||||
"albumRadio": "Radio de l'album",
|
||||
"creatingAlbumRadio": "Création de la radio de l'album..."
|
||||
"creatingAlbumRadio": "Création de la radio de l'album...",
|
||||
"createRadio": "Créer une radio",
|
||||
"qbzRadio": "Radio QBZ",
|
||||
"qobuzRadio": "Radio Qobuz"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user