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:
vicrodh committed 2026-02-20 22:10:44 -06:00
1 parent f8a56d7f4b
commit 2dcaa76027
11 files changed
+576 -24

No files matched your search

+68
View File
@@ -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
+2
View File
@@ -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";
+134
View File
@@ -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
+2
View File
@@ -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,
+212 -17
View File
@@ -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;
await setMenuPosition();
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.
setMenuPosition();
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,11 +520,51 @@
<span>Add to queue</span>
</button>
{/if}
{#if onCreateRadio}
<button class="menu-item" onclick={() => handleAction(onCreateRadio)}>
<Radio size={14} />
<span>Create radio</span>
</button>
{#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}
@@ -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;
+13 -1
View File
@@ -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}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}