feat: add track and playlist rendering

This commit is contained in:
Alejandro Gómez
2026-02-26 17:19:38 +01:00
parent 41a1009166
commit 66a422d3f8
5 changed files with 343 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
import type { NostrEvent } from "@/types/nostr";
import {
getTrackTitle,
getTrackUrl,
getTrackArtist,
getTrackImage,
getTrackMetadata,
} from "@/lib/music-helpers";
import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
import type { BaseEventProps } from "./BaseEventRenderer";
import { MediaEmbed } from "../MediaEmbed";
import { Label } from "@/components/ui/label";
import { UserName } from "../UserName";
import { Music, Bot } from "lucide-react";
export function MusicTrackRenderer({ event }: BaseEventProps) {
const title = getTrackTitle(event);
const artist = getTrackArtist(event);
const trackUrl = getTrackUrl(event);
const metadata = getTrackMetadata(event);
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-1">
{/* Title */}
<ClickableEventTitle
event={event}
className="text-base font-semibold flex items-center gap-1.5"
>
<Music className="size-4 text-muted-foreground flex-shrink-0" />
{title || "Untitled Track"}
</ClickableEventTitle>
{/* Artist & Album */}
<div className="text-sm text-muted-foreground">
{artist && <span>{artist}</span>}
{artist && metadata.album && <span> &middot; </span>}
{metadata.album && <span>{metadata.album}</span>}
</div>
{/* Labels */}
<div className="flex items-center gap-1.5 flex-wrap">
{metadata.language && <Label size="sm">{metadata.language}</Label>}
{metadata.aiGenerated && (
<Label size="sm">
<Bot className="size-3 inline mr-0.5" />
AI
</Label>
)}
</div>
</div>
{/* Audio player */}
{trackUrl && (
<div className="mt-2">
<MediaEmbed url={trackUrl} type="audio" showControls />
</div>
)}
</BaseEventContainer>
);
}
export function MusicTrackDetailRenderer({ event }: { event: NostrEvent }) {
const title = getTrackTitle(event);
const artist = getTrackArtist(event);
const trackUrl = getTrackUrl(event);
const image = getTrackImage(event);
const metadata = getTrackMetadata(event);
const hasLyrics = event.content && event.content.trim().length > 0;
return (
<div className="flex flex-col h-full bg-background overflow-y-auto">
{/* Cover art */}
{image && (
<div className="flex-shrink-0">
<MediaEmbed url={image} type="image" preset="preview" enableZoom />
</div>
)}
<div className="flex-1 p-3 space-y-4">
{/* Title */}
<h1 className="text-2xl font-bold text-balance">
{title || "Untitled Track"}
</h1>
{/* Artist */}
{artist && <p className="text-base text-muted-foreground">{artist}</p>}
{/* Author */}
<UserName pubkey={event.pubkey} className="text-sm text-accent" />
{/* Audio player */}
{trackUrl && <MediaEmbed url={trackUrl} type="audio" showControls />}
{/* Metadata grid */}
<div className="flex items-center gap-2 flex-wrap text-sm">
{metadata.album && <Label size="md">{metadata.album}</Label>}
{metadata.trackNumber && (
<Label size="sm">Track {metadata.trackNumber}</Label>
)}
{metadata.released && <Label size="sm">{metadata.released}</Label>}
{metadata.language && <Label size="sm">{metadata.language}</Label>}
{metadata.aiGenerated && (
<Label size="sm">
<Bot className="size-3 inline mr-0.5" />
AI Generated
</Label>
)}
{metadata.license && <Label size="sm">{metadata.license}</Label>}
</div>
{/* Lyrics / Content */}
{hasLyrics && (
<div className="space-y-2">
<h2 className="text-sm font-semibold text-muted-foreground">
Lyrics
</h2>
<pre className="text-sm whitespace-pre-wrap break-words">
{event.content}
</pre>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import type { NostrEvent } from "@/types/nostr";
import {
getPlaylistTitle,
getPlaylistTrackPointers,
isPlaylistPublic,
isPlaylistCollaborative,
} from "@/lib/music-helpers";
import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
import type { BaseEventProps } from "./BaseEventRenderer";
import { Label } from "@/components/ui/label";
import { UserName } from "../UserName";
import { useAddWindow } from "@/core/state";
import { nip19 } from "nostr-tools";
import { ListMusic, Music, ExternalLink } from "lucide-react";
import type { AddressPointer } from "nostr-tools/nip19";
function TrackItem({ pointer }: { pointer: AddressPointer }) {
const addWindow = useAddWindow();
const naddr = nip19.naddrEncode(pointer);
const displayText = pointer.identifier || naddr.slice(0, 24) + "...";
const handleClick = () => {
addWindow("open", { pointer });
};
return (
<div
className="flex items-center gap-2 text-sm cursor-crosshair hover:bg-muted/50 rounded px-2 py-1.5 transition-colors"
onClick={handleClick}
>
<Music className="size-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-accent hover:underline hover:decoration-dotted truncate">
{displayText}
</span>
<ExternalLink className="size-3 text-muted-foreground flex-shrink-0 ml-auto" />
</div>
);
}
export function PlaylistRenderer({ event }: BaseEventProps) {
const title = getPlaylistTitle(event);
const tracks = getPlaylistTrackPointers(event);
const isPublic = isPlaylistPublic(event);
const collaborative = isPlaylistCollaborative(event);
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Title */}
<ClickableEventTitle
event={event}
className="text-base font-semibold flex items-center gap-1.5"
>
<ListMusic className="size-4 text-muted-foreground flex-shrink-0" />
{title || "Untitled Playlist"}
</ClickableEventTitle>
{/* Track count and badges */}
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<span>
{tracks.length} {tracks.length === 1 ? "track" : "tracks"}
</span>
{isPublic && <Label size="sm">Public</Label>}
{collaborative && <Label size="sm">Collaborative</Label>}
</div>
</div>
</BaseEventContainer>
);
}
export function PlaylistDetailRenderer({ event }: { event: NostrEvent }) {
const title = getPlaylistTitle(event);
const tracks = getPlaylistTrackPointers(event);
const isPublic = isPlaylistPublic(event);
const collaborative = isPlaylistCollaborative(event);
return (
<div className="flex flex-col h-full bg-background overflow-y-auto">
<div className="flex-1 p-3 space-y-4">
{/* Title */}
<h1 className="text-2xl font-bold flex items-center gap-2 text-balance">
<ListMusic className="size-6 text-muted-foreground flex-shrink-0" />
{title || "Untitled Playlist"}
</h1>
{/* Author */}
<UserName pubkey={event.pubkey} className="text-sm text-accent" />
{/* Badges */}
<div className="flex items-center gap-2 flex-wrap">
{isPublic && <Label size="sm">Public</Label>}
{collaborative && <Label size="sm">Collaborative</Label>}
</div>
{/* Track list */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 mb-1">
<Music className="size-5 text-muted-foreground" />
<span className="font-semibold">Tracks ({tracks.length})</span>
</div>
{tracks.length === 0 ? (
<div className="text-sm text-muted-foreground italic">
No tracks
</div>
) : (
tracks.map((pointer) => (
<TrackItem
key={`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`}
pointer={pointer}
/>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -165,6 +165,11 @@ import { TrustedAssertionRenderer } from "./TrustedAssertionRenderer";
import { TrustedAssertionDetailRenderer } from "./TrustedAssertionDetailRenderer";
import { TrustedProviderListRenderer } from "./TrustedProviderListRenderer";
import { TrustedProviderListDetailRenderer } from "./TrustedProviderListDetailRenderer";
import {
MusicTrackRenderer,
MusicTrackDetailRenderer,
} from "./MusicTrackRenderer";
import { PlaylistRenderer, PlaylistDetailRenderer } from "./PlaylistRenderer";
/**
* Registry of kind-specific renderers
@@ -247,8 +252,10 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
30383: TrustedAssertionRenderer, // Event Assertion (NIP-85)
30384: TrustedAssertionRenderer, // Address Assertion (NIP-85)
30385: TrustedAssertionRenderer, // External Assertion (NIP-85)
34139: PlaylistRenderer, // Music Playlist
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
34236: Kind22Renderer, // Vertical Video (NIP-71 legacy)
36787: MusicTrackRenderer, // Music Track
30617: RepositoryRenderer, // Repository (NIP-34)
30618: RepositoryStateRenderer, // Repository State (NIP-34)
30777: SpellbookRenderer, // Spellbook (Grimoire)
@@ -367,6 +374,8 @@ const detailRenderers: Record<
31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89)
31990: ApplicationHandlerDetailRenderer, // Application Handler Detail (NIP-89)
32267: ZapstoreAppDetailRenderer, // Zapstore App Detail
34139: PlaylistDetailRenderer, // Music Playlist Detail
36787: MusicTrackDetailRenderer, // Music Track Detail
38383: P2pOrderDetailRenderer, // P2P Order Detail
39089: StarterPackDetailRenderer, // Starter Pack Detail (NIP-51)
39092: MediaStarterPackDetailRenderer, // Media Starter Pack Detail (NIP-51)

View File

@@ -37,11 +37,13 @@ import {
List,
ListChecks,
ListFilter,
ListMusic,
Lock,
Mail,
MessageCircle,
MessageSquare,
Mic,
Music,
Package,
PackageOpen,
Pin,
@@ -1419,6 +1421,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
nip: "71",
icon: Video,
},
34139: {
kind: 34139,
name: "Playlist",
description: "Music playlist",
nip: "",
icon: ListMusic,
},
34550: {
kind: 34550,
name: "Community",
@@ -1426,6 +1435,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
nip: "72",
icon: Users,
},
36787: {
kind: 36787,
name: "Music Track",
description: "Music track with audio and metadata",
nip: "",
icon: Music,
},
// 37516: {
// kind: 37516,
// name: "Geocache",

73
src/lib/music-helpers.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { NostrEvent } from "@/types/nostr";
import { getOrComputeCachedValue, getTagValue } from "applesauce-core/helpers";
import { getAddressPointerFromATag } from "applesauce-core/helpers/pointers";
import type { AddressPointer } from "nostr-tools/nip19";
// Simple tag-based helpers (no caching needed for getTagValue)
export function getTrackTitle(event: NostrEvent): string | undefined {
return getTagValue(event, "title");
}
export function getTrackUrl(event: NostrEvent): string | undefined {
return getTagValue(event, "url");
}
export function getTrackArtist(event: NostrEvent): string | undefined {
return getTagValue(event, "artist");
}
export function getTrackImage(event: NostrEvent): string | undefined {
return getTagValue(event, "image");
}
// Cached aggregate metadata
const TrackMetadataSymbol = Symbol("trackMetadata");
export interface TrackMetadata {
album?: string;
trackNumber?: string;
released?: string;
language?: string;
aiGenerated: boolean;
license?: string;
}
export function getTrackMetadata(event: NostrEvent): TrackMetadata {
return getOrComputeCachedValue(event, TrackMetadataSymbol, () => {
return {
album: getTagValue(event, "album"),
trackNumber: getTagValue(event, "track_number"),
released: getTagValue(event, "released"),
language: getTagValue(event, "language"),
aiGenerated: getTagValue(event, "ai_generated") === "true",
license: getTagValue(event, "license"),
};
});
}
// Playlist helpers
export function getPlaylistTitle(event: NostrEvent): string | undefined {
return getTagValue(event, "title");
}
const PlaylistTrackPointersSymbol = Symbol("playlistTrackPointers");
export function getPlaylistTrackPointers(event: NostrEvent): AddressPointer[] {
return getOrComputeCachedValue(event, PlaylistTrackPointersSymbol, () => {
return event.tags
.filter((t) => t[0] === "a" && t[1])
.map((t) => getAddressPointerFromATag(t))
.filter((p): p is AddressPointer => p !== null && p !== undefined);
});
}
export function isPlaylistPublic(event: NostrEvent): boolean {
return getTagValue(event, "public") === "true";
}
export function isPlaylistCollaborative(event: NostrEvent): boolean {
return getTagValue(event, "collaborative") === "true";
}