Browse Source

kind 36787 rendering

imwald
Silberengel 2 weeks ago
parent
commit
1fe68c2dac
  1. 36
      src/components/AudioPlayer/index.tsx
  2. 21
      src/components/ContentPreview/MusicTrackNotePreview.tsx
  3. 15
      src/components/ContentPreview/index.tsx
  4. 11
      src/components/MediaGridItem/index.tsx
  5. 1
      src/components/Note/Highlight/index.tsx
  6. 99
      src/components/Note/MusicTrackNote.tsx
  7. 5
      src/components/Note/index.tsx
  8. 2
      src/components/WebPreview/index.tsx
  9. 10
      src/constants.ts
  10. 1
      src/lib/feed-kind-filter.test.ts
  11. 3
      src/lib/feed-kind-filter.ts
  12. 2
      src/lib/kind-description.ts
  13. 243
      src/lib/music-track.test.ts
  14. 119
      src/lib/music-track.ts
  15. 8
      src/lib/parent-reply-blurb.ts
  16. 4
      src/pages/secondary/NotePage/index.tsx
  17. 10
      src/services/local-storage.service.ts
  18. 1
      src/services/mention-event-search.service.ts
  19. 1
      src/services/nip89.service.ts

36
src/components/AudioPlayer/index.tsx

@ -13,12 +13,21 @@ interface AudioPlayerProps { @@ -13,12 +13,21 @@ interface AudioPlayerProps {
className?: string
/** Optional cover / still (e.g. NIP-53 `image` on live events). */
poster?: string
/** Tried when `src` fails to load (e.g. Primal r2a mirror for blossom URLs). */
fallbackSrc?: string
/** Fires when enough data is buffered to play (e.g. to swap out a blurhash placeholder). */
onReady?: () => void
}
export default function AudioPlayer({ src, className, poster, onReady }: AudioPlayerProps) {
export default function AudioPlayer({
src,
className,
poster,
fallbackSrc,
onReady
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const [activeSrc, setActiveSrc] = useState(src)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
@ -26,6 +35,14 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl @@ -26,6 +35,14 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl
const seekTimeoutRef = useRef<NodeJS.Timeout>()
const isSeeking = useRef(false)
useEffect(() => {
setActiveSrc(src)
setError(false)
setIsPlaying(false)
setCurrentTime(0)
setDuration(0)
}, [src, fallbackSrc])
useEffect(() => {
if (!onReady) return
const audio = audioRef.current
@ -37,7 +54,7 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl @@ -37,7 +54,7 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl
}
audio.addEventListener('canplay', notify, { once: true })
return () => audio.removeEventListener('canplay', notify)
}, [src, onReady])
}, [activeSrc, onReady])
useEffect(() => {
if (error) {
@ -122,6 +139,19 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl @@ -122,6 +139,19 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl
}, 300)
}
const handleLoadError = () => {
const fb = fallbackSrc?.trim()
if (fb && activeSrc !== fb) {
setActiveSrc(fb)
setError(false)
setIsPlaying(false)
setCurrentTime(0)
setDuration(0)
return
}
setError(true)
}
if (error) {
return <ExternalLink url={src} />
}
@ -157,7 +187,7 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl @@ -157,7 +187,7 @@ export default function AudioPlayer({ src, className, poster, onReady }: AudioPl
!cover && 'max-w-md'
)}
>
<audio ref={audioRef} src={src} preload="metadata" onError={() => setError(true)} />
<audio ref={audioRef} src={activeSrc} preload="metadata" onError={handleLoadError} />
<Button size="icon" className="shrink-0 rounded-full" onClick={togglePlay}>
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}

21
src/components/ContentPreview/MusicTrackNotePreview.tsx

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
import { musicTrackPreviewText } from '@/components/Note/MusicTrackNote'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
export default function MusicTrackNotePreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const line = musicTrackPreviewText(event).trim()
return (
<div className={cn('pointer-events-none min-w-0 truncate text-sm italic', className)}>
{line || t('Music track', { defaultValue: 'Music track' })}
</div>
)
}

15
src/components/ContentPreview/index.tsx

@ -30,6 +30,7 @@ import NormalContentPreview from './NormalContentPreview' @@ -30,6 +30,7 @@ import NormalContentPreview from './NormalContentPreview'
import PictureNotePreview from './PictureNotePreview'
import PollPreview from './PollPreview'
import VideoNotePreview from './VideoNotePreview'
import MusicTrackNotePreview from './MusicTrackNotePreview'
import ZapPreview from './ZapPreview'
import DiscussionNote from '../DiscussionNote'
import ApplicationHandlerInfo from '../ApplicationHandlerInfo'
@ -244,6 +245,20 @@ export default function ContentPreview({ @@ -244,6 +245,20 @@ export default function ContentPreview({
return withKindRow(<VideoNotePreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.MUSIC_TRACK) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>
{line || t('Music track', { defaultValue: 'Music track' })}
</div>
</div>
)
}
return withKindRow(<MusicTrackNotePreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.PICTURE) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)

11
src/components/MediaGridItem/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { ExtendedKind, isNip71StyleVideoKind } from '@/constants'
import { ExtendedKind, isMusicTrackKind, isNip71StyleVideoKind } from '@/constants'
import { getMusicTrackFromEvent } from '@/lib/music-track'
import { isLongFormNip71VideoEventKind } from '@/lib/long-video-load-policy'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link'
@ -21,7 +22,11 @@ export default function MediaGridItem({ event }: { event: Event }) { @@ -21,7 +22,11 @@ export default function MediaGridItem({ event }: { event: Event }) {
const isVideo =
(!isPictureKind && first?.m?.startsWith('video/')) ||
(!isPictureKind && isNip71StyleVideoKind(event.kind))
const isAudio = first?.m?.startsWith('audio/') || event.kind === ExtendedKind.VOICE
const musicTrack = isMusicTrackKind(event.kind) ? getMusicTrackFromEvent(event) : null
const isAudio =
first?.m?.startsWith('audio/') ||
event.kind === ExtendedKind.VOICE ||
musicTrack != null
const hasMultiple = media.all.length > 1
// For videos prefer the poster image; long-form feed tiles never prefetch the .mp4 (open note to play).
@ -29,7 +34,7 @@ export default function MediaGridItem({ event }: { event: Event }) { @@ -29,7 +34,7 @@ export default function MediaGridItem({ event }: { event: Event }) {
? isLongFormVideo
? (first?.image ?? first?.thumb)
: (first?.image ?? first?.url)
: (first?.thumb ?? first?.url)
: musicTrack?.imageUrl ?? first?.thumb ?? first?.url
const handleClick = () => {
client.addEventToCache(event)

1
src/components/Note/Highlight/index.tsx

@ -287,6 +287,7 @@ export default function Highlight({ @@ -287,6 +287,7 @@ export default function Highlight({
ExtendedKind.WIKI_ARTICLE, // Has special card
ExtendedKind.NOSTR_SPECIFICATION, // Has special card
ExtendedKind.VOICE, // Has special card
ExtendedKind.MUSIC_TRACK,
ExtendedKind.VOICE_COMMENT, // Has special card
]

99
src/components/Note/MusicTrackNote.tsx

@ -0,0 +1,99 @@ @@ -0,0 +1,99 @@
import AudioPlayer from '@/components/AudioPlayer'
import {
getMusicTrackFromEvent,
musicTrackCaptionContent,
musicTrackDisplayLine,
musicTrackMetaLine
} from '@/lib/music-track'
import { primalR2aMirrorForBlossomPrimalUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import MediaPlayer from '../MediaPlayer'
export default function MusicTrackNote({
event,
className,
loadMedia = false
}: {
event: Event
className?: string
loadMedia?: boolean
}) {
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const mustLoad = loadMedia || autoLoadMedia
const { t } = useTranslation()
const track = useMemo(() => getMusicTrackFromEvent(event), [event])
const metaLine = useMemo(() => (track ? musicTrackMetaLine(track) : ''), [track])
const caption = useMemo(
() => (track ? musicTrackCaptionContent(event.content, track) : null),
[event.content, track]
)
const audioFallbackSrc = useMemo(
() => (track ? primalR2aMirrorForBlossomPrimalUrl(track.audioUrl) ?? undefined : undefined),
[track]
)
if (!track) {
return (
<p className={cn('text-sm text-muted-foreground', className)}>
{t('Invalid music track event', { defaultValue: 'Invalid music track event' })}
</p>
)
}
return (
<div className={cn('min-w-0', className)}>
<div className="not-prose w-full max-w-[400px] overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="flex gap-3 p-3">
{track.imageUrl ? (
<img
src={track.imageUrl}
alt=""
className="size-16 shrink-0 rounded-md object-cover shadow-sm"
loading="lazy"
referrerPolicy="no-referrer"
draggable={false}
/>
) : null}
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-sm font-semibold leading-snug">{track.title}</p>
{track.artist ? (
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{track.artist}</p>
) : null}
{metaLine ? (
<p className="mt-0.5 line-clamp-2 text-[11px] text-muted-foreground">{metaLine}</p>
) : null}
</div>
</div>
<div className="border-t border-border px-2 pb-2.5 pt-1.5">
<AudioPlayer
src={track.audioUrl}
fallbackSrc={audioFallbackSrc}
className="w-full max-w-none"
/>
</div>
{track.videoUrl ? (
<div className="border-t border-border px-2 pb-2 pt-1">
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t('Music video', { defaultValue: 'Music video' })}
</p>
<MediaPlayer src={track.videoUrl} className="w-full max-w-none" mustLoad={mustLoad} />
</div>
) : null}
</div>
{caption ? (
<p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{caption}</p>
) : null}
</div>
)
}
export function musicTrackPreviewText(event: Event): string {
const track = getMusicTrackFromEvent(event)
return track ? musicTrackDisplayLine(track) : event.tags.find((t) => t[0] === 'title')?.[1] ?? ''
}

5
src/components/Note/index.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { useSmartNoteNavigationOptional } from '@/PageManager'
import { ExtendedKind, isNip71StyleVideoKind, publicAssetUrl } from '@/constants'
import { ExtendedKind, isMusicTrackKind, isNip71StyleVideoKind, publicAssetUrl } from '@/constants'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import {
getHttpUrlFromITags,
@ -76,6 +76,7 @@ import ReactionEmojiDisplay from './ReactionEmojiDisplay' @@ -76,6 +76,7 @@ import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote'
import { Button } from '@/components/ui/button'
import VideoNote from './VideoNote'
import MusicTrackNote from './MusicTrackNote'
import RelayReview from './RelayReview'
import Superchat from './Superchat'
import Zap from './Zap'
@ -565,6 +566,8 @@ export default function Note({ @@ -565,6 +566,8 @@ export default function Note({
)
} else if (event.kind === ExtendedKind.PICTURE) {
content = <PictureNote className="mt-2" event={event} />
} else if (isMusicTrackKind(event.kind)) {
content = <MusicTrackNote className="mt-2" event={event} loadMedia={showFull} />
} else if (isNip71StyleVideoKind(event.kind)) {
content = <VideoNote className="mt-2" event={event} loadMedia={showFull} />
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {

2
src/components/WebPreview/index.tsx

@ -44,6 +44,8 @@ function getEventTypeName(kind: number): string { @@ -44,6 +44,8 @@ function getEventTypeName(kind: number): string {
return 'Comment'
case ExtendedKind.VOICE:
return 'Voice Post'
case ExtendedKind.MUSIC_TRACK:
return 'Music Track'
case ExtendedKind.VOICE_COMMENT:
return 'Voice Comment'
case kinds.Highlights:

10
src/constants.ts

@ -572,6 +572,8 @@ export const ExtendedKind = { @@ -572,6 +572,8 @@ export const ExtendedKind = {
SHORT_VIDEO: 22,
/** NIP-71: addressable normal video (same rendering as {@link ExtendedKind.VIDEO}). */
VIDEO_ADDRESSABLE: 34235,
/** Music track (addressable): audio URL + metadata in tags; kind 36787. */
MUSIC_TRACK: 36787,
POLL: 1068,
/** NIP-B9 zap poll (paid votes via zaps). */
ZAP_POLL: 6969,
@ -728,6 +730,10 @@ export function isNip71StyleVideoKind(kind: number): boolean { @@ -728,6 +730,10 @@ export function isNip71StyleVideoKind(kind: number): boolean {
return NIP71_VIDEO_KIND_SET.has(kind)
}
export function isMusicTrackKind(kind: number): boolean {
return kind === ExtendedKind.MUSIC_TRACK
}
/**
* When these kinds are ingested via {@link EventService.addEventToCache}, the client prefetches the event
* author's kind 3 + 10002 (contacts + NIP-65) so profile / relay UIs and publish routing stay warm.
@ -951,6 +957,7 @@ export const SUPPORTED_KINDS = [ @@ -951,6 +957,7 @@ export const SUPPORTED_KINDS = [
ExtendedKind.POLL,
ExtendedKind.COMMENT,
ExtendedKind.VOICE,
ExtendedKind.MUSIC_TRACK,
ExtendedKind.VOICE_COMMENT,
// ExtendedKind.PUBLIC_MESSAGE, // Excluded - public messages should only appear in notifications
kinds.Highlights,
@ -1004,7 +1011,8 @@ const PROFILE_PUBLICATIONS_TAB_KIND_SET = new Set<number>(PROFILE_PUBLICATIONS_T @@ -1004,7 +1011,8 @@ const PROFILE_PUBLICATIONS_TAB_KIND_SET = new Set<number>(PROFILE_PUBLICATIONS_T
export const PROFILE_MEDIA_TAB_KINDS: readonly number[] = [
ExtendedKind.PICTURE,
...NIP71_VIDEO_KINDS,
ExtendedKind.VOICE
ExtendedKind.VOICE,
ExtendedKind.MUSIC_TRACK
]
/** Home feed Gallery tab: picture + NIP-71 video only (20, 21, 22, 34235). */

1
src/lib/feed-kind-filter.test.ts

@ -36,6 +36,7 @@ describe('feed kind groups', () => { @@ -36,6 +36,7 @@ describe('feed kind groups', () => {
expect(on.showKinds).toContain(ExtendedKind.DISCUSSION)
expect(on.showKinds).toContain(ExtendedKind.PICTURE)
expect(on.showKinds).toContain(ExtendedKind.VOICE)
expect(on.showKinds).toContain(ExtendedKind.MUSIC_TRACK)
expect(isFeedPostsGroupEnabled(on.showKind1OPs, on.showKinds)).toBe(true)
})

3
src/lib/feed-kind-filter.ts

@ -8,7 +8,8 @@ export const FEED_POSTS_GROUP_KINDS: readonly number[] = [ @@ -8,7 +8,8 @@ export const FEED_POSTS_GROUP_KINDS: readonly number[] = [
kinds.Highlights,
ExtendedKind.DISCUSSION,
ExtendedKind.PICTURE,
ExtendedKind.VOICE
ExtendedKind.VOICE,
ExtendedKind.MUSIC_TRACK
]
/** Kind 1 replies, comments, voice comments, superchats — feed filter “Replies” group. */

2
src/lib/kind-description.ts

@ -28,6 +28,8 @@ export function getKindDescription( @@ -28,6 +28,8 @@ export function getKindDescription(
return { number: 1111, description: 'Comment' }
case ExtendedKind.VOICE:
return { number: 1222, description: 'Voice Note' }
case ExtendedKind.MUSIC_TRACK:
return { number: 36787, description: 'Music Track' }
case ExtendedKind.VOICE_COMMENT:
return { number: 1244, description: 'Voice Comment' }
case ExtendedKind.PICTURE:

243
src/lib/music-track.test.ts

@ -0,0 +1,243 @@ @@ -0,0 +1,243 @@
import { ExtendedKind } from '@/constants'
import {
formatMusicTrackDuration,
getMusicTrackFromEvent,
musicTrackCaptionContent,
musicTrackDisplayLine,
musicTrackMetaLine
} from './music-track'
import { describe, expect, it } from 'vitest'
import type { Event } from 'nostr-tools'
/** Minimal kind-36787 event for unit tests. */
function musicTrackEvent(
tags: string[][],
content = ''
): Event {
return {
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
created_at: 1779818315,
kind: ExtendedKind.MUSIC_TRACK,
tags,
content,
sig: 'c'.repeat(128)
}
}
const BASE_TAGS: string[][] = [
['d', 'track-8rqwm2jwg'],
['title', 'Scatman (Ski-Ba-Bop-Ba-Dop-Bop)'],
['url', 'https://blossom.primal.net/cc0c235629f80ef4e98cee3475dc0dcc2ba2eea53730a3448d6d4dcb37c30078.mp3'],
['artist', 'Scatman John'],
['album', "Scatman's World"],
['duration', '218'],
['format', 'mp3'],
['track_number', '5'],
['genre', 'Disco Pop'],
['t', 'music'],
['t', 'gruuv'],
['t', 'grooveblossom'],
['t', 'disco pop']
]
describe('getMusicTrackFromEvent', () => {
it('parses a realistic blossom music-track event', () => {
const track = getMusicTrackFromEvent(musicTrackEvent(BASE_TAGS))
expect(track).toEqual({
title: 'Scatman (Ski-Ba-Bop-Ba-Dop-Bop)',
artist: 'Scatman John',
audioUrl:
'https://blossom.primal.net/cc0c235629f80ef4e98cee3475dc0dcc2ba2eea53730a3448d6d4dcb37c30078.mp3',
imageUrl: undefined,
videoUrl: undefined,
album: "Scatman's World",
trackNumber: '5',
released: undefined,
durationSec: 218,
format: 'mp3',
explicit: false,
alt: undefined,
genres: ['gruuv', 'grooveblossom', 'disco pop'],
language: undefined
})
})
it('parses optional tags', () => {
const track = getMusicTrackFromEvent(
musicTrackEvent([
['d', 'summer-nights-2024'],
['title', 'Summer Nights'],
['url', 'https://cdn.example/audio.mp3'],
['image', 'https://cdn.example/art.jpg'],
['video', 'https://cdn.example/video.mp4'],
['artist', 'The Midnight Collective'],
['album', 'Endless Summer'],
['track_number', '3'],
['released', '2024-06-15'],
['duration', '245'],
['format', 'mp3'],
['explicit', 'true'],
['alt', 'Cover art'],
['language', 'en'],
['t', 'music'],
['t', 'electronic']
])
)
expect(track).toMatchObject({
imageUrl: 'https://cdn.example/art.jpg',
videoUrl: 'https://cdn.example/video.mp4',
released: '2024-06-15',
durationSec: 245,
explicit: true,
alt: 'Cover art',
language: 'en',
genres: ['electronic']
})
expect(musicTrackDisplayLine(track!)).toBe('The Midnight Collective — Summer Nights')
})
it('returns null without title, url, or t=music', () => {
expect(
getMusicTrackFromEvent(
musicTrackEvent([
['d', 'x'],
['title', 'T'],
['url', 'https://a.mp3']
])
)
).toBeNull()
expect(
getMusicTrackFromEvent(
musicTrackEvent([
['d', 'x'],
['url', 'https://a.mp3'],
['t', 'music']
])
)
).toBeNull()
expect(
getMusicTrackFromEvent(
musicTrackEvent([
['d', 'x'],
['title', 'T'],
['t', 'music']
])
)
).toBeNull()
expect(
getMusicTrackFromEvent(
musicTrackEvent([
['d', 'x'],
['title', 'T'],
['url', 'https://a.mp3'],
['t', 'rock']
])
)
).toBeNull()
})
it('ignores invalid or zero duration', () => {
const noDuration = getMusicTrackFromEvent(
musicTrackEvent([
['d', 'x'],
['title', 'T'],
['url', 'https://a.mp3'],
['t', 'music']
])
)
expect(noDuration?.durationSec).toBeUndefined()
const badDuration = getMusicTrackFromEvent(
musicTrackEvent([
['d', 'x'],
['title', 'T'],
['url', 'https://a.mp3'],
['t', 'music'],
['duration', 'nope'],
['duration', '0']
])
)
expect(badDuration?.durationSec).toBeUndefined()
})
it('prepends genre tag when not duplicated by a t tag', () => {
const track = getMusicTrackFromEvent(
musicTrackEvent([
['d', 'x'],
['title', 'T'],
['url', 'https://a.mp3'],
['t', 'music'],
['genre', 'Disco Pop'],
['t', 'synthwave']
])
)
expect(track?.genres).toEqual(['Disco Pop', 'synthwave'])
})
it('dedupes genre tag against t tags case-insensitively', () => {
const track = getMusicTrackFromEvent(
musicTrackEvent([
['d', 'x'],
['title', 'T'],
['url', 'https://a.mp3'],
['t', 'music'],
['genre', 'Disco Pop'],
['t', 'disco pop']
])
)
expect(track?.genres).toEqual(['disco pop'])
})
})
describe('formatMusicTrackDuration', () => {
it('formats mm:ss and h:mm:ss', () => {
expect(formatMusicTrackDuration(245)).toBe('4:05')
expect(formatMusicTrackDuration(218)).toBe('3:38')
expect(formatMusicTrackDuration(3665)).toBe('1:01:05')
})
it('returns empty string for invalid input', () => {
expect(formatMusicTrackDuration(NaN)).toBe('')
expect(formatMusicTrackDuration(-1)).toBe('')
expect(formatMusicTrackDuration(Infinity)).toBe('')
})
})
describe('musicTrackMetaLine', () => {
it('builds a metadata line from track fields', () => {
const track = getMusicTrackFromEvent(musicTrackEvent(BASE_TAGS))!
expect(musicTrackMetaLine(track)).toBe(
"Scatman's World #5 · 3:38 · MP3 · gruuv, grooveblossom, disco pop"
)
})
})
describe('musicTrackCaptionContent', () => {
const scatmanTrack = () => getMusicTrackFromEvent(musicTrackEvent(BASE_TAGS))!
it('drops promotional content that repeats title and artist', () => {
const track = scatmanTrack()
expect(
musicTrackCaptionContent(
'Listen to my song - Scatman (Ski-Ba-Bop-Ba-Dop-Bop) by Scatman John',
track
)
).toBeNull()
})
it('keeps lyrics or notes that are not just title/artist promotion', () => {
const track = scatmanTrack()
expect(musicTrackCaptionContent('Verse one…', track)).toBe('Verse one…')
expect(musicTrackCaptionContent('Listen to my song', track)).toBe('Listen to my song')
expect(musicTrackCaptionContent('', track)).toBeNull()
expect(musicTrackCaptionContent(' ', track)).toBeNull()
})
it('keeps content that mentions the title but not the artist', () => {
const track = scatmanTrack()
expect(musicTrackCaptionContent('Scatman (Ski-Ba-Bop-Ba-Dop-Bop) — live version', track)).toBe(
'Scatman (Ski-Ba-Bop-Ba-Dop-Bop) — live version'
)
})
})

119
src/lib/music-track.ts

@ -0,0 +1,119 @@ @@ -0,0 +1,119 @@
import { isMusicTrackKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import type { Event } from 'nostr-tools'
export type TMusicTrack = {
title: string
artist?: string
audioUrl: string
imageUrl?: string
videoUrl?: string
album?: string
trackNumber?: string
released?: string
durationSec?: number
format?: string
explicit?: boolean
alt?: string
genres: string[]
language?: string
}
function firstTagValue(event: Event, name: string): string | undefined {
const v = event.tags.find(tagNameEquals(name))?.[1]?.trim()
return v || undefined
}
function tagValues(event: Event, name: string): string[] {
return event.tags
.filter((t) => t[0] === name && t[1]?.trim())
.map((t) => t[1]!.trim())
}
function mergeMusicTrackGenres(event: Event): string[] {
const fromT = tagValues(event, 't').filter((t) => t !== 'music')
const genre = firstTagValue(event, 'genre')
if (!genre) return fromT
const key = genre.toLowerCase()
if (fromT.some((g) => g.toLowerCase() === key)) return fromT
return [genre, ...fromT]
}
/** Promotional note text that only repeats title/artist should not render below the card. */
export function musicTrackCaptionContent(
content: string | undefined,
track: TMusicTrack
): string | null {
const c = content?.trim()
if (!c) return null
const norm = c.toLowerCase()
const title = track.title.toLowerCase()
if (!norm.includes(title)) return c
const artist = track.artist?.toLowerCase()
if (artist && !norm.includes(artist)) return c
return null
}
export function formatMusicTrackDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return ''
const total = Math.floor(seconds)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
return `${m}:${s.toString().padStart(2, '0')}`
}
/** Parse kind 36787 music track metadata from event tags. */
export function getMusicTrackFromEvent(event: Event): TMusicTrack | null {
if (!isMusicTrackKind(event.kind)) return null
const title = firstTagValue(event, 'title')
const audioUrl = firstTagValue(event, 'url')
if (!title || !audioUrl) return null
const hasMusicTag = event.tags.some((t) => t[0] === 't' && t[1] === 'music')
if (!hasMusicTag) return null
const durationRaw = firstTagValue(event, 'duration')
const durationSec = durationRaw != null ? Number.parseInt(durationRaw, 10) : NaN
return {
title,
artist: firstTagValue(event, 'artist'),
audioUrl,
imageUrl: firstTagValue(event, 'image'),
videoUrl: firstTagValue(event, 'video'),
album: firstTagValue(event, 'album'),
trackNumber: firstTagValue(event, 'track_number'),
released: firstTagValue(event, 'released'),
durationSec: Number.isFinite(durationSec) && durationSec > 0 ? durationSec : undefined,
format: firstTagValue(event, 'format'),
explicit: firstTagValue(event, 'explicit')?.toLowerCase() === 'true',
alt: firstTagValue(event, 'alt'),
genres: mergeMusicTrackGenres(event),
language: firstTagValue(event, 'language')
}
}
export function musicTrackDisplayLine(track: TMusicTrack): string {
if (track.artist) return `${track.artist}${track.title}`
return track.title
}
export function musicTrackMetaLine(track: TMusicTrack): string {
const parts: string[] = []
if (track.album) {
parts.push(track.trackNumber ? `${track.album} #${track.trackNumber}` : track.album)
} else if (track.trackNumber) {
parts.push(`#${track.trackNumber}`)
}
if (track.released) parts.push(track.released)
if (track.durationSec) parts.push(formatMusicTrackDuration(track.durationSec))
if (track.format) parts.push(track.format.toUpperCase())
if (track.explicit) parts.push('Explicit')
if (track.genres.length) parts.push(track.genres.slice(0, 3).join(', '))
return parts.join(' · ')
}

8
src/lib/parent-reply-blurb.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { ExtendedKind, isNip71StyleVideoKind } from '@/constants'
import { ExtendedKind, isMusicTrackKind, isNip71StyleVideoKind } from '@/constants'
import { getMusicTrackFromEvent, musicTrackDisplayLine } from '@/lib/music-track'
import {
getLiveEventMetadataFromEvent,
getLongFormArticleMetadataFromEvent
@ -64,6 +65,11 @@ export function getParentReplyBlurbDisplayText( @@ -64,6 +65,11 @@ export function getParentReplyBlurbDisplayText(
if (live.summary?.trim()) return truncateBlurb(stripMarkupForPreview(live.summary), maxLen)
}
if (isMusicTrackKind(event.kind)) {
const track = getMusicTrackFromEvent(event)
if (track) return truncateBlurb(musicTrackDisplayLine(track), maxLen)
}
if (event.kind === ExtendedKind.PICTURE || isNip71StyleVideoKind(event.kind)) {
const cap = truncateBlurb(stripMarkupForPreview(event.content ?? ''), maxLen)
return cap

4
src/pages/secondary/NotePage/index.tsx

@ -74,6 +74,8 @@ function getEventTypeName(kind: number): string { @@ -74,6 +74,8 @@ function getEventTypeName(kind: number): string {
return 'Comment'
case ExtendedKind.VOICE:
return 'Voice Post'
case ExtendedKind.MUSIC_TRACK:
return 'Music Track'
case ExtendedKind.VOICE_COMMENT:
return 'Voice Comment'
case kinds.Highlights:
@ -284,6 +286,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -284,6 +286,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return 'Note: Comment'
case 1222: // ExtendedKind.VOICE
return 'Note: Voice Post'
case 36787: // ExtendedKind.MUSIC_TRACK
return 'Note: Music Track'
case 1244: // ExtendedKind.VOICE_COMMENT
return 'Note: Voice Comment'
default:

10
src/services/local-storage.service.ts

@ -325,13 +325,21 @@ class LocalStorageService { @@ -325,13 +325,21 @@ class LocalStorageService {
}
}
}
if (showKindsVersion < 15) {
if (
(showKinds.includes(ExtendedKind.VOICE) || showKinds.includes(ExtendedKind.PICTURE)) &&
!showKinds.includes(ExtendedKind.MUSIC_TRACK)
) {
showKinds.push(ExtendedKind.MUSIC_TRACK)
}
}
// v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent).
this.showKinds = showKinds
// Only persist when we read from localStorage. If SHOW_KINDS is missing here (migrated to IDB and
// keys cleared), persisting would write DEFAULT_FEED_SHOW_KINDS to IndexedDB and wipe the user's
// saved filter before initAsync/applySettings runs.
this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds))
this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '14')
this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '15')
}
// Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set)

1
src/services/mention-event-search.service.ts

@ -58,6 +58,7 @@ export const NADDR_KINDS = [ @@ -58,6 +58,7 @@ export const NADDR_KINDS = [
ExtendedKind.NOSTR_SPECIFICATION,
ExtendedKind.PUBLICATION_CONTENT,
kinds.LongFormArticle,
ExtendedKind.MUSIC_TRACK
] as const
export type PickerSearchMode = 'nevent' | 'naddr'

1
src/services/nip89.service.ts

@ -223,6 +223,7 @@ class Nip89Service { @@ -223,6 +223,7 @@ class Nip89Service {
ExtendedKind.POLL,
ExtendedKind.COMMENT,
ExtendedKind.VOICE,
ExtendedKind.MUSIC_TRACK,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.DISCUSSION,
ExtendedKind.RELAY_REVIEW,

Loading…
Cancel
Save