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