Files
qbz/src/lib/components/TrackRow.svelte
T
vicrodh a2eabf08f1 fix: freeze equalizer animation when player is paused
Separate isActiveTrack (current track) from isPlaying (actively playing)
so the equalizer bars freeze via animation-play-state when paused instead
of always animating. Show Play icon on hover when paused, Pause when playing.
Also fix AlbumDetailView which was missing the isPlaybackActive check.
2026-03-08 13:24:12 -06:00

774 lines
19 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { Play, Pause, Heart, HardDrive, AlertCircle, Ban, Music } from 'lucide-svelte';
import { t } from '$lib/i18n';
import TrackMenu from './TrackMenu.svelte';
import DownloadButton from './DownloadButton.svelte';
import {
subscribe as subscribeFavorites,
isTrackFavorite,
isTrackToggling,
toggleTrackFavorite
} from '$lib/stores/favoritesStore';
import { togglePlay } from '$lib/stores/playerStore';
// Offline cache status for tracks
type OfflineCacheStatus = 'none' | 'queued' | 'downloading' | 'ready' | 'failed';
interface Props {
trackId?: number; // Optional - required for favorites functionality unless hideFavorite=true
number: number;
title: string;
artist?: string;
album?: string;
duration: string;
quality?: string;
isPlaying?: boolean;
isActiveTrack?: boolean;
isLocal?: boolean; // Whether this is a local library track
localSource?: 'local' | 'plex';
isUnavailable?: boolean; // Track removed from Qobuz or otherwise unavailable
unavailableTooltip?: string; // Tooltip for unavailable indicator
isBlacklisted?: boolean; // Artist is blacklisted
isFavoriteOverride?: boolean; // Optional override for favorite state
downloadStatus?: OfflineCacheStatus;
downloadProgress?: number;
hideDownload?: boolean;
hideFavorite?: boolean;
compact?: boolean; // Compact mode: smaller height, artist as column
showArtwork?: boolean; // Optional artwork column (e.g., playlist detail)
artworkUrl?: string;
explicit?: boolean; // Parental advisory / explicit content
selectable?: boolean; // Multi-select mode: show checkbox
selected?: boolean;
onToggleSelect?: (e: MouseEvent) => void;
onPlay?: () => void;
onArtistClick?: () => void;
onAlbumClick?: () => void;
onDownload?: () => void;
onRemoveDownload?: () => void;
menuActions?: TrackMenuActions;
}
interface TrackMenuActions {
onPlayNow?: () => void;
onPlayTrackOnly?: () => void;
onPlayFromHere?: () => void;
onPlayNext?: () => void;
onPlayLater?: () => void;
onCreateQbzRadio?: () => void;
onCreateQobuzRadio?: () => void;
onAddToPlaylist?: () => void;
onRemoveFromPlaylist?: () => void;
onFindReplacement?: () => void;
onShareQobuz?: () => void;
onShareSonglink?: () => void;
onGoToAlbum?: () => void;
onGoToArtist?: () => void;
onShowInfo?: () => void;
onDownload?: () => void;
isTrackDownloaded?: boolean;
onReDownload?: () => void;
onRemoveDownload?: () => void;
}
let {
trackId,
number,
title,
artist,
album,
duration,
quality,
isPlaying = false,
isActiveTrack = false,
isLocal = false,
localSource = 'local',
isUnavailable = false,
unavailableTooltip,
isFavoriteOverride,
isBlacklisted = false,
downloadStatus = 'none',
downloadProgress = 0,
hideDownload = false,
hideFavorite = false,
compact = false,
explicit = false,
showArtwork = false,
artworkUrl,
selectable = false,
selected = false,
onToggleSelect,
onPlay,
onArtistClick,
onAlbumClick,
onDownload,
onRemoveDownload,
menuActions
}: Props = $props();
let isHovered = $state(false);
let contextMenuPos = $state<{ x: number; y: number } | null>(null);
let favoriteFromStore = $state(false);
let isToggling = $state(false);
// Use override if provided, otherwise use store
const isFavorite = $derived(isFavoriteOverride ?? favoriteFromStore);
const playNowAction = $derived(menuActions?.onPlayNow ?? onPlay);
const artistClickAction = $derived(onArtistClick ?? menuActions?.onGoToArtist);
const albumClickAction = $derived(onAlbumClick ?? menuActions?.onGoToAlbum);
// Subscribe to favorites store (only if trackId is provided)
onMount(() => {
if (trackId !== undefined) {
favoriteFromStore = isTrackFavorite(trackId);
isToggling = isTrackToggling(trackId);
const unsubscribe = subscribeFavorites(() => {
favoriteFromStore = isTrackFavorite(trackId);
isToggling = isTrackToggling(trackId);
});
return unsubscribe;
}
});
// Handle favorite toggle internally
async function handleToggleFavorite(e: MouseEvent) {
e.stopPropagation();
if (trackId !== undefined) {
await toggleTrackFavorite(trackId);
}
}
function handleArtistClick(e: MouseEvent) {
e.stopPropagation();
artistClickAction?.();
}
function handleAlbumClick(e: MouseEvent) {
e.stopPropagation();
albumClickAction?.();
}
function handlePauseClick(e: MouseEvent) {
e.stopPropagation();
void togglePlay();
}
</script>
<div
class="track-row"
class:playing={isActiveTrack || isPlaying}
class:hovered={isHovered && !isActiveTrack && !isPlaying && !isBlacklisted}
class:compact
class:blacklisted={isBlacklisted}
class:selected
data-track-id={trackId ?? undefined}
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
onclick={selectable ? onToggleSelect : (isBlacklisted ? undefined : onPlay)}
oncontextmenu={(e) => {
if (isBlacklisted || selectable) return;
e.preventDefault();
contextMenuPos = { x: e.clientX, y: e.clientY };
}}
role="button"
tabindex={isBlacklisted ? -1 : 0}
onkeydown={(e) => e.key === 'Enter' && !isBlacklisted && (selectable ? onToggleSelect?.(e as unknown as MouseEvent) : onPlay?.())}
>
<!-- Checkbox (select mode) -->
{#if selectable}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="track-checkbox" onclick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selected}
onchange={onToggleSelect as unknown as (e: Event) => void}
aria-label="Select track"
/>
</div>
{/if}
<!-- Track Number / Play Button / Unavailable Indicator -->
<div class="track-number" class:unavailable={isUnavailable} class:blacklisted={isBlacklisted}>
{#if isBlacklisted}
<span class="blacklisted-icon" title="Artist is blacklisted">
<Ban size={14} />
</span>
{:else if isUnavailable}
<span class="unavailable-icon" title={unavailableTooltip}>
<AlertCircle size={16} />
</span>
{:else if isActiveTrack || isPlaying}
{#if isHovered}
{#if isPlaying}
<button class="pause-btn" type="button" onclick={handlePauseClick} aria-label="Pause">
<Pause size={16} class="pause-icon" />
</button>
{:else}
<button class="pause-btn" type="button" onclick={handlePauseClick} aria-label="Resume">
<Play size={16} class="play-icon" fill="white" />
</button>
{/if}
{:else}
<div class="playing-indicator" class:paused={!isPlaying}>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
{/if}
{:else if isHovered}
<Play size={16} class="play-icon" fill="white" />
{:else}
<span>{number}</span>
{/if}
</div>
<!-- Track Info -->
{#if showArtwork}
<div class="track-artwork">
<div class="track-artwork-placeholder">
<Music size={14} />
</div>
{#if artworkUrl}
<img src={artworkUrl} alt={title} loading="lazy" decoding="async" />
{/if}
</div>
{/if}
<!-- Track Info -->
<div class="track-info">
<div class="track-title-row">
<span class="track-title" class:active={isActiveTrack || isPlaying}>{title}</span>
{#if explicit}
<span class="explicit-badge" title="Explicit"></span>
{/if}
</div>
{#if artist && !compact}
{#if artistClickAction}
<button class="track-artist track-link" type="button" onclick={handleArtistClick}>
{artist}
</button>
{:else}
<div class="track-artist">{artist}</div>
{/if}
{/if}
</div>
<!-- Artist Column (compact mode) -->
{#if artist && compact}
{#if artistClickAction}
<button class="track-artist-column track-link" type="button" onclick={handleArtistClick}>
{artist}
</button>
{:else}
<div class="track-artist-column">{artist}</div>
{/if}
{/if}
<!-- Album Column -->
{#if album}
{#if albumClickAction}
<button class="track-album track-link" type="button" onclick={handleAlbumClick}>
{album}
</button>
{:else}
<div class="track-album">{album}</div>
{/if}
{/if}
<!-- Duration -->
<div class="track-duration">{duration}</div>
<!-- Quality (always render to maintain column alignment) -->
<div class="track-quality">{quality ?? ''}</div>
<!-- Favorite Button (placeholder for local tracks or hidden to maintain column width) -->
{#if isLocal || hideFavorite}
<div class="favorite-placeholder"></div>
{:else}
<button
type="button"
class="favorite-btn"
class:is-favorite={isFavorite}
class:is-toggling={isToggling}
onclick={handleToggleFavorite}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
disabled={isToggling}
>
{#if isFavorite}
<Heart size={14} fill="var(--accent-primary)" color="var(--accent-primary)" />
{:else}
<Heart size={14} color="var(--text-muted)" />
{/if}
</button>
{/if}
<!-- Download Indicator / Local Indicator (placeholder when hidden to maintain column width) -->
{#if isLocal}
<div
class="local-indicator"
class:plex-source={localSource === 'plex'}
title={localSource === 'plex' ? $t('library.plexTrackIndicator') : $t('library.localTrackIndicator')}
>
{#if localSource === 'plex'}
<span class="plex-indicator-icon" aria-hidden="true"></span>
{:else}
<HardDrive size={14} />
{/if}
</div>
{:else if hideDownload}
<div class="download-placeholder"></div>
{:else}
<div class="download-indicator" class:has-download={downloadStatus !== 'none'}>
<DownloadButton
status={downloadStatus}
progress={downloadProgress}
size={14}
onDownload={onDownload}
onRemove={onRemoveDownload}
/>
</div>
{/if}
<div class="track-actions">
<TrackMenu
onPlayNow={playNowAction}
onPlayTrackOnly={menuActions?.onPlayTrackOnly}
onPlayFromHere={menuActions?.onPlayFromHere}
onPlayNext={menuActions?.onPlayNext}
onPlayLater={menuActions?.onPlayLater}
onCreateQbzRadio={menuActions?.onCreateQbzRadio}
onCreateQobuzRadio={menuActions?.onCreateQobuzRadio}
onAddFavorite={trackId !== undefined ? () => toggleTrackFavorite(trackId) : undefined}
onAddToPlaylist={menuActions?.onAddToPlaylist}
onRemoveFromPlaylist={menuActions?.onRemoveFromPlaylist}
onFindReplacement={menuActions?.onFindReplacement}
onShareQobuz={menuActions?.onShareQobuz}
onShareSonglink={menuActions?.onShareSonglink}
onGoToAlbum={menuActions?.onGoToAlbum}
onGoToArtist={menuActions?.onGoToArtist}
onShowInfo={menuActions?.onShowInfo}
onDownload={menuActions?.onDownload}
isTrackDownloaded={menuActions?.isTrackDownloaded}
onReDownload={menuActions?.onReDownload}
onRemoveDownload={menuActions?.onRemoveDownload ?? onRemoveDownload}
contextMenuPosition={contextMenuPos}
onContextMenuClosed={() => { contextMenuPos = null; }}
/>
</div>
</div>
<style>
.track-row {
width: 100%;
height: 56px;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
border-radius: 8px;
cursor: pointer;
transition: background-color 150ms ease;
box-sizing: border-box;
}
.track-row.hovered {
background-color: var(--bg-hover);
}
.track-row.playing {
background-color: var(--bg-secondary);
}
.track-row.compact {
height: 44px;
padding: 0 12px;
gap: 12px;
}
.track-row.compact .track-number {
width: 32px;
}
.track-row.selected {
background-color: color-mix(in srgb, var(--accent-primary) 12%, transparent);
}
.track-row.selected.hovered {
background-color: color-mix(in srgb, var(--accent-primary) 20%, transparent);
}
.track-checkbox {
width: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.track-checkbox input[type='checkbox'] {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--accent-primary);
}
.track-number {
width: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.track-number span {
font-size: 14px;
color: #666666;
}
.track-number.unavailable {
color: var(--error-color, #ef4444);
}
.unavailable-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--error-color, #ef4444);
cursor: help;
}
/* Blacklisted track styles */
.track-row.blacklisted {
opacity: 0.4;
cursor: not-allowed;
}
.track-row.blacklisted:hover {
background: transparent;
}
.track-number.blacklisted {
color: var(--text-muted);
}
.blacklisted-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.track-number :global(.play-icon) {
color: white;
}
:global([data-theme="light"]) .track-number :global(.play-icon) {
color: rgba(40, 42, 54, 0.85);
}
.pause-btn {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
}
.pause-btn :global(.pause-icon) {
color: white;
}
:global([data-theme="light"]) .pause-btn :global(.pause-icon) {
color: rgba(40, 42, 54, 0.85);
}
.playing-indicator {
display: flex;
align-items: center;
gap: 2px;
}
.playing-indicator .bar {
width: 3px;
background-color: var(--accent-primary);
border-radius: 9999px;
transform-origin: bottom;
animation: equalize 1s ease-in-out infinite;
animation-play-state: running;
}
.playing-indicator.paused .bar {
animation-play-state: paused;
}
.playing-indicator .bar:nth-child(1) {
height: 12px;
}
.playing-indicator .bar:nth-child(2) {
height: 16px;
animation-delay: 0.15s;
}
.playing-indicator .bar:nth-child(3) {
height: 10px;
animation-delay: 0.3s;
}
@keyframes equalize {
0%, 100% {
transform: scaleY(0.5);
opacity: 0.7;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
.track-info {
flex: 1;
min-width: 0;
}
.track-artwork {
width: 36px;
height: 36px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
position: relative;
background-color: var(--bg-tertiary);
}
.track-artwork img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.track-artwork-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
opacity: 0.7;
}
.track-title-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.track-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-title.active {
color: var(--accent-primary);
}
.explicit-badge {
display: inline-block;
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.45;
background-color: var(--text-secondary);
-webkit-mask: url('/explicit.svg') center / contain no-repeat;
mask: url('/explicit.svg') center / contain no-repeat;
}
.track-artist {
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-artist-column {
width: 180px;
flex-shrink: 0;
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-album {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-link {
background: none;
border: none;
padding: 0;
text-align: left;
cursor: pointer;
}
.track-link:hover {
color: var(--text-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.track-duration {
font-size: 14px;
color: var(--text-muted);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
width: 80px;
text-align: center;
}
.track-quality {
font-size: 12px;
color: #666666;
width: 80px;
text-align: center;
}
.favorite-placeholder {
width: 28px;
height: 28px;
}
.local-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: var(--text-muted);
opacity: 0.6;
}
.local-indicator.plex-source {
opacity: 0.9;
}
.plex-indicator-icon {
width: 14px;
height: 14px;
background-color: var(--accent-primary);
-webkit-mask: url('/plex-mono.svg') center / contain no-repeat;
mask: url('/plex-mono.svg') center / contain no-repeat;
}
.download-placeholder {
width: 28px;
height: 28px;
}
.favorite-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0.3;
transition: opacity 150ms ease, background-color 150ms ease;
}
.favorite-btn.is-favorite {
opacity: 1;
}
.favorite-btn:hover {
opacity: 1;
background-color: var(--bg-tertiary);
}
.track-row:hover .favorite-btn {
opacity: 0.6;
}
.track-row:hover .favorite-btn.is-favorite,
.track-row:hover .favorite-btn:hover {
opacity: 1;
}
.favorite-btn.is-toggling {
opacity: 1;
cursor: wait;
animation: favorite-pulse 0.8s ease-in-out infinite;
}
@keyframes favorite-pulse {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
.download-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
opacity: 0;
transition: opacity 150ms ease;
pointer-events: none;
}
.download-indicator.has-download {
opacity: 1;
pointer-events: auto;
}
.track-row:hover .download-indicator {
opacity: 0.6;
pointer-events: auto;
}
.track-row:hover .download-indicator.has-download,
.track-row:hover .download-indicator:hover {
opacity: 1;
}
.track-actions {
margin-left: auto;
display: flex;
align-items: center;
opacity: 0.7;
transition: opacity 150ms ease;
}
.track-row:hover .track-actions,
.track-row.playing .track-actions {
opacity: 1;
}
</style>