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"; +}