Slim song cards with an expand arrow, and lyrics
Feed and profile cards now show one slim line (small artwork, title, artist, Open button) with the progress bar and reactions. The arrow opens the rest: album, your other services, Share, and lyrics. Lyrics come from LRCLIB (open source; LRCLIB_URL can point at a self-hosted copy, or be empty to turn lyrics off). The server looks each song up once and caches it, including misses (retried after a week). Timed lyrics follow along with the song; otherwise the first lines show with "All lyrics". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -227,7 +227,9 @@ input:focus, textarea:focus, select:focus {
|
||||
.form label.checkbox { flex-direction: row; align-items: center; gap: 0.5rem; font-weight: 500; }
|
||||
|
||||
/* Now-playing cards take their colour from the album art. */
|
||||
.np-card { border-color: rgba(255, 255, 255, 0.11); }
|
||||
.np-card { border-color: rgba(255, 255, 255, 0.11); padding: 0.9rem 1rem; }
|
||||
.np-card .card-header { margin-bottom: 0.65rem; }
|
||||
.np-card .reactions { margin-top: 0.55rem; }
|
||||
.np-card.pinned { border-color: rgba(251, 191, 36, 0.45); box-shadow: 0 0 0 1px rgba(251, 191, 36, 0.15), 0 18px 40px -20px rgba(251, 191, 36, 0.35); }
|
||||
.np-backdrop {
|
||||
position: absolute; inset: -40%; z-index: 0; background-size: cover; background-position: center;
|
||||
@@ -286,6 +288,33 @@ input:focus, textarea:focus, select:focus {
|
||||
.track-title { font-family: var(--font-display); font-weight: 700; font-size: 1.15rem; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.track-artist { color: var(--text); opacity: 0.82; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.progress { margin-top: 0.85rem; }
|
||||
.track-row { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.track-row-main {
|
||||
flex: 1; min-width: 0; display: flex; align-items: center; gap: 0.8rem; padding: 0; border: 0; background: none;
|
||||
color: inherit; font: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.track-row-main .track-body { display: flex; flex-direction: column; }
|
||||
.track-row-main .track-title { font-size: 1.02rem; }
|
||||
.artwork.artwork-row { width: 56px; height: 56px; border-radius: 10px; font-size: 1.3rem; }
|
||||
.track-row:hover .artwork { transform: scale(1.04) rotate(-1deg); }
|
||||
.chevron {
|
||||
display: grid; place-items: center; width: 32px; height: 32px; flex-shrink: 0; border-radius: 999px; border: 0; cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.06); color: var(--muted); transition: transform 0.3s var(--spring), color 0.2s, background 0.2s;
|
||||
}
|
||||
.chevron:hover { color: var(--text); background: rgba(255, 255, 255, 0.12); }
|
||||
.chevron.open { transform: rotate(180deg); }
|
||||
.np-card .progress { margin-top: 0.65rem; }
|
||||
.np-details { overflow: hidden; }
|
||||
.np-details > .track-actions { margin-top: 0.75rem; }
|
||||
.lyrics { margin-top: 0.9rem; }
|
||||
.lyrics-lines { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
.lyric { margin: 0; line-height: 1.45; transition: opacity 0.3s, font-size 0.3s; }
|
||||
.lyric-past { opacity: 0.4; }
|
||||
.lyric-next { opacity: 0.65; }
|
||||
.lyric-now { font-family: var(--font-display); font-weight: 700; font-size: 1.08rem; }
|
||||
.lyrics-note { margin: 0.9rem 0 0; font-size: 0.85rem; }
|
||||
.lyrics-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 0.5rem; font-size: 0.82rem; }
|
||||
.lyrics-source { margin-left: auto; font-size: 0.72rem; }
|
||||
.progress-track { height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.14); overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: inherit; background: var(--grad); transition: width 1s linear; }
|
||||
.progress-times { display: flex; justify-content: space-between; margin-top: 0.3rem; font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { currentLyricIndex, playbackPosition, type LyricLine, type LyricsDTO, type NowPlayingDTO, type TrackDTO } from '@musicshare/shared';
|
||||
import { fetcher } from '@/lib/api';
|
||||
|
||||
/** Lines shown around the current one while following along. */
|
||||
const BEFORE = 1;
|
||||
const AFTER = 3;
|
||||
/** Lines shown before "All lyrics" when there's nothing to follow. */
|
||||
const PREVIEW = 8;
|
||||
|
||||
/**
|
||||
* A song's lyrics. With timed lyrics and a live position (`np`), shows a few
|
||||
* lines around the one being sung and follows along; "All lyrics" shows the rest.
|
||||
*/
|
||||
export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) {
|
||||
const { data, error, isLoading } = useSWR<LyricsDTO | null>(`/tracks/${track.id}/lyrics`, fetcher);
|
||||
const [full, setFull] = useState(false);
|
||||
|
||||
if (isLoading) return <p className="muted lyrics-note">Finding lyrics…</p>;
|
||||
if (error) return <p className="muted lyrics-note">Couldn't load lyrics right now.</p>;
|
||||
if (!data) return <p className="muted lyrics-note">No lyrics found for this song.</p>;
|
||||
if (data.instrumental && !data.synced && !data.plain) return <p className="muted lyrics-note">♪ Instrumental</p>;
|
||||
|
||||
const synced = data.synced;
|
||||
const live = Boolean(np?.isPlaying && playbackPosition(np, 0));
|
||||
const following = Boolean(synced && live && !full);
|
||||
const text = synced ? synced.map((l) => l.text).join('\n') : (data.plain ?? '');
|
||||
const all = text.split('\n');
|
||||
const clipped = !following && !full && all.length > PREVIEW;
|
||||
const canToggle = (synced && live) || clipped || full;
|
||||
|
||||
return (
|
||||
<div className="lyrics">
|
||||
<div className="lyrics-lines" aria-live={following ? 'polite' : undefined}>
|
||||
{following && synced && np ? (
|
||||
// Each fresh position from the server restarts the clock.
|
||||
<FollowAlong key={`${np.track.id}|${np.progressMs}|${np.updatedAt}`} lines={synced} np={np} />
|
||||
) : (
|
||||
(clipped ? all.slice(0, PREVIEW) : all).map((t, i) => (
|
||||
<p key={i} className="lyric lyric-plain">
|
||||
{t || ' '}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="lyrics-footer">
|
||||
{canToggle && (
|
||||
<button type="button" className="link-button" onClick={() => setFull((v) => !v)}>
|
||||
{!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'}
|
||||
</button>
|
||||
)}
|
||||
<a className="muted lyrics-source" href={data.source.url} target="_blank" rel="noreferrer">
|
||||
Lyrics from {data.source.name}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The line being sung, one before it and a few after, moving with the song. */
|
||||
function FollowAlong({ lines, np }: { lines: LyricLine[]; np: NowPlayingDTO }) {
|
||||
const [receivedAt] = useState(() => Date.now());
|
||||
const [now, setNow] = useState(receivedAt);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 500);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const position = playbackPosition(np, now - receivedAt)?.positionMs ?? 0;
|
||||
const current = currentLyricIndex(lines, position);
|
||||
const from = Math.max(0, current - BEFORE);
|
||||
return lines.slice(from, Math.max(current, 0) + AFTER + 1).map((l, i) => {
|
||||
const index = from + i;
|
||||
const state = index < current ? 'past' : index === current ? 'now' : 'next';
|
||||
return (
|
||||
<p key={`${l.timeMs}-${index}`} className={`lyric lyric-${state}`}>
|
||||
{l.text || '♪'}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
|
||||
import { Avatar } from './Avatar';
|
||||
import { Equalizer } from './Equalizer';
|
||||
import { Lyrics } from './Lyrics';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { Reactions } from './Reactions';
|
||||
import { TrackCard } from './TrackCard';
|
||||
import { ServiceLinks, TrackRow } from './TrackCard';
|
||||
|
||||
export function NowPlayingCard({
|
||||
np,
|
||||
@@ -23,6 +25,7 @@ export function NowPlayingCard({
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
const sourceLabel = np.sourceApp ?? SERVICE_LABELS[np.source];
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<article className={`card np-card ${pinned ? 'pinned' : ''} ${np.isPlaying ? 'playing' : ''}`}>
|
||||
{/* The card glows with its album art; crossfades when the song changes. */}
|
||||
@@ -42,7 +45,7 @@ export function NowPlayingCard({
|
||||
<header className="card-header">
|
||||
{showUser && (
|
||||
<Link href={`/u/${np.user.username}`} className="user-chip">
|
||||
<Avatar user={np.user} size={30} live={np.isPlaying} />
|
||||
<Avatar user={np.user} size={26} live={np.isPlaying} />
|
||||
<span className="user-name">{np.user.displayName ?? np.user.username}</span>
|
||||
</Link>
|
||||
)}
|
||||
@@ -84,9 +87,25 @@ export function NowPlayingCard({
|
||||
exit={{ opacity: 0, y: -12, filter: 'blur(6px)' }}
|
||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<TrackCard track={np.track} preferred={preferred} />
|
||||
<ProgressBar np={np} />
|
||||
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={() => setExpanded((v) => !v)} />
|
||||
<ProgressBar np={np} compact={!expanded} />
|
||||
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
className="np-details"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="track-actions">
|
||||
<ServiceLinks track={np.track} preferred={preferred} />
|
||||
</div>
|
||||
<Lyrics track={np.track} np={np} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</article>
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useEffect, useState } from 'react';
|
||||
import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshare/shared';
|
||||
|
||||
/** Where they are in the song. Renders nothing when the source doesn't report a position. */
|
||||
export function ProgressBar({ np }: { np: NowPlayingDTO }) {
|
||||
export function ProgressBar({ np, compact }: { np: NowPlayingDTO; /** Just the bar, no times. */ compact?: boolean }) {
|
||||
if (!np.isPlaying || playbackPosition(np, 0) === null) return null;
|
||||
// Each fresh position from the server restarts the clock, realigning the bar.
|
||||
return <Bar key={`${np.track.id}|${np.progressMs}|${np.updatedAt}`} np={np} />;
|
||||
return <Bar key={`${np.track.id}|${np.progressMs}|${np.updatedAt}`} np={np} compact={compact} />;
|
||||
}
|
||||
|
||||
function Bar({ np }: { np: NowPlayingDTO }) {
|
||||
function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) {
|
||||
const [receivedAt] = useState(() => Date.now());
|
||||
const [now, setNow] = useState(receivedAt);
|
||||
useEffect(() => {
|
||||
@@ -25,10 +25,12 @@ function Bar({ np }: { np: NowPlayingDTO }) {
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${(pos.positionMs / pos.durationMs) * 100}%` }} />
|
||||
</div>
|
||||
<div className="progress-times" aria-hidden>
|
||||
<span>{formatDuration(pos.positionMs)}</span>
|
||||
<span>{formatDuration(pos.durationMs)}</span>
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="progress-times" aria-hidden>
|
||||
<span>{formatDuration(pos.positionMs)}</span>
|
||||
<span>{formatDuration(pos.durationMs)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,85 @@ import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type Tr
|
||||
import { useMe } from '@/lib/api';
|
||||
import { ShareButton } from './ShareButton';
|
||||
|
||||
function Artwork({ track, small }: { track: TrackDTO; small?: boolean }) {
|
||||
const cls = small ? 'artwork artwork-row' : 'artwork';
|
||||
return track.artworkUrl ? <img className={cls} src={track.artworkUrl} alt="" /> : <div className={`${cls} artwork-empty`}>♪</div>;
|
||||
}
|
||||
|
||||
/** The big "Open in Spotify" button for your main service ("Search in" when we have no direct link). */
|
||||
function PrimaryLink({ track, preferred, short }: { track: TrackDTO; preferred: StreamingService; short?: boolean }) {
|
||||
const { url, exact } = openUrl(track, preferred);
|
||||
const full = `${exact ? 'Open' : 'Search'} in ${SERVICE_LABELS[preferred]}`;
|
||||
return (
|
||||
<a className={`btn btn-primary ${short ? 'btn-small' : ''}`} href={url} target="_blank" rel="noreferrer" title={full}>
|
||||
{short ? (exact ? 'Open ▶' : 'Search') : full}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Quieter links for your other services, and Share. */
|
||||
export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||
const { me } = useMe();
|
||||
const others = viewerServices(me).filter((s) => s !== preferred);
|
||||
return (
|
||||
<>
|
||||
{others.map((s) => {
|
||||
const { url, exact } = openUrl(track, s);
|
||||
return (
|
||||
<a
|
||||
key={s}
|
||||
className={`service-link ${exact ? '' : 'inexact'}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={exact ? `Open in ${SERVICE_LABELS[s]}` : `Search ${SERVICE_LABELS[s]}`}
|
||||
>
|
||||
{SERVICE_LABELS[s]}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<ShareButton track={track} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One slim line: small artwork, title and artist, the open button, and an
|
||||
* arrow that shows the rest (`expanded` / `onToggle`).
|
||||
*/
|
||||
export function TrackRow({
|
||||
track,
|
||||
preferred,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
track: TrackDTO;
|
||||
preferred: StreamingService | null | undefined;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="track-row">
|
||||
<button type="button" className="track-row-main" onClick={onToggle} aria-expanded={expanded}>
|
||||
<Artwork track={track} small />
|
||||
<span className="track-body">
|
||||
<span className="track-title">{track.title}</span>
|
||||
<span className="track-artist">
|
||||
{track.artist}
|
||||
{expanded && track.album && <span className="muted"> · {track.album}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{preferred && <PrimaryLink track={track} preferred={preferred} short />}
|
||||
<button type="button" className={`chevron ${expanded ? 'open' : ''}`} onClick={onToggle} aria-expanded={expanded} title={expanded ? 'Show less' : 'Show more'}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artwork + title, with a primary button for the viewer's preferred service
|
||||
* and quieter links for the rest. Links are https universal links, so on
|
||||
@@ -21,17 +100,9 @@ export function TrackCard({
|
||||
/** When the page already shows the artwork big. */
|
||||
hideArtwork?: boolean;
|
||||
}) {
|
||||
const { me } = useMe();
|
||||
const others = viewerServices(me).filter((s) => s !== preferred);
|
||||
const primary = preferred ? openUrl(track, preferred) : null;
|
||||
|
||||
return (
|
||||
<div className={`track ${compact ? 'track-compact' : ''}`}>
|
||||
{hideArtwork ? null : track.artworkUrl ? (
|
||||
<img className="artwork" src={track.artworkUrl} alt="" />
|
||||
) : (
|
||||
<div className="artwork artwork-empty">♪</div>
|
||||
)}
|
||||
{!hideArtwork && <Artwork track={track} />}
|
||||
<div className="track-body">
|
||||
<div className="track-title">{track.title}</div>
|
||||
<div className="track-artist">
|
||||
@@ -39,27 +110,8 @@ export function TrackCard({
|
||||
{track.album && <span className="muted"> · {track.album}</span>}
|
||||
</div>
|
||||
<div className="track-actions">
|
||||
{preferred && primary && (
|
||||
<a className="btn btn-primary" href={primary.url} target="_blank" rel="noreferrer">
|
||||
{primary.exact ? 'Open' : 'Search'} in {SERVICE_LABELS[preferred]}
|
||||
</a>
|
||||
)}
|
||||
{others.map((s) => {
|
||||
const { url, exact } = openUrl(track, s);
|
||||
return (
|
||||
<a
|
||||
key={s}
|
||||
className={`service-link ${exact ? '' : 'inexact'}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={exact ? `Open in ${SERVICE_LABELS[s]}` : `Search ${SERVICE_LABELS[s]}`}
|
||||
>
|
||||
{SERVICE_LABELS[s]}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<ShareButton track={track} />
|
||||
{preferred && <PrimaryLink track={track} preferred={preferred} />}
|
||||
<ServiceLinks track={track} preferred={preferred} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user