feat(label): redesign label detail view with Android-style sections
Replace the flat album grid LabelView with a rich detail page featuring: - Header with label image, name, description, and play button - Popular Tracks section (5/10/all expand, same UX as ArtistDetailView) - Releases horizontal scroll row with "See all" → LabelReleasesView - Critics' Picks section from second releases container - Playlists, Artists, and More Labels sections Backend: add /label/page and /label/explore API endpoints with V2 commands (v2_get_label_page, v2_get_label_explore). Models use serde_json::Value for flexible section parsing. Old LabelView renamed to LabelReleasesView for the "See all" flow. i18n keys added to all 4 locales (en, es, de, fr).
This commit is contained in:
1 parent
318318a3ab
commit
585053bd42
21 files changed
+2260
-424
No files matched your search
@@ -8,7 +8,8 @@ use tokio::sync::RwLock;
|
||||
|
||||
use qbz_models::{
|
||||
Album, Artist, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse,
|
||||
DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, PageArtistResponse, Playlist,
|
||||
DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, LabelExploreResponse,
|
||||
LabelPageData, PageArtistResponse, Playlist,
|
||||
PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession,
|
||||
};
|
||||
use qbz_player::{Player, PlaybackState, QueueManager};
|
||||
@@ -734,6 +735,32 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
.map_err(CoreError::Api)
|
||||
}
|
||||
|
||||
/// Get label page (aggregated: top tracks, releases, playlists, artists)
|
||||
pub async fn get_label_page(&self, label_id: u64) -> Result<LabelPageData, CoreError> {
|
||||
let client = self.client.read().await;
|
||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||
|
||||
client
|
||||
.get_label_page(label_id)
|
||||
.await
|
||||
.map_err(CoreError::Api)
|
||||
}
|
||||
|
||||
/// Get label explore (discover more labels)
|
||||
pub async fn get_label_explore(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<LabelExploreResponse, CoreError> {
|
||||
let client = self.client.read().await;
|
||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||
|
||||
client
|
||||
.get_label_explore(limit, offset)
|
||||
.await
|
||||
.map_err(CoreError::Api)
|
||||
}
|
||||
|
||||
// ==================== Event Emission ====================
|
||||
|
||||
/// Emit an event to the frontend adapter
|
||||
|
||||
@@ -42,7 +42,8 @@ pub use playback::{PlaybackState, PlaybackStatus, QueueState, QueueTrack, Repeat
|
||||
pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter};
|
||||
pub use types::{
|
||||
Album, AlbumSummary, Artist, ArtistAlbums, ArtistBiography, Favorites, Genre, GenreInfo,
|
||||
GenreListContainer, GenreListResponse, ImageSet, Label, LabelDetail, Playlist,
|
||||
GenreListContainer, GenreListResponse, ImageSet, Label, LabelDetail,
|
||||
LabelExploreResponse, LabelPageContainer, LabelPageData, LabelPageGenericList, Playlist,
|
||||
PlaylistDuplicateResult, PlaylistGenre, PlaylistOwner, PlaylistWithTrackIds, Quality,
|
||||
SearchResults, SearchResultsPage, StreamRestriction, StreamUrl, Track, TracksContainer,
|
||||
UserSession,
|
||||
|
||||
@@ -353,6 +353,47 @@ pub struct LabelDetail {
|
||||
pub albums_count: Option<u32>,
|
||||
}
|
||||
|
||||
// ============ Label Page Types (/label/page) ============
|
||||
|
||||
/// Top-level response from /label/page
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelPageData {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub releases: Option<Vec<LabelPageContainer>>,
|
||||
#[serde(default)]
|
||||
pub playlists: Option<LabelPageGenericList>,
|
||||
#[serde(default)]
|
||||
pub top_tracks: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
pub top_artists: Option<LabelPageGenericList>,
|
||||
}
|
||||
|
||||
/// A container within label page (e.g. releases category)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelPageContainer {
|
||||
pub id: Option<String>,
|
||||
pub data: Option<LabelPageGenericList>,
|
||||
}
|
||||
|
||||
/// Generic list with has_more and items
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelPageGenericList {
|
||||
pub has_more: Option<bool>,
|
||||
pub items: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Response from /label/explore
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelExploreResponse {
|
||||
pub has_more: Option<bool>,
|
||||
pub items: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Genre model
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Genre {
|
||||
|
||||
@@ -1012,6 +1012,49 @@ impl QobuzClient {
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
/// Get label page (aggregated: top tracks, releases, playlists, artists)
|
||||
pub async fn get_label_page(&self, label_id: u64) -> Result<LabelPageData> {
|
||||
let url = endpoints::build_url(paths::LABEL_PAGE);
|
||||
|
||||
log::debug!("[API] get_label_page({})", label_id);
|
||||
let response: serde_json::Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.api_headers().await?)
|
||||
.query(&[("label_id", label_id.to_string())])
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get label explore (discover more labels)
|
||||
pub async fn get_label_explore(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<LabelExploreResponse> {
|
||||
let url = endpoints::build_url(paths::LABEL_EXPLORE);
|
||||
|
||||
log::debug!("[API] get_label_explore(limit={}, offset={})", limit, offset);
|
||||
let response: serde_json::Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.api_headers().await?)
|
||||
.query(&[
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get label by ID with albums
|
||||
pub async fn get_label(&self, label_id: u64, limit: u32, offset: u32) -> Result<LabelDetail> {
|
||||
let url = endpoints::build_url(paths::LABEL_GET);
|
||||
|
||||
@@ -44,6 +44,8 @@ pub mod paths {
|
||||
|
||||
// Label
|
||||
pub const LABEL_GET: &str = "/label/get";
|
||||
pub const LABEL_PAGE: &str = "/label/page";
|
||||
pub const LABEL_EXPLORE: &str = "/label/explore";
|
||||
|
||||
// Genre
|
||||
pub const GENRE_LIST: &str = "/genre/list";
|
||||
|
||||
@@ -1168,6 +1168,49 @@ impl QobuzClient {
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
/// Get label page (aggregated: top tracks, releases, playlists, artists)
|
||||
pub async fn get_label_page(&self, label_id: u64) -> Result<LabelPageData> {
|
||||
let url = endpoints::build_url(paths::LABEL_PAGE);
|
||||
|
||||
log::debug!("[API] get_label_page({})", label_id);
|
||||
let response: serde_json::Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.api_headers().await?)
|
||||
.query(&[("label_id", label_id.to_string())])
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get label explore (discover more labels)
|
||||
pub async fn get_label_explore(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<LabelExploreResponse> {
|
||||
let url = endpoints::build_url(paths::LABEL_EXPLORE);
|
||||
|
||||
log::debug!("[API] get_label_explore(limit={}, offset={})", limit, offset);
|
||||
let response: serde_json::Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.api_headers().await?)
|
||||
.query(&[
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get label by ID with albums
|
||||
pub async fn get_label(&self, label_id: u64, limit: u32, offset: u32) -> Result<LabelDetail> {
|
||||
let url = endpoints::build_url(paths::LABEL_GET);
|
||||
|
||||
@@ -47,6 +47,8 @@ pub mod paths {
|
||||
|
||||
// Label
|
||||
pub const LABEL_GET: &str = "/label/get";
|
||||
pub const LABEL_PAGE: &str = "/label/page";
|
||||
pub const LABEL_EXPLORE: &str = "/label/explore";
|
||||
|
||||
// Genre
|
||||
pub const GENRE_LIST: &str = "/genre/list";
|
||||
|
||||
@@ -445,6 +445,47 @@ pub struct LabelDetail {
|
||||
pub albums_count: Option<u32>,
|
||||
}
|
||||
|
||||
// ============ Label Page Types (/label/page) ============
|
||||
|
||||
/// Top-level response from /label/page
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelPageData {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub releases: Option<Vec<LabelPageContainer>>,
|
||||
#[serde(default)]
|
||||
pub playlists: Option<LabelPageGenericList>,
|
||||
#[serde(default)]
|
||||
pub top_tracks: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
pub top_artists: Option<LabelPageGenericList>,
|
||||
}
|
||||
|
||||
/// A container within label page (e.g. releases category)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelPageContainer {
|
||||
pub id: Option<String>,
|
||||
pub data: Option<LabelPageGenericList>,
|
||||
}
|
||||
|
||||
/// Generic list with has_more and items
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelPageGenericList {
|
||||
pub has_more: Option<bool>,
|
||||
pub items: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Response from /label/explore
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelExploreResponse {
|
||||
pub has_more: Option<bool>,
|
||||
pub items: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Genre model (basic, used in album responses)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Genre {
|
||||
|
||||
@@ -11,7 +11,8 @@ use tokio::sync::RwLock;
|
||||
|
||||
use qbz_models::{
|
||||
Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse,
|
||||
GenreInfo, LabelDetail, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState,
|
||||
GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData, PageArtistResponse, Playlist,
|
||||
PlaylistTag, Quality, QueueState,
|
||||
QueueTrack as CoreQueueTrack, RepeatMode, SearchResultsPage, Track, UserSession,
|
||||
};
|
||||
|
||||
@@ -6454,6 +6455,49 @@ pub async fn v2_get_label(
|
||||
.map_err(RuntimeError::Internal)
|
||||
}
|
||||
|
||||
/// Get label page (aggregated: top tracks, releases, playlists, artists)
|
||||
#[tauri::command]
|
||||
#[allow(non_snake_case)]
|
||||
pub async fn v2_get_label_page(
|
||||
labelId: u64,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<LabelPageData, RuntimeError> {
|
||||
runtime
|
||||
.manager()
|
||||
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
|
||||
.await?;
|
||||
|
||||
log::info!("[V2] get_label_page: {}", labelId);
|
||||
let bridge = bridge.get().await;
|
||||
bridge
|
||||
.get_label_page(labelId)
|
||||
.await
|
||||
.map_err(RuntimeError::Internal)
|
||||
}
|
||||
|
||||
/// Get label explore (discover more labels)
|
||||
#[tauri::command]
|
||||
#[allow(non_snake_case)]
|
||||
pub async fn v2_get_label_explore(
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<LabelExploreResponse, RuntimeError> {
|
||||
runtime
|
||||
.manager()
|
||||
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
|
||||
.await?;
|
||||
|
||||
log::info!("[V2] get_label_explore: limit={} offset={}", limit, offset);
|
||||
let bridge = bridge.get().await;
|
||||
bridge
|
||||
.get_label_explore(limit, offset)
|
||||
.await
|
||||
.map_err(RuntimeError::Internal)
|
||||
}
|
||||
|
||||
// ==================== Integrations V2 Commands ====================
|
||||
//
|
||||
// These commands use the qbz-integrations crate which is Tauri-independent.
|
||||
|
||||
@@ -14,7 +14,8 @@ use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, Vi
|
||||
use qbz_core::QbzCore;
|
||||
use qbz_models::{
|
||||
Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse,
|
||||
GenreInfo, LabelDetail, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState,
|
||||
GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData, PageArtistResponse, Playlist,
|
||||
PlaylistTag, Quality, QueueState,
|
||||
QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession,
|
||||
};
|
||||
use qbz_player::{PlaybackState, Player};
|
||||
@@ -499,6 +500,26 @@ impl CoreBridge {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get label page (aggregated: top tracks, releases, playlists, artists)
|
||||
pub async fn get_label_page(&self, label_id: u64) -> Result<LabelPageData, String> {
|
||||
self.core
|
||||
.get_label_page(label_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get label explore (discover more labels)
|
||||
pub async fn get_label_explore(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<LabelExploreResponse, String> {
|
||||
self.core
|
||||
.get_label_explore(limit, offset)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ==================== Streaming ====================
|
||||
|
||||
/// Get stream URL for a track with quality fallback
|
||||
|
||||
@@ -981,6 +981,8 @@ pub fn run() {
|
||||
commands_v2::v2_get_similar_artists,
|
||||
commands_v2::v2_get_artist_with_albums,
|
||||
commands_v2::v2_get_label,
|
||||
commands_v2::v2_get_label_page,
|
||||
commands_v2::v2_get_label_explore,
|
||||
commands_v2::v2_pause_playback,
|
||||
commands_v2::v2_resume_playback,
|
||||
commands_v2::v2_stop_playback,
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { ArrowLeft, Search, X, Disc3, Loader2 } from 'lucide-svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import type { QobuzAlbum, LabelDetail } from '$lib/types';
|
||||
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
|
||||
|
||||
interface Props {
|
||||
labelId: number;
|
||||
labelName?: string;
|
||||
onBack: () => void;
|
||||
onAlbumClick?: (albumId: string) => void;
|
||||
onAlbumPlay?: (albumId: string) => void;
|
||||
onAlbumPlayNext?: (albumId: string) => void;
|
||||
onAlbumPlayLater?: (albumId: string) => void;
|
||||
onAddAlbumToPlaylist?: (albumId: string) => void;
|
||||
onAlbumShareQobuz?: (albumId: string) => void;
|
||||
onAlbumShareSonglink?: (albumId: string) => void;
|
||||
onAlbumDownload?: (albumId: string) => void;
|
||||
onOpenAlbumFolder?: (albumId: string) => void;
|
||||
onReDownloadAlbum?: (albumId: string) => void;
|
||||
checkAlbumFullyDownloaded?: (albumId: string) => Promise<boolean>;
|
||||
downloadStateVersion?: number;
|
||||
onArtistClick?: (artistId: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
labelId,
|
||||
labelName = '',
|
||||
onBack,
|
||||
onAlbumClick,
|
||||
onAlbumPlay,
|
||||
onAlbumPlayNext,
|
||||
onAlbumPlayLater,
|
||||
onAddAlbumToPlaylist,
|
||||
onAlbumShareQobuz,
|
||||
onAlbumShareSonglink,
|
||||
onAlbumDownload,
|
||||
onOpenAlbumFolder,
|
||||
onReDownloadAlbum,
|
||||
checkAlbumFullyDownloaded,
|
||||
downloadStateVersion,
|
||||
onArtistClick
|
||||
}: Props = $props();
|
||||
|
||||
// State
|
||||
let label = $state<LabelDetail | null>(null);
|
||||
let albums = $state<QobuzAlbum[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let searchExpanded = $state(false);
|
||||
let totalAlbums = $state(0);
|
||||
let albumsFetched = $state(0);
|
||||
|
||||
// API search state
|
||||
let apiSearchResults = $state<QobuzAlbum[] | null>(null);
|
||||
let apiSearching = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let searchVersion = 0;
|
||||
|
||||
// Download status tracking
|
||||
let albumOfflineCacheStatuses = $state<Map<string, boolean>>(new Map());
|
||||
|
||||
// When API search is active, show API results; otherwise show all loaded albums
|
||||
let displayAlbums = $derived(apiSearchResults !== null ? apiSearchResults : albums);
|
||||
|
||||
async function loadLabel() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await invoke<{
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: { small?: string; thumbnail?: string; large?: string };
|
||||
albums?: { items: QobuzAlbum[]; total: number; offset: number; limit: number };
|
||||
albums_count?: number;
|
||||
}>('v2_get_label', { labelId, limit: 100, offset: 0 });
|
||||
|
||||
label = {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
image: result.image,
|
||||
albums: result.albums?.items ?? [],
|
||||
totalAlbums: result.albums?.total ?? result.albums_count ?? 0,
|
||||
albumsFetched: result.albums?.items?.length ?? 0
|
||||
};
|
||||
|
||||
albums = label.albums;
|
||||
totalAlbums = label.totalAlbums;
|
||||
albumsFetched = label.albumsFetched;
|
||||
|
||||
// Load download statuses
|
||||
await loadAllAlbumOfflineCacheStatuses(albums);
|
||||
} catch (e) {
|
||||
console.error('Failed to load label:', e);
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore || albumsFetched >= totalAlbums) return;
|
||||
|
||||
loadingMore = true;
|
||||
|
||||
try {
|
||||
const result = await invoke<{
|
||||
id: number;
|
||||
name: string;
|
||||
albums?: { items: QobuzAlbum[]; total: number; offset: number; limit: number };
|
||||
}>('v2_get_label', { labelId, limit: 100, offset: albumsFetched });
|
||||
|
||||
const newAlbums = result.albums?.items ?? [];
|
||||
albums = [...albums, ...newAlbums];
|
||||
albumsFetched += newAlbums.length;
|
||||
|
||||
// Load download statuses for new albums
|
||||
await loadAllAlbumOfflineCacheStatuses(newAlbums);
|
||||
} catch (e) {
|
||||
console.error('Failed to load more albums:', e);
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlbumOfflineCacheStatus(albumId: string) {
|
||||
if (!checkAlbumFullyDownloaded) return false;
|
||||
try {
|
||||
const isDownloaded = await checkAlbumFullyDownloaded(albumId);
|
||||
albumOfflineCacheStatuses.set(albumId, isDownloaded);
|
||||
return isDownloaded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllAlbumOfflineCacheStatuses(albumList: QobuzAlbum[]) {
|
||||
if (!checkAlbumFullyDownloaded || albumList.length === 0) return;
|
||||
await Promise.all(albumList.map(album => loadAlbumOfflineCacheStatus(album.id)));
|
||||
}
|
||||
|
||||
function isAlbumDownloaded(albumId: string): boolean {
|
||||
void downloadStateVersion;
|
||||
return albumOfflineCacheStatuses.get(albumId) || false;
|
||||
}
|
||||
|
||||
function getQualityLabel(album: QobuzAlbum): string {
|
||||
if (album.hires_streamable) {
|
||||
const bitDepth = album.maximum_bit_depth || 24;
|
||||
const sampleRate = album.maximum_sampling_rate || 96;
|
||||
return `${bitDepth}-bit/${sampleRate}kHz`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getGenreLabel(album: QobuzAlbum): string {
|
||||
return album.genre?.name || '';
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery = '';
|
||||
apiSearchResults = null;
|
||||
apiSearching = false;
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
searchExpanded = !searchExpanded;
|
||||
if (!searchExpanded) {
|
||||
searchQuery = '';
|
||||
apiSearchResults = null;
|
||||
apiSearching = false;
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchInput() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
const query = searchQuery.trim();
|
||||
if (query.length < 2) {
|
||||
apiSearchResults = null;
|
||||
apiSearching = false;
|
||||
return;
|
||||
}
|
||||
apiSearching = true;
|
||||
searchTimeout = setTimeout(() => performLabelSearch(query), 300);
|
||||
}
|
||||
|
||||
async function performLabelSearch(query: string) {
|
||||
searchVersion++;
|
||||
const thisVersion = searchVersion;
|
||||
try {
|
||||
const results = await invoke<{ items: QobuzAlbum[]; total: number; offset: number; limit: number }>(
|
||||
'v2_search_albums', { query, limit: 200, offset: 0, searchType: null }
|
||||
);
|
||||
if (thisVersion !== searchVersion) return;
|
||||
apiSearchResults = results.items.filter(
|
||||
album => album.label?.id === labelId
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Label search failed:', e);
|
||||
if (thisVersion === searchVersion) apiSearchResults = [];
|
||||
} finally {
|
||||
if (thisVersion === searchVersion) apiSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll handler for infinite loading (disabled during API search)
|
||||
function handleScroll(e: Event) {
|
||||
if (apiSearchResults !== null) return;
|
||||
const target = e.target as HTMLElement;
|
||||
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
|
||||
if (scrollBottom < 400 && !loadingMore && albumsFetched < totalAlbums) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadLabel();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="label-view" onscroll={handleScroll}>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="back-btn" onclick={onBack} title="Go back">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div class="header-icon">
|
||||
<Disc3 size={36} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1>{label?.name || labelName || 'Label'}</h1>
|
||||
<p class="subtitle">
|
||||
{#if loading}
|
||||
Loading...
|
||||
{:else if totalAlbums > 0}
|
||||
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Fixed Navigation/Search Bar -->
|
||||
<nav class="label-nav">
|
||||
<div class="nav-left">
|
||||
<span class="nav-title">Albums</span>
|
||||
{#if apiSearchResults !== null}
|
||||
<span class="nav-count">{apiSearchResults.length} result{apiSearchResults.length !== 1 ? 's' : ''}</span>
|
||||
{:else if albumsFetched > 0 && albumsFetched < totalAlbums}
|
||||
<span class="nav-count">Showing {albumsFetched} of {totalAlbums}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
{#if searchExpanded}
|
||||
<div class="search-expanded">
|
||||
<Search size={16} class="search-icon-inline" />
|
||||
<input
|
||||
type="text"
|
||||
class="search-input-inline"
|
||||
placeholder="Search albums..."
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearchInput}
|
||||
autofocus
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="search-clear-btn" onclick={clearSearch}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="search-close-btn" onclick={toggleSearch}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="search-icon-btn" onclick={toggleSearch} title="Search albums">
|
||||
<Search size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading label...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>Failed to load label</p>
|
||||
<p class="error-detail">{error}</p>
|
||||
<button class="retry-btn" onclick={loadLabel}>Retry</button>
|
||||
</div>
|
||||
{:else if albums.length === 0}
|
||||
<div class="empty">
|
||||
<Disc3 size={48} />
|
||||
<p>No albums found for this label</p>
|
||||
</div>
|
||||
{:else if apiSearching}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Searching...</p>
|
||||
</div>
|
||||
{:else if displayAlbums.length === 0}
|
||||
<div class="empty">
|
||||
<Search size={48} />
|
||||
<p>No albums match "{searchQuery}"</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="album-grid">
|
||||
{#each displayAlbums as album (album.id)}
|
||||
<AlbumCard
|
||||
albumId={album.id}
|
||||
artwork={album.image?.large || album.image?.thumbnail || ''}
|
||||
title={album.title}
|
||||
artist={album.artist?.name || 'Unknown Artist'}
|
||||
artistId={album.artist?.id}
|
||||
onArtistClick={onArtistClick}
|
||||
genre={getGenreLabel(album)}
|
||||
releaseDate={album.release_date_original}
|
||||
quality={getQualityLabel(album)}
|
||||
size="large"
|
||||
onclick={() => onAlbumClick?.(album.id)}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
|
||||
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
|
||||
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
|
||||
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
|
||||
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
|
||||
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
|
||||
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
|
||||
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
|
||||
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
|
||||
{downloadStateVersion}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loadingMore}
|
||||
<div class="loading-more">
|
||||
<Loader2 size={20} class="spinner-icon" />
|
||||
<span>Loading more albums...</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.label-view {
|
||||
padding: 24px;
|
||||
padding-left: 18px;
|
||||
padding-right: 8px;
|
||||
padding-bottom: 100px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.label-view::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.label-view::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.label-view::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.label-view::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.label-nav {
|
||||
position: sticky;
|
||||
top: -24px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
margin: 0 -8px 16px -24px;
|
||||
width: calc(100% + 32px);
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--alpha-6);
|
||||
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.search-icon-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.search-expanded {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
:global(.search-icon-inline) {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input-inline {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-input-inline::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-clear-btn,
|
||||
.search-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover,
|
||||
.search-close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading p,
|
||||
.error p,
|
||||
.empty p {
|
||||
margin: 16px 0 0 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--bg-tertiary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 16px;
|
||||
padding: 8px 24px;
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.album-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px 14px;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.spinner-icon) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -81,6 +81,20 @@
|
||||
"noPlaylistsFor": "Keine Playlists gefunden für \"{query}\"",
|
||||
"viewMore": "Mehr anzeigen"
|
||||
},
|
||||
"label": {
|
||||
"title": "Label",
|
||||
"popularTracks": "Beliebte Titel",
|
||||
"releases": "Veröffentlichungen",
|
||||
"criticsPicks": "Kritiker-Auswahl",
|
||||
"playlists": "Playlists",
|
||||
"artists": "Künstler",
|
||||
"moreLabels": "Weitere Labels",
|
||||
"readMore": "MEHR LESEN",
|
||||
"readLess": "WENIGER",
|
||||
"seeAll": "Alle anzeigen",
|
||||
"showMore": "Mehr anzeigen",
|
||||
"showLess": "Weniger anzeigen"
|
||||
},
|
||||
"library": {
|
||||
"title": "Lokale Bibliothek",
|
||||
"browse": "Bibliothek durchsuchen",
|
||||
|
||||
@@ -84,6 +84,20 @@
|
||||
"noPlaylistsFor": "No playlists found for \"{query}\"",
|
||||
"viewMore": "View more"
|
||||
},
|
||||
"label": {
|
||||
"title": "Label",
|
||||
"popularTracks": "Popular Tracks",
|
||||
"releases": "Releases",
|
||||
"criticsPicks": "Critics' Picks",
|
||||
"playlists": "Playlists",
|
||||
"artists": "Artists",
|
||||
"moreLabels": "More labels",
|
||||
"readMore": "READ MORE",
|
||||
"readLess": "READ LESS",
|
||||
"seeAll": "See all",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less"
|
||||
},
|
||||
"library": {
|
||||
"title": "Local Library",
|
||||
"browse": "Browse Library",
|
||||
|
||||
@@ -84,6 +84,20 @@
|
||||
"noPlaylistsFor": "No se encontraron listas para \"{query}\"",
|
||||
"viewMore": "Ver más"
|
||||
},
|
||||
"label": {
|
||||
"title": "Sello",
|
||||
"popularTracks": "Canciones populares",
|
||||
"releases": "Lanzamientos",
|
||||
"criticsPicks": "Selección de la crítica",
|
||||
"playlists": "Playlists",
|
||||
"artists": "Artistas",
|
||||
"moreLabels": "Más sellos",
|
||||
"readMore": "LEER MÁS",
|
||||
"readLess": "LEER MENOS",
|
||||
"seeAll": "Ver todo",
|
||||
"showMore": "Ver más",
|
||||
"showLess": "Ver menos"
|
||||
},
|
||||
"library": {
|
||||
"title": "Biblioteca Local",
|
||||
"browse": "Explorar Biblioteca",
|
||||
|
||||
@@ -81,6 +81,20 @@
|
||||
"noPlaylistsFor": "Aucune playlist trouvée pour « {query} »",
|
||||
"viewMore": "Voir plus"
|
||||
},
|
||||
"label": {
|
||||
"title": "Label",
|
||||
"popularTracks": "Titres populaires",
|
||||
"releases": "Sorties",
|
||||
"criticsPicks": "Choix des critiques",
|
||||
"playlists": "Playlists",
|
||||
"artists": "Artistes",
|
||||
"moreLabels": "Plus de labels",
|
||||
"readMore": "LIRE PLUS",
|
||||
"readLess": "LIRE MOINS",
|
||||
"seeAll": "Voir tout",
|
||||
"showMore": "Voir plus",
|
||||
"showLess": "Voir moins"
|
||||
},
|
||||
"library": {
|
||||
"title": "Bibliothèque locale",
|
||||
"browse": "Parcourir la bibliothèque",
|
||||
|
||||
@@ -16,6 +16,7 @@ export type ViewType =
|
||||
| 'artist'
|
||||
| 'musician'
|
||||
| 'label'
|
||||
| 'label-releases'
|
||||
| 'playlist'
|
||||
| 'playlist-manager'
|
||||
| 'blacklist-manager'
|
||||
|
||||
@@ -9,7 +9,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
export type ContextType = 'album' | 'playlist' | 'artist_top' | 'home_list' | 'favorites' | 'local_library' | 'radio' | 'search';
|
||||
export type ContextType = 'album' | 'playlist' | 'artist_top' | 'label_top' | 'home_list' | 'favorites' | 'local_library' | 'radio' | 'search';
|
||||
export type ContentSource = 'qobuz' | 'local' | 'plex';
|
||||
|
||||
export interface PlaybackContext {
|
||||
@@ -133,18 +133,21 @@ export function consumeContextTrackFocus(
|
||||
export function getContextDisplayInfo(): string | null {
|
||||
if (!currentContext) return null;
|
||||
|
||||
const typeStr = {
|
||||
const typeStr: Record<ContextType, string> = {
|
||||
album: 'Album',
|
||||
playlist: 'Playlist',
|
||||
artist_top: 'Artist Top Songs',
|
||||
label_top: 'Label Top Songs',
|
||||
home_list: 'Home List',
|
||||
favorites: 'Favorites',
|
||||
local_library: 'Local Library',
|
||||
radio: 'Radio',
|
||||
search: 'Search Results'
|
||||
}[currentContext.type];
|
||||
};
|
||||
|
||||
return `${typeStr} · ${currentContext.label}`;
|
||||
const displayType = typeStr[currentContext.type];
|
||||
|
||||
return `${displayType} · ${currentContext.label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -276,6 +276,35 @@ export interface LabelDetail {
|
||||
albumsFetched: number;
|
||||
}
|
||||
|
||||
/** Response from /label/page */
|
||||
export interface LabelPageData {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: string | Record<string, string>;
|
||||
releases?: LabelReleaseContainer[];
|
||||
playlists?: { has_more?: boolean; items?: Record<string, unknown>[] };
|
||||
top_tracks?: Record<string, unknown>[];
|
||||
top_artists?: { has_more?: boolean; items?: Record<string, unknown>[] };
|
||||
}
|
||||
|
||||
export interface LabelReleaseContainer {
|
||||
id?: string;
|
||||
data?: { has_more?: boolean; items?: QobuzAlbum[] };
|
||||
}
|
||||
|
||||
/** Response from /label/explore */
|
||||
export interface LabelExploreResponse {
|
||||
has_more?: boolean;
|
||||
items?: LabelExploreItem[];
|
||||
}
|
||||
|
||||
export interface LabelExploreItem {
|
||||
id: number;
|
||||
name: string;
|
||||
image?: string | Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PlaylistTrack {
|
||||
id: number;
|
||||
number: number;
|
||||
|
||||
@@ -315,6 +315,7 @@
|
||||
import ArtistDetailView from '$lib/components/views/ArtistDetailView.svelte';
|
||||
import MusicianPageView from '$lib/components/views/MusicianPageView.svelte';
|
||||
import LabelView from '$lib/components/views/LabelView.svelte';
|
||||
import LabelReleasesView from '$lib/components/views/LabelReleasesView.svelte';
|
||||
import PlaylistDetailView from '$lib/components/views/PlaylistDetailView.svelte';
|
||||
import FavoritesView from '$lib/components/views/FavoritesView.svelte';
|
||||
import LocalLibraryView from '$lib/components/views/LocalLibraryView.svelte';
|
||||
@@ -795,6 +796,11 @@
|
||||
navigateTo('label');
|
||||
}
|
||||
|
||||
function handleNavigateLabelReleases(labelId: number, labelName: string) {
|
||||
selectedLabel = { id: labelId, name: labelName };
|
||||
navigateTo('label-releases');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle musician click from credits
|
||||
* Resolves musician and routes based on confidence level:
|
||||
@@ -3556,6 +3562,35 @@
|
||||
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
|
||||
{downloadStateVersion}
|
||||
onArtistClick={handleArtistClick}
|
||||
onLabelClick={handleLabelClick}
|
||||
onNavigateReleases={handleNavigateLabelReleases}
|
||||
onPlaylistClick={selectPlaylist}
|
||||
onTrackPlay={handleDisplayTrackPlay}
|
||||
onTrackPlayNext={queueQobuzTrackNext}
|
||||
onTrackPlayLater={queueQobuzTrackLater}
|
||||
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
||||
onTrackGoToAlbum={handleAlbumClick}
|
||||
activeTrackId={currentTrack?.id ?? null}
|
||||
isPlaybackActive={isPlaying}
|
||||
/>
|
||||
{:else if activeView === 'label-releases' && selectedLabel}
|
||||
<LabelReleasesView
|
||||
labelId={selectedLabel.id}
|
||||
labelName={selectedLabel.name}
|
||||
onBack={navGoBack}
|
||||
onAlbumClick={handleAlbumClick}
|
||||
onAlbumPlay={playAlbumById}
|
||||
onAlbumPlayNext={queueAlbumNextById}
|
||||
onAlbumPlayLater={queueAlbumLaterById}
|
||||
onAddAlbumToPlaylist={addAlbumToPlaylistById}
|
||||
onAlbumShareQobuz={shareAlbumQobuzLinkById}
|
||||
onAlbumShareSonglink={shareAlbumSonglinkById}
|
||||
onAlbumDownload={downloadAlbumById}
|
||||
onOpenAlbumFolder={openAlbumFolderById}
|
||||
onReDownloadAlbum={reDownloadAlbumById}
|
||||
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
|
||||
{downloadStateVersion}
|
||||
onArtistClick={handleArtistClick}
|
||||
/>
|
||||
{:else if activeView === 'library' || activeView === 'library-album'}
|
||||
<LocalLibraryView
|
||||
|
||||
Reference in new issue
Block a user