19 changed files with 581 additions and 11 deletions
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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] ?? '' |
||||||
|
} |
||||||
@ -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' |
||||||
|
) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -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(' · ') |
||||||
|
} |
||||||
Loading…
Reference in new issue