audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1065 lines 26 kB view raw
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>