diff --git a/src/components/nostr/kinds/MusicTrackRenderer.tsx b/src/components/nostr/kinds/MusicTrackRenderer.tsx
new file mode 100644
index 0000000..37da5ae
--- /dev/null
+++ b/src/components/nostr/kinds/MusicTrackRenderer.tsx
@@ -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 (
+
+
+ {/* Title */}
+
+
+ {title || "Untitled Track"}
+
+
+ {/* Artist & Album */}
+
+ {artist && {artist}}
+ {artist && metadata.album && · }
+ {metadata.album && {metadata.album}}
+
+
+ {/* Labels */}
+
+ {metadata.language && }
+ {metadata.aiGenerated && (
+
+ )}
+
+
+
+ {/* Audio player */}
+ {trackUrl && (
+
+
+
+ )}
+
+ );
+}
+
+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 (
+
+ {/* Cover art */}
+ {image && (
+
+
+
+ )}
+
+
+ {/* Title */}
+
+ {title || "Untitled Track"}
+
+
+ {/* Artist */}
+ {artist &&
{artist}
}
+
+ {/* Author */}
+
+
+ {/* Audio player */}
+ {trackUrl &&
}
+
+ {/* Metadata grid */}
+
+ {metadata.album && }
+ {metadata.trackNumber && (
+
+ )}
+ {metadata.released && }
+ {metadata.language && }
+ {metadata.aiGenerated && (
+
+ )}
+ {metadata.license && }
+
+
+ {/* Lyrics / Content */}
+ {hasLyrics && (
+
+
+ Lyrics
+
+
+ {event.content}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/PlaylistRenderer.tsx b/src/components/nostr/kinds/PlaylistRenderer.tsx
new file mode 100644
index 0000000..e815c6d
--- /dev/null
+++ b/src/components/nostr/kinds/PlaylistRenderer.tsx
@@ -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 (
+
+
+
+ {displayText}
+
+
+
+ );
+}
+
+export function PlaylistRenderer({ event }: BaseEventProps) {
+ const title = getPlaylistTitle(event);
+ const tracks = getPlaylistTrackPointers(event);
+ const isPublic = isPlaylistPublic(event);
+ const collaborative = isPlaylistCollaborative(event);
+
+ return (
+
+
+ {/* Title */}
+
+
+ {title || "Untitled Playlist"}
+
+
+ {/* Track count and badges */}
+
+
+ {tracks.length} {tracks.length === 1 ? "track" : "tracks"}
+
+ {isPublic && }
+ {collaborative && }
+
+
+
+ );
+}
+
+export function PlaylistDetailRenderer({ event }: { event: NostrEvent }) {
+ const title = getPlaylistTitle(event);
+ const tracks = getPlaylistTrackPointers(event);
+ const isPublic = isPlaylistPublic(event);
+ const collaborative = isPlaylistCollaborative(event);
+
+ return (
+
+
+ {/* Title */}
+
+
+ {title || "Untitled Playlist"}
+
+
+ {/* Author */}
+
+
+ {/* Badges */}
+
+ {isPublic && }
+ {collaborative && }
+
+
+ {/* Track list */}
+
+
+
+ Tracks ({tracks.length})
+
+ {tracks.length === 0 ? (
+
+ No tracks
+
+ ) : (
+ tracks.map((pointer) => (
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 2732fc4..63547ca 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -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> = {
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)
diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts
index c52284f..1af86f9 100644
--- a/src/constants/kinds.ts
+++ b/src/constants/kinds.ts
@@ -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 = {
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 = {
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",
diff --git a/src/lib/music-helpers.ts b/src/lib/music-helpers.ts
new file mode 100644
index 0000000..668bc02
--- /dev/null
+++ b/src/lib/music-helpers.ts
@@ -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";
+}