mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
feat: add track and playlist rendering
This commit is contained in:
127
src/components/nostr/kinds/MusicTrackRenderer.tsx
Normal file
127
src/components/nostr/kinds/MusicTrackRenderer.tsx
Normal 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> · </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>
|
||||
);
|
||||
}
|
||||
118
src/components/nostr/kinds/PlaylistRenderer.tsx
Normal file
118
src/components/nostr/kinds/PlaylistRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
73
src/lib/music-helpers.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user