audio streaming app
plyr.fm
1<script lang="ts">
2 import { browser } from '$app/environment';
3 import ShareButton from './ShareButton.svelte';
4 import AddToMenu from './AddToMenu.svelte';
5 import TrackActionsMenu from './TrackActionsMenu.svelte';
6 import LikersTooltip from './LikersTooltip.svelte';
7 import CommentersTooltip from './CommentersTooltip.svelte';
8 import SensitiveImage from './SensitiveImage.svelte';
9 import { hasPlayableLossless, isLosslessFormat } from '$lib/audio-support';
10 import { likersSheet } from '$lib/likers-sheet.svelte';
11 import { trackCoverUrl, trackThumbnailUrl } from '$lib/track-cover';
12 import type { Track } from '$lib/types';
13 import { queue } from '$lib/queue.svelte';
14 import { toast } from '$lib/toast.svelte';
15 import { playTrack, guardGatedTrack } from '$lib/playback.svelte';
16 import {
17 getRefreshedAvatar,
18 triggerAvatarRefresh,
19 hasAttemptedRefresh
20 } from '$lib/avatar-refresh.svelte';
21
22 interface Props {
23 track: Track;
24 isPlaying?: boolean;
25 onPlay: (_track: Track) => void;
26 isAuthenticated?: boolean;
27 hideAlbum?: boolean;
28 hideArtist?: boolean;
29 index?: number;
30 showIndex?: boolean;
31 excludePlaylistId?: string;
32 }
33
34 let {
35 track,
36 isPlaying = false,
37 onPlay,
38 isAuthenticated = false,
39 hideAlbum = false,
40 hideArtist = false,
41 index = 0,
42 showIndex = false,
43 excludePlaylistId
44 }: Props = $props();
45
46 // optimize image loading: eager for first 3, lazy for rest
47 const imageLoading = index < 3 ? 'eager' : 'lazy';
48 const imageFetchPriority = index < 2 ? 'high' : undefined;
49
50 let isMobile = $state(false);
51
52 $effect(() => {
53 if (browser) {
54 const mq = window.matchMedia('(max-width: 768px)');
55 isMobile = mq.matches;
56 const handler = (e: MediaQueryListEvent) => (isMobile = e.matches);
57 mq.addEventListener('change', handler);
58 return () => mq.removeEventListener('change', handler);
59 }
60 });
61
62 let showLikersTooltip = $state(false);
63 let showCommentersTooltip = $state(false);
64 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI
65 let likeCount = $derived(track.like_count || 0);
66 let commentCount = $derived(track.comment_count || 0);
67 // local UI state keyed by track.id - reset when track changes (component recycling)
68 let trackImageError = $state(false);
69 let avatarError = $state(false);
70 let tagsExpanded = $state(false);
71 let prevTrackId: number | undefined;
72
73 // get refreshed avatar URL if available
74 let refreshedAvatarUrl = $derived(getRefreshedAvatar(track.artist_did));
75 let artistAvatarUrl = $derived(refreshedAvatarUrl ?? track.artist_avatar_url);
76
77 // cover art with album fallback — keeps track listings in sync with
78 // the player bar's existing inheritance rule.
79 let coverFullUrl = $derived(trackCoverUrl(track));
80 let coverThumbUrl = $derived(trackThumbnailUrl(track));
81
82 // reset local UI state when track changes (component may be recycled)
83 // using $effect.pre so state is ready before render
84 $effect.pre(() => {
85 if (prevTrackId !== undefined && track.id !== prevTrackId) {
86 trackImageError = false;
87 avatarError = false;
88 tagsExpanded = false;
89 }
90 prevTrackId = track.id;
91 });
92
93 /**
94 * handle avatar error - show placeholder and trigger background refresh.
95 */
96 function handleAvatarError() {
97 avatarError = true;
98 if (track.artist_did && !hasAttemptedRefresh(track.artist_did)) {
99 triggerAvatarRefresh(track.artist_did);
100 }
101 }
102
103 // limit visible tags to prevent vertical sprawl (max 2 shown)
104 const MAX_VISIBLE_TAGS = 2;
105 let visibleTags = $derived(
106 tagsExpanded ? track.tags : track.tags?.slice(0, MAX_VISIBLE_TAGS)
107 );
108 let hiddenTagCount = $derived((track.tags?.length || 0) - MAX_VISIBLE_TAGS);
109
110 // shareable URL for link previews (track page redirects to home with query param)
111 const shareUrl = typeof window !== 'undefined' ? `${window.location.origin}/track/${track.id}` : '';
112
113 function addToQueue(e: Event) {
114 e.stopPropagation();
115 if (!guardGatedTrack(track, isAuthenticated)) return;
116 queue.addTracks([track]);
117 toast.success(`queued ${track.title}`, 1800);
118 }
119
120 function handleQueue() {
121 if (!guardGatedTrack(track, isAuthenticated)) return;
122 queue.addTracks([track]);
123 toast.success(`queued ${track.title}`, 1800);
124 }
125
126 let likersTooltipTimeout: ReturnType<typeof setTimeout> | null = null;
127
128 function handleLikesMouseEnter() {
129 if (isMobile) return;
130 if (likersTooltipTimeout) {
131 clearTimeout(likersTooltipTimeout);
132 likersTooltipTimeout = null;
133 }
134 showLikersTooltip = true;
135 }
136
137 function handleLikesMouseLeave() {
138 if (isMobile) return;
139 likersTooltipTimeout = setTimeout(() => {
140 showLikersTooltip = false;
141 likersTooltipTimeout = null;
142 }, 150);
143 }
144
145 function handleLikesClick(e: Event) {
146 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) {
147 return;
148 }
149 e.stopPropagation();
150 if (isMobile) {
151 likersSheet.open(track.id, likeCount);
152 }
153 }
154
155 function handleLikesKeydown(event: KeyboardEvent) {
156 if (event.key === 'Enter' || event.key === ' ') {
157 event.preventDefault();
158 if (isMobile) {
159 likersSheet.open(track.id, likeCount);
160 } else {
161 showLikersTooltip = true;
162 }
163 }
164 if (event.key === 'Escape') {
165 showLikersTooltip = false;
166 }
167 }
168
169 let commentersTooltipTimeout: ReturnType<typeof setTimeout> | null = null;
170
171 function handleCommentsMouseEnter() {
172 if (commentersTooltipTimeout) {
173 clearTimeout(commentersTooltipTimeout);
174 commentersTooltipTimeout = null;
175 }
176 showCommentersTooltip = true;
177 }
178
179 function handleCommentsMouseLeave() {
180 commentersTooltipTimeout = setTimeout(() => {
181 showCommentersTooltip = false;
182 commentersTooltipTimeout = null;
183 }, 150);
184 }
185
186 function handleCommentsKeydown(event: KeyboardEvent) {
187 if (event.key === 'Enter' || event.key === ' ') {
188 // don't prevent default - let the link navigate
189 }
190 if (event.key === 'Escape') {
191 showCommentersTooltip = false;
192 }
193 }
194
195 function handleLikeChange(liked: boolean) {
196 // update like count immediately
197 likeCount = liked ? likeCount + 1 : likeCount - 1;
198 // also update the track object itself
199 track.like_count = likeCount;
200 }
201</script>
202
203<div
204 class="track-container"
205 class:playing={isPlaying}
206 class:lossless={hasPlayableLossless(track.original_file_type) || isLosslessFormat(track.file_type)}
207 class:likers-tooltip-open={showLikersTooltip}
208 title={hasPlayableLossless(track.original_file_type) || isLosslessFormat(track.file_type) ? 'lossless audio available' : undefined}
209>
210 {#if showIndex}
211 <span class="track-index">{index + 1}</span>
212 {/if}
213 <button
214 class="track"
215 onclick={async (e) => {
216 // only play if clicking the track itself, not a link inside
217 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) {
218 return;
219 }
220 // use playTrack for gated content checks, fall back to onPlay for non-gated
221 if (track.gated) {
222 await playTrack(track);
223 } else {
224 onPlay(track);
225 }
226 }}
227 >
228 <div class="track-image-wrapper" class:gated={track.gated}>
229 {#if (coverFullUrl || coverThumbUrl) && !trackImageError}
230 <SensitiveImage src={coverThumbUrl ?? coverFullUrl}>
231 <div class="track-image">
232 <img
233 src={coverThumbUrl ?? coverFullUrl}
234 alt="{track.title} artwork"
235 width="48"
236 height="48"
237 loading={imageLoading}
238 fetchpriority={imageFetchPriority}
239 onerror={() => trackImageError = true}
240 />
241 </div>
242 </SensitiveImage>
243 {:else if artistAvatarUrl && !avatarError}
244 <SensitiveImage src={artistAvatarUrl}>
245 <a
246 href="/u/{track.artist_handle}"
247 class="track-avatar"
248 >
249 <img
250 src={artistAvatarUrl}
251 alt={track.artist}
252 width="48"
253 height="48"
254 loading={imageLoading}
255 fetchpriority={imageFetchPriority}
256 onerror={handleAvatarError}
257 />
258 </a>
259 </SensitiveImage>
260 {:else}
261 <div class="track-image-placeholder">
262 <svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
263 <circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" />
264 <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
265 </svg>
266 </div>
267 {/if}
268 {#if track.gated}
269 <div class="gated-badge" title="supporters only">
270 <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
271 <path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
272 </svg>
273 </div>
274 {/if}
275 </div>
276 <div class="track-info">
277 <a href="/track/{track.id}" class="track-title">{track.title}</a>
278 <div class="track-metadata">
279 {#if (!hideArtist) || (track.features && track.features.length > 0)}
280 <div class="artist-line"
281 class:only-features={hideArtist && track.features && track.features.length > 0}
282 >
283 {#if !hideArtist}
284 <a
285 href="/u/{track.artist_handle}"
286 class="artist-link"
287 >
288 {track.artist}
289 </a>
290 {/if}
291 {#if track.features && track.features.length > 0}
292 <span class="features-inline">
293 <span class="features-label">feat.</span>
294 {#each track.features as feature, i}
295 {#if i > 0}<span class="feature-separator">, </span>{/if}
296 <a href="/u/{feature.handle}" class="feature-link">
297 {feature.display_name}
298 </a>
299 {/each}
300 </span>
301 {/if}
302 </div>
303 {/if}
304 {#if track.album && !hideAlbum}
305 <span class="metadata-separator">•</span>
306 <span class="album">
307 <svg class="album-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
308 <rect x="2" y="2" width="12" height="12" stroke="currentColor" stroke-width="1.5" fill="none"/>
309 <circle cx="8" cy="8" r="2.5" fill="currentColor"/>
310 </svg>
311 <a href="/u/{track.artist_handle}/album/{track.album.slug}" class="album-link">
312 {track.album.title}
313 </a>
314 </span>
315 {/if}
316 {#if track.tags && track.tags.length > 0}
317 <span class="tags-line" class:expanded={tagsExpanded}>
318 {#each visibleTags as tag}
319 <a href="/tag/{encodeURIComponent(tag)}" class="tag-badge">{tag}</a>
320 {/each}
321 {#if hiddenTagCount > 0 && !tagsExpanded}
322 <span
323 class="tags-more"
324 role="button"
325 tabindex="0"
326 onclick={(e) => { e.stopPropagation(); tagsExpanded = true; }}
327 onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); tagsExpanded = true; } }}
328 >
329 +{hiddenTagCount}
330 </span>
331 {/if}
332 </span>
333 {/if}
334 </div>
335 <div class="track-meta">
336 <span class="plays">{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span>
337 {#if likeCount > 0}
338 <span class="meta-separator">•</span>
339 <span
340 class="likes"
341 role="button"
342 tabindex="0"
343 aria-label={`${likeCount} ${likeCount === 1 ? 'like' : 'likes'} (focus to view users)`}
344 aria-expanded={showLikersTooltip}
345 onclick={handleLikesClick}
346 onmouseenter={handleLikesMouseEnter}
347 onmouseleave={handleLikesMouseLeave}
348 onfocus={handleLikesMouseEnter}
349 onblur={handleLikesMouseLeave}
350 onkeydown={handleLikesKeydown}
351 >
352 {likeCount} {likeCount === 1 ? 'like' : 'likes'}
353 {#if showLikersTooltip && !isMobile}
354 <LikersTooltip
355 trackId={track.id}
356 likeCount={likeCount}
357 onMouseEnter={handleLikesMouseEnter}
358 onMouseLeave={handleLikesMouseLeave}
359 />
360 {/if}
361 </span>
362 {/if}
363 {#if commentCount > 0}
364 <span class="meta-separator">•</span>
365 <span
366 class="comments-wrapper"
367 role="button"
368 tabindex="0"
369 aria-label="{commentCount} {commentCount === 1 ? 'comment' : 'comments'} (focus to view participants)"
370 aria-expanded={showCommentersTooltip}
371 onmouseenter={handleCommentsMouseEnter}
372 onmouseleave={handleCommentsMouseLeave}
373 onfocus={handleCommentsMouseEnter}
374 onblur={handleCommentsMouseLeave}
375 onkeydown={handleCommentsKeydown}
376 >
377 <a
378 href="/track/{track.id}"
379 class="comments"
380 title="view comments"
381 >
382 <svg class="comment-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
383 <path d="M2 3h12v8H5l-3 3V3z" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
384 </svg>
385 <span class="comment-count">{commentCount}</span>
386 </a>
387 {#if showCommentersTooltip}
388 <CommentersTooltip
389 trackId={track.id}
390 commentCount={commentCount}
391 onMouseEnter={handleCommentsMouseEnter}
392 onMouseLeave={handleCommentsMouseLeave}
393 />
394 {/if}
395 </span>
396 {/if}
397 {#if hasPlayableLossless(track.original_file_type) || isLosslessFormat(track.file_type)}
398 <span class="meta-separator">·</span>
399 <span class="lossless-indicator" title="lossless audio available">lossless</span>
400 {/if}
401 </div>
402 </div>
403 </button>
404 <div class="track-actions" role="presentation" onclick={(e) => e.stopPropagation()}>
405 <!-- desktop: show individual buttons -->
406 <div class="desktop-actions">
407 {#if isAuthenticated}
408 <AddToMenu
409 trackId={track.id}
410 trackTitle={track.title}
411 trackUri={track.atproto_record_uri}
412 trackCid={track.atproto_record_cid}
413 fileId={track.file_id}
414 gated={track.gated}
415 initialLiked={track.is_liked || false}
416 disabled={!track.atproto_record_uri}
417 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined}
418 onLikeChange={handleLikeChange}
419 {excludePlaylistId}
420 />
421 {/if}
422 <button
423 class="action-button"
424 onclick={addToQueue}
425 title="add to queue"
426 >
427 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
428 <!-- plus sign -->
429 <line x1="5" y1="15" x2="5" y2="21"></line>
430 <line x1="2" y1="18" x2="8" y2="18"></line>
431 <!-- list lines -->
432 <line x1="9" y1="6" x2="21" y2="6"></line>
433 <line x1="9" y1="12" x2="21" y2="12"></line>
434 <line x1="9" y1="18" x2="21" y2="18"></line>
435 </svg>
436 </button>
437 <ShareButton url={shareUrl} title="share track" trackId={track.id} />
438 </div>
439
440 <!-- mobile: show three-dot menu -->
441 <div class="mobile-actions">
442 <TrackActionsMenu
443 trackId={track.id}
444 trackTitle={track.title}
445 trackUri={track.atproto_record_uri}
446 trackCid={track.atproto_record_cid}
447 fileId={track.file_id}
448 gated={track.gated}
449 initialLiked={track.is_liked || false}
450 shareUrl={shareUrl}
451 onQueue={handleQueue}
452 isAuthenticated={isAuthenticated}
453 likeDisabled={!track.atproto_record_uri}
454 {excludePlaylistId}
455 />
456 </div>
457 </div>
458</div>
459
460<style>
461 .track-container {
462 position: relative;
463 display: flex;
464 align-items: center;
465 gap: 0.75rem;
466 background: var(--track-bg, var(--bg-secondary));
467 border: 1px solid var(--track-border, var(--border-subtle));
468 border-radius: var(--radius-md);
469 padding: 1rem;
470 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
471 transition:
472 box-shadow 0.2s ease-out,
473 background 0.15s ease-out,
474 border-color 0.15s ease-out;
475 }
476
477 .track-index {
478 width: 24px;
479 font-size: var(--text-sm);
480 color: var(--text-muted);
481 text-align: center;
482 flex-shrink: 0;
483 font-variant-numeric: tabular-nums;
484 }
485
486 .track-container:hover {
487 background: var(--track-bg-hover, var(--bg-tertiary));
488 border-color: color-mix(in srgb, var(--accent) 15%, var(--track-border-hover, var(--border-default)));
489 box-shadow:
490 0 1px 3px rgba(0, 0, 0, 0.06),
491 0 0 8px color-mix(in srgb, var(--accent) 8%, transparent);
492 }
493
494 .track-container:active {
495 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
496 transition-duration: 0.08s;
497 }
498
499 .track-container.playing {
500 background: color-mix(in srgb, var(--accent) 10%, var(--track-bg-playing, var(--bg-tertiary)));
501 border-color: color-mix(in srgb, var(--accent) 20%, var(--track-border, var(--border-subtle)));
502 }
503
504 .track-container.lossless {
505 border-color: color-mix(in srgb, var(--accent) 12%, var(--track-border, var(--border-subtle)));
506 box-shadow:
507 0 1px 2px rgba(0, 0, 0, 0.04),
508 inset 0 0 0 1px color-mix(in srgb, var(--accent) 6%, transparent);
509 }
510
511 .track-container.lossless:hover {
512 border-color: color-mix(in srgb, var(--accent) 18%, var(--track-border-hover, var(--border-default)));
513 box-shadow:
514 0 1px 3px rgba(0, 0, 0, 0.06),
515 0 0 8px color-mix(in srgb, var(--accent) 8%, transparent),
516 inset 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent);
517 }
518
519 /* elevate entire track container when likers tooltip is open
520 z-index: 60 is above header (50) and sibling tracks */
521 .track-container.likers-tooltip-open {
522 position: relative;
523 z-index: 60;
524 }
525
526 .track {
527 background: transparent;
528 border: none;
529 cursor: pointer;
530 text-align: left;
531 padding: 0;
532 flex: 1;
533 min-width: 0;
534 display: flex;
535 align-items: center;
536 gap: 0.75rem;
537 font-family: inherit;
538 }
539
540 .track-image-wrapper {
541 position: relative;
542 flex-shrink: 0;
543 width: 48px;
544 height: 48px;
545 }
546
547 .track-image-wrapper.gated::after {
548 content: '';
549 position: absolute;
550 inset: 0;
551 background: rgba(0, 0, 0, 0.3);
552 border-radius: var(--radius-sm);
553 pointer-events: none;
554 }
555
556 .gated-badge {
557 position: absolute;
558 bottom: -4px;
559 right: -4px;
560 width: 18px;
561 height: 18px;
562 display: flex;
563 align-items: center;
564 justify-content: center;
565 background: var(--accent);
566 border: 2px solid var(--bg-secondary);
567 border-radius: var(--radius-full);
568 color: white;
569 z-index: 1;
570 }
571
572 .track-image,
573 .track-image-placeholder {
574 flex-shrink: 0;
575 width: 48px;
576 height: 48px;
577 display: flex;
578 align-items: center;
579 justify-content: center;
580 border-radius: var(--radius-sm);
581 overflow: hidden;
582 background: var(--bg-tertiary);
583 border: 1px solid var(--border-subtle);
584 }
585
586 .track-avatar {
587 flex-shrink: 0;
588 width: 48px;
589 height: 48px;
590 display: block;
591 }
592
593 .track-avatar {
594 text-decoration: none;
595 transition: transform 0.2s;
596 }
597
598 .track-avatar:hover {
599 transform: scale(1.05);
600 }
601
602 .track-image img,
603 .track-avatar img {
604 width: 100%;
605 height: 100%;
606 object-fit: cover;
607 }
608
609 .track-image-placeholder {
610 color: var(--text-muted);
611 }
612
613 .track-avatar img {
614 border-radius: var(--radius-full);
615 border: 2px solid var(--border-default);
616 transition: border-color 0.2s;
617 }
618
619 .track-avatar:hover img {
620 border-color: var(--accent);
621 }
622
623 .track-info {
624 flex: 1;
625 min-width: 0;
626 display: flex;
627 flex-direction: column;
628 gap: 0.35rem;
629 }
630
631 .track-actions {
632 display: flex;
633 gap: 0.5rem;
634 flex-shrink: 0;
635 align-items: center;
636 }
637
638 .desktop-actions {
639 display: flex;
640 gap: 0.5rem;
641 align-items: center;
642 }
643
644 .mobile-actions {
645 display: none;
646 }
647
648 .track-title {
649 font-family: inherit;
650 font-weight: 600;
651 font-size: 1.05rem;
652 color: var(--text-primary);
653 white-space: nowrap;
654 overflow: hidden;
655 text-overflow: ellipsis;
656 text-decoration: none;
657 transition: color 0.15s;
658 width: fit-content;
659 max-width: 100%;
660 }
661
662 .track-title:hover {
663 color: var(--accent);
664 }
665
666 .track-metadata {
667 display: flex;
668 flex-direction: column;
669 align-items: flex-start;
670 gap: 0.15rem;
671 color: var(--text-secondary);
672 font-size: var(--text-base);
673 font-family: inherit;
674 min-width: 0;
675 width: 100%;
676 }
677
678 .artist-line {
679 display: inline-flex;
680 align-items: center;
681 gap: 0.35rem;
682 min-width: 0;
683 flex-wrap: nowrap;
684 }
685
686 .artist-line.only-features {
687 gap: 0.25rem;
688 }
689
690 .metadata-separator {
691 display: none;
692 font-size: var(--text-xs);
693 }
694
695 .artist-link {
696 color: var(--text-secondary);
697 text-decoration: none;
698 transition: color 0.2s;
699 font-weight: 500;
700 font-family: inherit;
701 max-width: 100%;
702 min-width: 0;
703 white-space: nowrap;
704 overflow: hidden;
705 text-overflow: ellipsis;
706 }
707
708 .artist-link:hover {
709 color: var(--accent);
710 }
711
712 .features-inline {
713 display: inline-flex;
714 align-items: center;
715 gap: 0.25rem;
716 color: var(--text-secondary);
717 white-space: nowrap;
718 }
719
720 .features-label {
721 color: var(--accent-hover);
722 font-weight: 500;
723 text-transform: lowercase;
724 }
725
726 .feature-link {
727 color: var(--accent-hover);
728 text-decoration: none;
729 font-weight: 500;
730 transition: color 0.2s;
731 }
732
733 .feature-link:hover {
734 color: var(--accent);
735 text-decoration: underline;
736 }
737
738 .feature-separator {
739 color: var(--accent-hover);
740 }
741
742 .album {
743 color: var(--text-tertiary);
744 display: inline-flex;
745 align-items: center;
746 gap: 0.35rem;
747 min-width: 0;
748 width: 100%;
749 }
750
751 .album-link {
752 color: var(--text-tertiary);
753 text-decoration: none;
754 transition: color 0.2s;
755 display: inline-block;
756 max-width: 100%;
757 min-width: 0;
758 white-space: nowrap;
759 overflow: hidden;
760 text-overflow: ellipsis;
761 }
762
763 .album-link:hover {
764 color: var(--accent);
765 }
766
767 .album-icon {
768 width: 14px;
769 height: 14px;
770 opacity: 0.7;
771 flex-shrink: 0;
772 }
773
774 .tags-line {
775 display: flex;
776 flex-wrap: nowrap;
777 gap: 0.25rem;
778 margin-top: 0.15rem;
779 overflow: hidden;
780 max-width: 100%;
781 }
782
783 .tags-line.expanded {
784 flex-wrap: wrap;
785 }
786
787 .tag-badge {
788 display: inline-block;
789 padding: 0.1rem 0.4rem;
790 background: color-mix(in srgb, var(--accent) 15%, transparent);
791 color: var(--accent-hover);
792 border-radius: var(--radius-sm);
793 font-size: var(--text-xs);
794 font-weight: 500;
795 text-decoration: none;
796 transition: all 0.15s;
797 white-space: nowrap;
798 overflow: hidden;
799 text-overflow: ellipsis;
800 min-width: 2rem;
801 }
802
803 .tag-badge:hover {
804 background: color-mix(in srgb, var(--accent) 25%, transparent);
805 color: var(--accent);
806 }
807
808 .tags-more {
809 display: inline-block;
810 padding: 0.1rem 0.4rem;
811 background: var(--bg-tertiary);
812 color: var(--text-muted);
813 border: none;
814 border-radius: var(--radius-sm);
815 font-size: var(--text-xs);
816 font-weight: 500;
817 font-family: inherit;
818 cursor: pointer;
819 transition: all 0.15s;
820 flex-shrink: 0;
821 white-space: nowrap;
822 }
823
824 .tags-more:hover {
825 background: var(--bg-hover);
826 color: var(--text-secondary);
827 }
828
829 .track-meta {
830 font-size: var(--text-sm);
831 color: var(--text-tertiary);
832 display: flex;
833 align-items: center;
834 gap: 0.5rem;
835 }
836
837 .plays {
838 color: var(--text-tertiary);
839 font-family: inherit;
840 }
841
842 .meta-separator {
843 color: var(--text-muted);
844 font-size: var(--text-xs);
845 }
846
847 .likes {
848 color: var(--text-tertiary);
849 font-family: inherit;
850 position: relative;
851 cursor: help;
852 transition: color 0.2s;
853 }
854
855 .likes:hover {
856 color: var(--accent);
857 }
858
859 .comments-wrapper {
860 position: relative;
861 cursor: help;
862 }
863
864 .comments-wrapper:hover .comments,
865 .comments-wrapper:focus .comments {
866 color: var(--accent);
867 }
868
869 .comments-wrapper:focus {
870 outline: none;
871 }
872
873 .comments {
874 color: var(--text-tertiary);
875 font-family: inherit;
876 display: inline-flex;
877 align-items: center;
878 gap: 0.25rem;
879 text-decoration: none;
880 transition: color 0.2s;
881 }
882
883 .comments:hover {
884 color: var(--accent);
885 }
886
887 .comment-icon {
888 width: 12px;
889 height: 12px;
890 flex-shrink: 0;
891 }
892
893 .comment-count {
894 font-family: inherit;
895 }
896
897 .lossless-indicator {
898 color: var(--accent-muted, var(--text-tertiary));
899 font-weight: 500;
900 cursor: default;
901 transition: color 0.2s;
902 }
903
904 .lossless-indicator:hover {
905 color: var(--accent);
906 }
907
908 .action-button {
909 width: 32px;
910 height: 32px;
911 display: flex;
912 align-items: center;
913 justify-content: center;
914 background: transparent;
915 border: 1px solid var(--border-default);
916 border-radius: var(--radius-sm);
917 color: var(--text-tertiary);
918 cursor: pointer;
919 transition: all 0.2s;
920 text-decoration: none;
921 }
922
923 .action-button:hover {
924 background: var(--bg-tertiary);
925 border-color: var(--accent);
926 color: var(--accent);
927 }
928
929 .action-button:disabled {
930 opacity: 0.6;
931 cursor: not-allowed;
932 }
933
934 .action-button svg {
935 width: 16px;
936 height: 16px;
937 }
938
939 @media (max-width: 768px) {
940 .desktop-actions {
941 display: none;
942 }
943
944 .mobile-actions {
945 display: flex;
946 }
947
948 .track-container {
949 padding: 0.65rem 0.75rem;
950 gap: 0.5rem;
951 }
952
953 .track-index {
954 display: none;
955 }
956
957 .track {
958 gap: 0.5rem;
959 }
960
961 .track-image-wrapper,
962 .track-image,
963 .track-image-placeholder,
964 .track-avatar {
965 width: 40px;
966 height: 40px;
967 }
968
969 .gated-badge {
970 width: 16px;
971 height: 16px;
972 bottom: -3px;
973 right: -3px;
974 }
975
976 .gated-badge svg {
977 width: 8px;
978 height: 8px;
979 }
980
981 .track-title {
982 font-size: var(--text-base);
983 }
984
985 .track-metadata {
986 font-size: var(--text-sm);
987 gap: 0.35rem;
988 }
989
990 .track-meta {
991 font-size: var(--text-xs);
992 }
993
994 .track-actions {
995 gap: 0.35rem;
996 }
997
998 .action-button {
999 width: 32px;
1000 height: 32px;
1001 }
1002
1003 .action-button svg {
1004 width: 14px;
1005 height: 14px;
1006 }
1007 }
1008
1009 @media (max-width: 480px) {
1010 .track-container {
1011 padding: 0.5rem 0.65rem;
1012 }
1013
1014 .track-image-wrapper,
1015 .track-image,
1016 .track-image-placeholder,
1017 .track-avatar {
1018 width: 36px;
1019 height: 36px;
1020 }
1021
1022 .gated-badge {
1023 width: 14px;
1024 height: 14px;
1025 bottom: -2px;
1026 right: -2px;
1027 }
1028
1029 .gated-badge svg {
1030 width: 7px;
1031 height: 7px;
1032 }
1033
1034 .track-title {
1035 font-size: var(--text-sm);
1036 }
1037
1038 .track-metadata {
1039 font-size: var(--text-xs);
1040 }
1041
1042 .metadata-separator {
1043 font-size: 0.6rem;
1044 }
1045
1046 .track-meta {
1047 font-size: 0.65rem;
1048 gap: 0.35rem;
1049 }
1050
1051 .meta-separator {
1052 font-size: 0.6rem;
1053 }
1054
1055 .action-button {
1056 width: 30px;
1057 height: 30px;
1058 }
1059
1060 .action-button svg {
1061 width: 13px;
1062 height: 13px;
1063 }
1064 }
1065</style>