Files
qbz/src/lib/components/views/DynamicSuggestView.svelte
T
vicrodh fd1a4fade1 fix(qbzd): route ALL playback and queue calls through commandRouter
playbackService.ts: v2_play_track and v2_stop_playback now route
to daemon HTTP API when in remote mode.

9 views: bulk queue operations (add_tracks_to_queue, add_tracks_to_queue_next)
now use cmdAddTracksToQueue/cmdAddTracksToQueueNext instead of
direct invoke(). 14 call sites total.

This was the root cause of 'connects but nothing plays' — the
play/queue commands were always going to local Tauri, never to
the remote daemon.

cc
2026-04-09 10:51:20 -06:00

993 lines
29 KiB
Svelte

<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { cmdAddTracksToQueue, cmdAddTracksToQueueNext } from '$lib/services/commandRouter';
import { onMount, onDestroy } from 'svelte';
import { ArrowLeft, CloudDownload, Heart, Info, ListPlus, Play, RefreshCw, Search, Shuffle, X, SquareCheckBig } from 'lucide-svelte';
import PlaylistModal from '$lib/components/PlaylistModal.svelte';
import TrackRow from '$lib/components/TrackRow.svelte';
import BulkActionBar from '$lib/components/BulkActionBar.svelte';
import { t } from '$lib/i18n';
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import { getDynamicSuggest } from '$lib/services/dynamicSuggest';
import { getHomeSeeds, getHomeSeedsML } from '$lib/services/recoService';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { showToast } from '$lib/stores/toastStore';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
import type { DisplayTrack } from '$lib/types';
import type {
DynamicSuggestResponse,
DynamicTrackToAnalyseInput,
DynamicSuggestTrack
} from '$lib/types/dynamicSuggest';
interface Props {
onBack: () => void;
onTrackPlay?: (track: DisplayTrack) => void;
onTrackPlayNext?: (track: DisplayTrack) => void;
onTrackPlayLater?: (track: DisplayTrack) => void;
onTrackAddToPlaylist?: (trackId: number) => void;
onTrackShareQobuz?: (trackId: number) => void;
onTrackShareSonglink?: (track: DisplayTrack) => void;
onTrackGoToAlbum?: (albumId: string) => void;
onTrackGoToArtist?: (artistId: number) => void;
onTrackShowInfo?: (trackId: number) => void;
onTrackDownload?: (track: DisplayTrack) => void;
onTrackRemoveDownload?: (trackId: number) => void;
onTrackReDownload?: (track: DisplayTrack) => void;
getTrackOfflineCacheStatus?: (trackId: number) => { status: OfflineCacheStatus; progress: number };
activeTrackId?: number | null;
isPlaybackActive?: boolean;
}
let {
onBack,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddToPlaylist,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist,
onTrackShowInfo,
onTrackDownload,
onTrackRemoveDownload,
onTrackReDownload,
getTrackOfflineCacheStatus,
activeTrackId = null,
isPlaybackActive = false,
}: Props = $props();
interface RawSuggestTrack {
performer?: { id?: number };
composer?: { id?: number };
album?: {
label?: { id?: number };
genre?: { id?: number };
};
}
interface Playlist {
id: number;
name: string;
tracks_count: number;
}
interface DailyMixCacheEntry {
localDate: string;
result: DynamicSuggestResponse;
cachedAtIso: string;
}
const DAILY_MIX_CACHE_KEY = 'qbz-dailyq-cache-v2';
const DAILY_MIX_TARGET_SIZE = 50;
const DAILY_ANALYSE_TRACKS = 9;
const DAILY_SEED_POOL = 120;
let loading = $state(false);
let showPlaylistModal = $state(false);
let userPlaylists = $state<Playlist[]>([]);
let playlistModalTrackIds = $state<number[]>([]);
let error = $state<string | null>(null);
let result = $state<DynamicSuggestResponse | null>(null);
let autoRunDone = false;
let searchQuery = $state('');
// Multi-select
let multiSelectMode = $state(false);
let multiSelectedIds = $state(new Set<number>());
function toggleMultiSelectMode() {
multiSelectMode = !multiSelectMode;
if (!multiSelectMode) multiSelectedIds = new Set();
}
function toggleMultiSelect(id: number) {
const next = new Set(multiSelectedIds);
if (next.has(id)) next.delete(id); else next.add(id);
multiSelectedIds = next;
}
function toggleSelectAll() {
const allIds = filteredTracks.map(track => track.id);
if (multiSelectedIds.size === allIds.length) {
multiSelectedIds = new Set();
} else {
multiSelectedIds = new Set(allIds);
}
}
async function handleBulkPlayNext() {
const selected = filteredTracks.filter(trk => multiSelectedIds.has(trk.id));
await cmdAddTracksToQueueNext(buildQueueTracks(selected));
multiSelectMode = false; multiSelectedIds = new Set();
}
async function handleBulkPlayLater() {
const selected = filteredTracks.filter(trk => multiSelectedIds.has(trk.id));
await cmdAddTracksToQueue(buildQueueTracks(selected));
multiSelectMode = false; multiSelectedIds = new Set();
}
async function handleBulkAddToPlaylist() {
const trackIds = filteredTracks.filter(trk => multiSelectedIds.has(trk.id)).map(trk => trk.id);
if (trackIds.length === 0) return;
try {
userPlaylists = await invoke<Playlist[]>('v2_get_user_playlists');
playlistModalTrackIds = trackIds;
showPlaylistModal = true;
} catch (err) {
error = err instanceof Error ? err.message : $t('yourMixes.errors.playlistsLoadFailed');
}
multiSelectMode = false; multiSelectedIds = new Set();
}
function getLocalDateKey(date: Date = new Date()): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function dedupeTrackIds(ids: number[]): number[] {
const seen = new Set<number>();
const out: number[] = [];
for (const trackId of ids) {
if (!Number.isFinite(trackId) || trackId <= 0) continue;
if (seen.has(trackId)) continue;
seen.add(trackId);
out.push(trackId);
}
return out;
}
function pickSpread(ids: number[], count: number): number[] {
if (ids.length <= count) return ids;
const picked: number[] = [];
const step = (ids.length - 1) / (count - 1);
for (let index = 0; index < count; index += 1) {
const position = Math.round(index * step);
picked.push(ids[position]);
}
return dedupeTrackIds(picked).slice(0, count);
}
function readDailyCache(localDate: string): DailyMixCacheEntry | null {
const raw = getUserItem(DAILY_MIX_CACHE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as DailyMixCacheEntry;
if (!parsed || parsed.localDate !== localDate || !parsed.result) return null;
return parsed;
} catch {
return null;
}
}
function writeDailyCache(entry: DailyMixCacheEntry): void {
setUserItem(DAILY_MIX_CACHE_KEY, JSON.stringify(entry));
}
function qualityLabel(track: DynamicSuggestTrack): string {
const bitDepth = track.maximum_bit_depth;
const rate = track.maximum_sampling_rate;
if (bitDepth && rate) {
return `${bitDepth}bit/${rate}kHz`;
}
return '';
}
async function buildSeeds(): Promise<{
listenedTrackIds: number[];
tracksToAnalyse: DynamicTrackToAnalyseInput[];
limit: number;
}> {
const [seedsRecent, seedsMl] = await Promise.all([
getHomeSeeds({ continueTracks: 90, favorites: 25 }),
getHomeSeedsML({ continueTracks: 90, favorites: 25 })
]);
const merged = dedupeTrackIds([
...seedsRecent.continueListeningTrackIds,
...seedsMl.continueListeningTrackIds,
...seedsRecent.favoriteTrackIds,
...seedsMl.favoriteTrackIds
]).slice(0, DAILY_SEED_POOL);
const analysedSeedIds = pickSpread(merged, DAILY_ANALYSE_TRACKS);
const analysed = await Promise.all(
analysedSeedIds.map(async (trackId) => {
const raw = await invoke<RawSuggestTrack>('v2_get_track', { trackId });
const artistId = raw.performer?.id ?? raw.composer?.id ?? 0;
return {
trackId,
artistId,
genreId: raw.album?.genre?.id ?? 0,
labelId: raw.album?.label?.id ?? 0
};
})
);
const tracksToAnalyse = analysed
.filter((item) => item.artistId > 0)
.slice(0, DAILY_ANALYSE_TRACKS);
const limit = Math.max(1, DAILY_MIX_TARGET_SIZE - tracksToAnalyse.length);
return {
listenedTrackIds: merged,
tracksToAnalyse,
limit
};
}
async function generateDailyQ(cacheMode: 'none' | 'read-write' = 'none'): Promise<void> {
loading = true;
error = null;
try {
const localDate = getLocalDateKey();
if (cacheMode === 'read-write') {
const cached = readDailyCache(localDate);
if (cached) {
result = cached.result;
return;
}
}
const seedPayload = await buildSeeds();
if (seedPayload.listenedTrackIds.length === 0) {
error = $t('yourMixes.errors.insufficientHistory');
result = null;
return;
}
let response = await getDynamicSuggest(seedPayload);
if (response.tracks.items.length === 0 && seedPayload.listenedTrackIds.length > 0) {
response = await getDynamicSuggest({
limit: DAILY_MIX_TARGET_SIZE,
listenedTrackIds: seedPayload.listenedTrackIds,
tracksToAnalyse: []
});
}
result = response;
if (cacheMode === 'read-write') {
writeDailyCache({
localDate,
result: response,
cachedAtIso: new Date().toISOString()
});
}
} catch (err) {
error = err instanceof Error ? err.message : $t('yourMixes.errors.fetchFailed');
result = null;
} finally {
loading = false;
}
}
function getResultTrackIds(): number[] {
if (!result) return [];
return result.tracks.items
.map((track) => Number(track.id))
.filter((trackId) => Number.isFinite(trackId) && trackId > 0);
}
async function openSaveAsPlaylist(): Promise<void> {
const trackIds = getResultTrackIds();
if (trackIds.length === 0) {
showToast($t('yourMixes.errors.emptyPlaylist'), 'info');
return;
}
try {
userPlaylists = await invoke<Playlist[]>('v2_get_user_playlists');
playlistModalTrackIds = trackIds;
showPlaylistModal = true;
} catch (err) {
error = err instanceof Error ? err.message : $t('yourMixes.errors.playlistsLoadFailed');
}
}
function toDisplayTrack(track: DynamicSuggestTrack): DisplayTrack {
return {
id: track.id,
title: track.title,
artist: track.performer?.name || 'Unknown Artist',
album: track.album?.title,
albumArt: getQobuzImage(track.album?.image),
albumId: track.album?.id,
artistId: track.performer?.id,
duration: formatDuration(track.duration || 0),
durationSeconds: track.duration || 0,
hires: track.hires,
bitDepth: track.maximum_bit_depth,
samplingRate: track.maximum_sampling_rate,
};
}
function buildQueueTracks(tracks: DynamicSuggestTrack[]) {
return tracks
.filter(trk => {
if (!trk.performer?.id) return true;
return !isArtistBlacklisted(trk.performer.id);
})
.map(trk => ({
id: trk.id,
title: trk.title,
artist: trk.performer?.name || 'Unknown Artist',
album: trk.album?.title || 'DailyQ',
duration_secs: trk.duration || 0,
artwork_url: getQobuzImage(trk.album?.image) || '',
hires: trk.hires ?? false,
bit_depth: trk.maximum_bit_depth ?? null,
sample_rate: trk.maximum_sampling_rate ?? null,
is_local: false,
album_id: trk.album?.id || null,
artist_id: trk.performer?.id ?? null,
}));
}
async function handleTrackClick(track: DynamicSuggestTrack): Promise<void> {
const queueTracks = buildQueueTracks(filteredTracks);
const queueTrackIds = queueTracks.map(qt => qt.id);
const queueIndex = queueTrackIds.indexOf(track.id);
if (queueIndex < 0) return;
await setPlaybackContext(
'daily_q',
'dailyq',
'DailyQ',
'qobuz',
queueTrackIds,
queueIndex
);
try {
await replacePlaybackQueue(queueTracks, queueIndex, {
debugLabel: 'dynamic-suggest'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
onTrackPlay?.(toDisplayTrack(track));
}
async function handlePlayAll(): Promise<void> {
if (filteredTracks.length === 0) return;
await handleTrackClick(filteredTracks[0]);
}
async function handleShuffle(): Promise<void> {
if (filteredTracks.length === 0) return;
const randomIndex = Math.floor(Math.random() * filteredTracks.length);
const track = filteredTracks[randomIndex];
if (!track) return;
await handleTrackClick(track);
}
const filteredTracks = $derived.by(() => {
const items = result?.tracks.items ?? [];
const query = searchQuery.trim().toLowerCase();
if (!query) return items;
return items.filter((track) => {
const title = (track.title ?? '').toLowerCase();
const artist = (track.performer?.name ?? '').toLowerCase();
const album = (track.album?.title ?? '').toLowerCase();
return title.includes(query) || artist.includes(query) || album.includes(query);
});
});
const selectAllState = $derived(
!filteredTracks || filteredTracks.length === 0 ? 'none' as const
: multiSelectedIds.size === 0 ? 'none' as const
: multiSelectedIds.size === filteredTracks.length ? 'all' as const
: 'partial' as const
);
let showAlgoTooltip = $state(false);
let algoTooltipTimer: ReturnType<typeof setTimeout> | null = null;
function toggleAlgoTooltip(event: MouseEvent) {
event.stopPropagation();
if (showAlgoTooltip) {
dismissAlgoTooltip();
return;
}
showAlgoTooltip = true;
algoTooltipTimer = setTimeout(dismissAlgoTooltip, 2000);
}
function dismissAlgoTooltip() {
showAlgoTooltip = false;
if (algoTooltipTimer) { clearTimeout(algoTooltipTimer); algoTooltipTimer = null; }
}
function handleGlobalClick() {
if (showAlgoTooltip) dismissAlgoTooltip();
}
const totalDurationFormatted = $derived.by(() => {
const totalSecs = filteredTracks.reduce((sum, track) => sum + (track.duration || 0), 0);
const hours = Math.floor(totalSecs / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
});
onMount(() => {
if (autoRunDone) return;
autoRunDone = true;
void generateDailyQ('read-write');
document.addEventListener('click', handleGlobalClick);
});
onDestroy(() => {
dismissAlgoTooltip();
document.removeEventListener('click', handleGlobalClick);
});
</script>
<div class="dailyq-view">
<div class="nav-row">
<button class="back-btn" onclick={onBack}>
<ArrowLeft size={16} />
<span>{$t('actions.back')}</span>
</button>
</div>
<div class="playlist-header">
<div class="artwork-container">
<div class="artwork artwork-daily"></div>
</div>
<div class="metadata">
<span class="playlist-label">{$t('home.yourMixes')}</span>
<h1 class="playlist-title">
{$t('yourMixes.title')}
<span class="info-wrapper">
<button class="info-btn" onclick={toggleAlgoTooltip}>
<Info size={16} />
</button>
{#if showAlgoTooltip}
<span class="info-tooltip">{$t('yourMixes.algorithmInfo')}</span>
{/if}
</span>
</h1>
<p class="playlist-description">{@html $t('yourMixes.cardDesc')}</p>
<div class="playlist-info">
<span>{$t('yourMixes.result.count', { values: { count: filteredTracks.length } })}</span>
{#if filteredTracks.length > 0}
<span class="separator">•</span>
<span>{totalDurationFormatted}</span>
{/if}
</div>
<div class="actions">
<button class="action-btn-circle primary" onclick={handlePlayAll} disabled={loading || filteredTracks.length === 0} title={$t('actions.playNow')}>
<Play size={20} fill="currentColor" color="currentColor" />
</button>
<button class="action-btn-circle" onclick={handleShuffle} disabled={loading || filteredTracks.length === 0} title={$t('actions.shuffle')}>
<Shuffle size={18} />
</button>
<button class="action-btn-circle" onclick={openSaveAsPlaylist} disabled={loading || !result || result.tracks.items.length === 0} title={$t('yourMixes.actions.saveAsPlaylist')}>
<ListPlus size={18} />
</button>
<button class="action-btn-circle" onclick={() => generateDailyQ('none')} disabled={loading} title={$t('actions.refresh')}>
<RefreshCw size={18} />
</button>
<button
class="action-btn-circle"
class:is-active={multiSelectMode}
onclick={toggleMultiSelectMode}
disabled={loading || filteredTracks.length === 0}
title={multiSelectMode ? $t('actions.cancelSelection') : $t('actions.select')}
>
<SquareCheckBig size={18} />
</button>
</div>
</div>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="track-controls">
<div class="search-container">
<Search size={16} class="search-icon" />
<input
type="text"
placeholder={$t('placeholders.searchInPlaylist')}
bind:value={searchQuery}
class="search-input"
/>
{#if searchQuery}
<button class="search-clear" onclick={() => searchQuery = ''}>
<X size={14} />
</button>
{/if}
</div>
</div>
{#if !result && loading}
<div class="empty">{$t('actions.loading')}</div>
{:else if result && filteredTracks.length === 0}
<div class="empty">{$t('yourMixes.result.empty')}</div>
{:else if result}
<div class="track-list">
<div class="track-list-header">
{#if multiSelectMode}
<div class="col-select-all">
<input
type="checkbox"
checked={selectAllState === 'all'}
indeterminate={selectAllState === 'partial'}
onchange={toggleSelectAll}
title={$t('actions.selectAll')}
/>
</div>
{/if}
<div class="col-number">#</div>
<div class="col-artwork"></div>
<div class="col-title">{$t('tracklist.title')}</div>
<div class="col-album">{$t('tracklist.album')}</div>
<div class="col-duration">{$t('tracklist.duration')}</div>
<div class="col-quality">{$t('tracklist.quality')}</div>
<div class="col-icon"><Heart size={14} /></div>
<div class="col-icon"><CloudDownload size={14} /></div>
<div class="col-spacer"></div>
</div>
{#each filteredTracks as track, index}
{@const trackBlacklisted = track.performer?.id ? isArtistBlacklisted(track.performer.id) : false}
{@const cacheStatus = getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 }}
{@const isTrackDownloaded = cacheStatus.status === 'ready'}
{@const displayTrack = toDisplayTrack(track)}
<TrackRow
trackId={track.id}
number={index + 1}
title={track.title}
artist={track.performer?.name}
album={track.album?.title}
duration={formatDuration(track.duration || 0)}
quality={qualityLabel(track)}
showArtwork={true}
artworkUrl={getQobuzImage(track.album?.image)}
isPlaying={isPlaybackActive && activeTrackId === track.id}
isActiveTrack={activeTrackId === track.id}
isBlacklisted={trackBlacklisted}
selectable={multiSelectMode}
selected={multiSelectedIds.has(track.id)}
dragTrackIds={multiSelectMode && multiSelectedIds.has(track.id) ? [...multiSelectedIds] : undefined}
onToggleSelect={() => toggleMultiSelect(track.id)}
hideDownload={trackBlacklisted}
hideFavorite={trackBlacklisted}
downloadStatus={cacheStatus.status}
downloadProgress={cacheStatus.progress}
onPlay={!trackBlacklisted ? () => handleTrackClick(track) : undefined}
onDownload={!trackBlacklisted && onTrackDownload ? () => onTrackDownload(displayTrack) : undefined}
onRemoveDownload={isTrackDownloaded && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
menuActions={trackBlacklisted ? {
onGoToAlbum: track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album!.id!) : undefined,
onGoToArtist: track.performer?.id && onTrackGoToArtist ? () => onTrackGoToArtist(track.performer!.id!) : undefined,
onShowInfo: onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined,
} : {
onPlayNow: () => handleTrackClick(track),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(displayTrack) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(displayTrack) : undefined,
onAddToPlaylist: onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined,
onShareQobuz: onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined,
onShareSonglink: onTrackShareSonglink ? () => onTrackShareSonglink(displayTrack) : undefined,
onGoToAlbum: track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album!.id!) : undefined,
onGoToArtist: track.performer?.id && onTrackGoToArtist ? () => onTrackGoToArtist(track.performer!.id!) : undefined,
onShowInfo: onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined,
onDownload: onTrackDownload ? () => onTrackDownload(displayTrack) : undefined,
isTrackDownloaded,
onReDownload: isTrackDownloaded && onTrackReDownload ? () => onTrackReDownload(displayTrack) : undefined,
onRemoveDownload: isTrackDownloaded && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined,
}}
/>
{/each}
<BulkActionBar
count={multiSelectedIds.size}
onPlayNext={handleBulkPlayNext}
onPlayLater={handleBulkPlayLater}
onAddToPlaylist={handleBulkAddToPlaylist}
onClearSelection={() => { multiSelectedIds = new Set(); }}
/>
</div>
{/if}
</div>
<PlaylistModal
isOpen={showPlaylistModal}
mode="addTrack"
trackIds={playlistModalTrackIds}
userPlaylists={userPlaylists}
isLocalTracks={false}
onClose={() => {
showPlaylistModal = false;
}}
onSuccess={() => {
showToast($t('toast.addedToPlaylist'), 'success');
showPlaylistModal = false;
}}
/>
<style>
.dailyq-view {
padding: 8px 24px 100px 24px;
color: var(--text-primary);
height: 100%;
overflow-y: auto;
}
.nav-row {
display: flex;
align-items: center;
margin-bottom: 24px;
}
.back-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 0;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
margin-top: 24px;
margin-bottom: 24px;
transition: color 150ms ease;
}
.back-btn:hover {
color: var(--text-primary);
}
.playlist-header {
display: flex;
gap: 32px;
margin-bottom: 32px;
}
.artwork-container {
flex-shrink: 0;
}
.artwork {
width: 186px;
height: 186px;
position: relative;
border-radius: 8px;
overflow: hidden;
background-color: var(--bg-tertiary);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.artwork-daily {
position: relative;
overflow: hidden;
}
.artwork-daily::before {
content: '';
position: absolute;
inset: -40%;
background:
linear-gradient(125deg, transparent 20%, rgba(255, 255, 230, 0.45) 23%, transparent 26%),
linear-gradient(125deg, transparent 55%, rgba(80, 30, 0, 0.35) 58%, transparent 61%),
radial-gradient(ellipse at 30% 20%, rgba(255, 255, 255, 0.25) 0%, transparent 50%),
radial-gradient(ellipse at 70% 60%, rgba(255, 200, 50, 0.4) 0%, transparent 50%),
radial-gradient(ellipse at 20% 80%, rgba(255, 140, 0, 0.5) 0%, transparent 60%),
linear-gradient(135deg, #e8a020 0%, #d4781a 30%, #c45e18 60%, #a04010 100%);
will-change: transform;
animation: silk-daily 30s ease-in-out infinite alternate;
}
@keyframes silk-daily {
0% { transform: translate(5%, 3%) rotate(0deg) scale(1); }
25% { transform: translate(-8%, 6%) rotate(6deg) scale(1.03); }
50% { transform: translate(3%, -5%) rotate(-4deg) scale(0.98); }
75% { transform: translate(-4%, 8%) rotate(8deg) scale(1.02); }
100% { transform: translate(6%, -3%) rotate(-2deg) scale(1); }
}
.metadata {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-width: 0;
}
.playlist-label {
font-size: 12px;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.1em;
margin-bottom: 8px;
}
.playlist-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px 0;
line-height: 1.2;
display: flex;
align-items: center;
gap: 8px;
}
.info-wrapper {
position: relative;
display: inline-flex;
align-items: center;
}
.info-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
transition: color 150ms ease;
}
.info-btn:hover {
color: var(--text-primary);
}
.info-tooltip {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 1.4;
padding: 8px 12px;
border-radius: 6px;
white-space: normal;
width: 260px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 10;
pointer-events: none;
animation: tooltip-fade-in 150ms ease;
}
@keyframes tooltip-fade-in {
from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.playlist-description {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 12px 0;
line-height: 1.4;
}
.playlist-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
}
.separator {
color: var(--text-muted);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.error {
margin: 0 0 10px;
color: var(--error-color, #ef4444);
}
.track-controls {
display: flex;
align-items: center;
gap: 16px;
margin-top: 24px;
margin-bottom: 16px;
}
.search-container {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg-tertiary);
border-radius: 8px;
padding: 8px 12px;
flex: 1;
max-width: 300px;
}
.search-container :global(.search-icon) {
color: var(--text-muted);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: none;
border: none;
color: var(--text-primary);
font-size: 14px;
outline: none;
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-clear {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
}
.search-clear:hover {
color: var(--text-primary);
}
.track-list {
margin-top: 24px;
}
.track-list-header {
width: 100%;
height: 40px;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
font-size: 12px;
text-transform: uppercase;
color: #666666;
font-weight: 400;
box-sizing: border-box;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 8px;
}
.col-select-all {
width: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.col-select-all input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-primary);
cursor: pointer;
}
.col-number {
width: 48px;
text-align: center;
}
.col-artwork {
width: 36px;
flex-shrink: 0;
}
.col-title {
flex: 1;
min-width: 0;
}
.col-album {
flex: 1;
min-width: 0;
}
.col-duration {
width: 80px;
text-align: center;
}
.col-quality {
width: 80px;
text-align: center;
}
.col-icon {
width: 28px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
opacity: 0.5;
}
.col-spacer {
width: 28px;
}
.empty {
border: 1px dashed var(--border-color);
border-radius: 10px;
padding: 18px;
color: var(--text-muted);
text-align: center;
}
@media (max-width: 760px) {
.playlist-header {
flex-direction: column;
gap: 16px;
}
.artwork {
width: 160px;
height: 160px;
}
.playlist-title {
font-size: 20px;
}
.track-list-header {
display: none;
}
}
</style>