import React, { useState, useEffect, useMemo } from "react"; import { usePaginatedRecords } from "../hooks/usePaginatedRecords"; import { useDidResolution } from "../hooks/useDidResolution"; import type { TealFeedPlayRecord } from "../types/teal"; /** * Options for rendering a paginated list of song history from teal.fm. */ export interface SongHistoryListProps { /** * DID whose song history should be fetched. */ did: string; /** * Maximum number of records to list per page. Defaults to `6`. */ limit?: number; /** * Enables pagination controls when `true`. Defaults to `true`. */ enablePagination?: boolean; } interface SonglinkResponse { linksByPlatform: { [platform: string]: { url: string; entityUniqueId: string; }; }; entitiesByUniqueId: { [id: string]: { thumbnailUrl?: string; title?: string; artistName?: string; }; }; entityUniqueId?: string; } /** * Fetches a user's song history from teal.fm and renders them with album art focus. * * @param did - DID whose song history should be displayed. * @param limit - Maximum number of songs per page. Default `6`. * @param enablePagination - Whether pagination controls should render. Default `true`. * @returns A card-like list element with loading, empty, and error handling. */ export const SongHistoryList: React.FC = React.memo(({ did, limit = 6, enablePagination = true, }) => { const { handle: resolvedHandle } = useDidResolution(did); const actorLabel = resolvedHandle ?? formatDid(did); const { records, loading, error, hasNext, hasPrev, loadNext, loadPrev, pageIndex, pagesCount, } = usePaginatedRecords({ did, collection: "fm.teal.alpha.feed.play", limit, }); const pageLabel = useMemo(() => { const knownTotal = Math.max(pageIndex + 1, pagesCount); if (!enablePagination) return undefined; if (hasNext && knownTotal === pageIndex + 1) return `${pageIndex + 1}/…`; return `${pageIndex + 1}/${knownTotal}`; }, [enablePagination, hasNext, pageIndex, pagesCount]); if (error) return (
Failed to load song history.
); return (
Listening History @{actorLabel}
{pageLabel && ( {pageLabel} )}
{loading && records.length === 0 && (
Loading songs…
)} {records.map((record, idx) => ( ))} {!loading && records.length === 0 && (
No songs found.
)}
{enablePagination && (
{pageIndex + 1} {(hasNext || pagesCount > pageIndex + 1) && ( {pageIndex + 2} )}
)} {loading && records.length > 0 && (
Updating…
)}
); }); interface SongRowProps { record: TealFeedPlayRecord; hasDivider: boolean; } const SongRow: React.FC = ({ record, hasDivider }) => { const [albumArt, setAlbumArt] = useState(undefined); const [artLoading, setArtLoading] = useState(true); const artistNames = record.artists.map((a) => a.artistName).join(", "); const relative = record.playedTime ? formatRelativeTime(record.playedTime) : undefined; const absolute = record.playedTime ? new Date(record.playedTime).toLocaleString() : undefined; useEffect(() => { let cancelled = false; setArtLoading(true); setAlbumArt(undefined); const fetchAlbumArt = async () => { try { // Try ISRC first if (record.isrc) { const response = await fetch( `https://api.song.link/v1-alpha.1/links?platform=isrc&type=song&id=${encodeURIComponent(record.isrc)}&songIfSingle=true` ); if (cancelled) return; if (response.ok) { const data: SonglinkResponse = await response.json(); const entityId = data.entityUniqueId; const entity = entityId ? data.entitiesByUniqueId?.[entityId] : undefined; if (entity?.thumbnailUrl) { setAlbumArt(entity.thumbnailUrl); setArtLoading(false); return; } } } // Fallback to iTunes search const iTunesSearchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent( `${record.trackName} ${artistNames}` )}&media=music&entity=song&limit=1`; const iTunesResponse = await fetch(iTunesSearchUrl); if (cancelled) return; if (iTunesResponse.ok) { const iTunesData = await iTunesResponse.json(); if (iTunesData.results && iTunesData.results.length > 0) { const match = iTunesData.results[0]; const artworkUrl = match.artworkUrl100?.replace('100x100', '600x600') || match.artworkUrl100; if (artworkUrl) { setAlbumArt(artworkUrl); } } } setArtLoading(false); } catch (err) { console.error(`Failed to fetch album art for "${record.trackName}":`, err); setArtLoading(false); } }; fetchAlbumArt(); return () => { cancelled = true; }; }, [record.trackName, artistNames, record.isrc]); return (
{/* Album Art - Large and prominent */}
{artLoading ? (
) : albumArt ? ( {`${record.releaseName { e.currentTarget.style.display = "none"; const parent = e.currentTarget.parentElement; if (parent) { const placeholder = document.createElement("div"); Object.assign(placeholder.style, listStyles.albumArtPlaceholder); placeholder.innerHTML = ` `; parent.appendChild(placeholder); } }} /> ) : (
)}
{/* Song Info */}
{record.trackName}
{artistNames}
{record.releaseName && (
{record.releaseName}
)} {relative && (
{relative}
)}
{/* External Link */} {record.originUrl && ( )}
); }; function formatDid(did: string) { return did.replace(/^did:(plc:)?/, ""); } function formatRelativeTime(iso: string): string { const date = new Date(iso); const diffSeconds = (date.getTime() - Date.now()) / 1000; const absSeconds = Math.abs(diffSeconds); const thresholds: Array<{ limit: number; unit: Intl.RelativeTimeFormatUnit; divisor: number; }> = [ { limit: 60, unit: "second", divisor: 1 }, { limit: 3600, unit: "minute", divisor: 60 }, { limit: 86400, unit: "hour", divisor: 3600 }, { limit: 604800, unit: "day", divisor: 86400 }, { limit: 2629800, unit: "week", divisor: 604800 }, { limit: 31557600, unit: "month", divisor: 2629800 }, { limit: Infinity, unit: "year", divisor: 31557600 }, ]; const threshold = thresholds.find((t) => absSeconds < t.limit) ?? thresholds[thresholds.length - 1]; const value = diffSeconds / threshold.divisor; const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); return rtf.format(Math.round(value), threshold.unit); } const listStyles = { card: { borderRadius: 16, borderWidth: "1px", borderStyle: "solid", borderColor: "transparent", boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)", overflow: "hidden", display: "flex", flexDirection: "column", } satisfies React.CSSProperties, header: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "14px 18px", fontSize: 14, fontWeight: 500, borderBottom: "1px solid var(--atproto-color-border)", } satisfies React.CSSProperties, headerInfo: { display: "flex", alignItems: "center", gap: 12, } satisfies React.CSSProperties, headerIcon: { width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "50%", color: "var(--atproto-color-text)", } satisfies React.CSSProperties, headerText: { display: "flex", flexDirection: "column", gap: 2, } satisfies React.CSSProperties, title: { fontSize: 15, fontWeight: 600, } satisfies React.CSSProperties, subtitle: { fontSize: 12, fontWeight: 500, } satisfies React.CSSProperties, pageMeta: { fontSize: 12, } satisfies React.CSSProperties, items: { display: "flex", flexDirection: "column", } satisfies React.CSSProperties, empty: { padding: "24px 18px", fontSize: 13, textAlign: "center", } satisfies React.CSSProperties, row: { padding: "18px", display: "flex", gap: 16, alignItems: "center", transition: "background-color 120ms ease", position: "relative", } satisfies React.CSSProperties, albumArtContainer: { width: 96, height: 96, flexShrink: 0, borderRadius: 8, overflow: "hidden", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", } satisfies React.CSSProperties, albumArt: { width: "100%", height: "100%", objectFit: "cover", display: "block", } satisfies React.CSSProperties, albumArtPlaceholder: { width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", color: "rgba(255, 255, 255, 0.6)", } satisfies React.CSSProperties, loadingSpinner: { width: 28, height: 28, border: "3px solid rgba(255, 255, 255, 0.3)", borderTop: "3px solid rgba(255, 255, 255, 0.9)", borderRadius: "50%", animation: "spin 1s linear infinite", } satisfies React.CSSProperties, songInfo: { flex: 1, display: "flex", flexDirection: "column", gap: 4, minWidth: 0, } satisfies React.CSSProperties, trackName: { fontSize: 16, fontWeight: 600, lineHeight: 1.3, color: "var(--atproto-color-text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", } satisfies React.CSSProperties, artistName: { fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", } satisfies React.CSSProperties, releaseName: { fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", } satisfies React.CSSProperties, playedTime: { fontSize: 12, fontWeight: 500, marginTop: 2, } satisfies React.CSSProperties, externalLink: { flexShrink: 0, width: 36, height: 36, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "50%", background: "var(--atproto-color-bg-elevated)", border: "1px solid var(--atproto-color-border)", color: "var(--atproto-color-text-secondary)", cursor: "pointer", transition: "all 0.2s ease", textDecoration: "none", } satisfies React.CSSProperties, footer: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderTop: "1px solid transparent", fontSize: 13, } satisfies React.CSSProperties, pageChips: { display: "flex", gap: 6, alignItems: "center", } satisfies React.CSSProperties, pageChip: { padding: "4px 10px", borderRadius: 999, fontSize: 13, borderWidth: "1px", borderStyle: "solid", borderColor: "transparent", } satisfies React.CSSProperties, pageChipActive: { padding: "4px 10px", borderRadius: 999, fontSize: 13, fontWeight: 600, borderWidth: "1px", borderStyle: "solid", borderColor: "transparent", } satisfies React.CSSProperties, pageButton: { border: "none", borderRadius: 999, padding: "6px 12px", fontSize: 13, fontWeight: 500, background: "transparent", display: "flex", alignItems: "center", gap: 4, transition: "background-color 120ms ease", } satisfies React.CSSProperties, loadingBar: { padding: "4px 18px 14px", fontSize: 12, textAlign: "right", color: "#64748b", } satisfies React.CSSProperties, }; // Add keyframes and hover styles if (typeof document !== "undefined") { const styleId = "song-history-styles"; if (!document.getElementById(styleId)) { const styleElement = document.createElement("style"); styleElement.id = styleId; styleElement.textContent = ` @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `; document.head.appendChild(styleElement); } } export default SongHistoryList;