feat: audio

This commit is contained in:
codytseng
2025-07-29 22:44:43 +08:00
parent de09942124
commit 4ea5ea1705
37 changed files with 629 additions and 145 deletions

View File

@@ -0,0 +1,113 @@
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import mediaManager from '@/services/media-manager.service'
import { Pause, Play } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
interface AudioPlayerProps {
src: string
className?: string
}
export default function AudioPlayer({ src, className }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const seekTimeoutRef = useRef<NodeJS.Timeout>()
const isSeeking = useRef(false)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const updateTime = () => {
if (!isSeeking.current) {
setCurrentTime(audio.currentTime)
}
}
const updateDuration = () => setDuration(audio.duration)
const handleEnded = () => setIsPlaying(false)
const handlePause = () => setIsPlaying(false)
const handlePlay = () => setIsPlaying(true)
audio.addEventListener('timeupdate', updateTime)
audio.addEventListener('loadedmetadata', updateDuration)
audio.addEventListener('ended', handleEnded)
audio.addEventListener('pause', handlePause)
audio.addEventListener('play', handlePlay)
return () => {
audio.removeEventListener('timeupdate', updateTime)
audio.removeEventListener('loadedmetadata', updateDuration)
audio.removeEventListener('ended', handleEnded)
audio.removeEventListener('pause', handlePause)
audio.removeEventListener('play', handlePlay)
}
}, [])
const togglePlay = () => {
const audio = audioRef.current
if (!audio) return
if (isPlaying) {
audio.pause()
setIsPlaying(false)
} else {
audio.play()
setIsPlaying(true)
mediaManager.play(audio)
}
}
const handleSeek = (value: number[]) => {
const audio = audioRef.current
if (!audio) return
isSeeking.current = true
setCurrentTime(value[0])
if (seekTimeoutRef.current) {
clearTimeout(seekTimeoutRef.current)
}
seekTimeoutRef.current = setTimeout(() => {
audio.currentTime = value[0]
isSeeking.current = false
}, 300)
}
return (
<div
className={cn(
'flex items-center gap-3 py-2 pl-2 pr-4 border rounded-full max-w-md',
className
)}
onClick={(e) => e.stopPropagation()}
>
<audio ref={audioRef} src={src} preload="metadata" />
{/* Play/Pause Button */}
<Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}>
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
</Button>
{/* Progress Section */}
<div className="flex-1 relative">
<Slider value={[currentTime]} max={duration || 100} step={1} onValueChange={handleSeek} />
</div>
<div className="text-sm font-mono text-muted-foreground">{formatTime(duration)}</div>
</div>
)
}
const formatTime = (time: number) => {
if (time === Infinity || isNaN(time)) {
return '-:--'
}
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

View File

@@ -5,9 +5,9 @@ import {
EmbeddedHashtagParser,
EmbeddedImageParser,
EmbeddedLNInvoiceParser,
EmbeddedMediaParser,
EmbeddedMentionParser,
EmbeddedNormalUrlParser,
EmbeddedVideoParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
@@ -28,14 +28,14 @@ import {
} from '../Embedded'
import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery'
import VideoPlayer from '../VideoPlayer'
import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview'
const Content = memo(({ event, className }: { event: Event; className?: string }) => {
const translatedEvent = useTranslatedEvent(event.id)
const nodes = parseContent(translatedEvent?.content ?? event.content, [
EmbeddedImageParser,
EmbeddedVideoParser,
EmbeddedMediaParser,
EmbeddedNormalUrlParser,
EmbeddedLNInvoiceParser,
EmbeddedWebsocketUrlParser,
@@ -91,8 +91,8 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
<ImageGallery className="mt-2" key={index} images={allImages} start={start} end={end} />
)
}
if (node.type === 'video') {
return <VideoPlayer className="mt-2" key={index} src={node.data} />
if (node.type === 'media') {
return <MediaPlayer className="mt-2" key={index} src={node.data} />
}
if (node.type === 'url') {
return <EmbeddedNormalUrl url={node.data} key={index} />

View File

@@ -3,7 +3,7 @@ import {
EmbeddedEventParser,
EmbeddedImageParser,
EmbeddedMentionParser,
EmbeddedVideoParser,
EmbeddedMediaParser,
parseContent
} from '@/lib/content-parser'
import { cn } from '@/lib/utils'
@@ -26,7 +26,7 @@ export default function Content({
const nodes = useMemo(() => {
return parseContent(content, [
EmbeddedImageParser,
EmbeddedVideoParser,
EmbeddedMediaParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedEmojiParser
@@ -42,8 +42,8 @@ export default function Content({
if (node.type === 'image' || node.type === 'images') {
return index > 0 ? ` [${t('image')}]` : `[${t('image')}]`
}
if (node.type === 'video') {
return index > 0 ? ` [${t('video')}]` : `[${t('video')}]`
if (node.type === 'media') {
return index > 0 ? ` [${t('media')}]` : `[${t('media')}]`
}
if (node.type === 'event') {
return index > 0 ? ` [${t('note')}]` : `[${t('note')}]`

View File

@@ -36,7 +36,15 @@ export default function ContentPreview({
)
}
if ([kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.PICTURE].includes(event.kind)) {
if (
[
kinds.ShortTextNote,
ExtendedKind.COMMENT,
ExtendedKind.PICTURE,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT
].includes(event.kind)
) {
return <NormalContentPreview event={event} className={className} />
}

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react'
import AudioPlayer from '../AudioPlayer'
import VideoPlayer from '../VideoPlayer'
export default function MediaPlayer({ src, className }: { src: string; className?: string }) {
const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null)
useEffect(() => {
if (!src) {
setMediaType(null)
return
}
const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase()
if (extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma'].includes(extension)) {
setMediaType('audio')
return
}
const video = document.createElement('video')
video.src = src
video.preload = 'metadata'
video.crossOrigin = 'anonymous'
video.onloadedmetadata = () => {
setMediaType(video.videoWidth > 0 || video.videoHeight > 0 ? 'video' : 'audio')
}
video.onerror = () => {
setMediaType(null)
}
return () => {
video.src = ''
}
}, [src])
if (!mediaType) {
return null
}
if (mediaType === 'video') {
return <VideoPlayer src={src} className={className} />
}
return <AudioPlayer src={src} className={className} />
}

View File

@@ -12,6 +12,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
import AudioPlayer from '../AudioPlayer'
import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import ImageGallery from '../ImageGallery'
@@ -71,7 +72,9 @@ export default function Note({
ExtendedKind.GROUP_METADATA,
ExtendedKind.PICTURE,
ExtendedKind.COMMENT,
ExtendedKind.POLL
ExtendedKind.POLL,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT
].includes(event.kind)
) {
content = <UnknownNote className="mt-2" event={event} />
@@ -96,6 +99,8 @@ export default function Note({
<Poll className="mt-2" event={event} />
</>
)
} else if (event.kind === ExtendedKind.VOICE || event.kind === ExtendedKind.VOICE_COMMENT) {
content = <AudioPlayer className="mt-2" src={event.content} />
} else {
content = <Content className="mt-2" event={event} />
}

View File

@@ -30,7 +30,9 @@ const KINDS = [
kinds.Highlights,
kinds.LongFormArticle,
ExtendedKind.COMMENT,
ExtendedKind.POLL
ExtendedKind.POLL,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT
]
export default function NoteList({

View File

@@ -31,7 +31,10 @@ export function NotificationItem({
if (notification.kind === kinds.Zap) {
return <ZapNotification notification={notification} isNew={isNew} />
}
if (notification.kind === ExtendedKind.COMMENT) {
if (
notification.kind === ExtendedKind.COMMENT ||
notification.kind === ExtendedKind.VOICE_COMMENT
) {
return <CommentNotification notification={notification} isNew={isNew} />
}
if (notification.kind === ExtendedKind.POLL_RESPONSE) {

View File

@@ -39,7 +39,7 @@ const NotificationList = forwardRef((_, ref) => {
const filterKinds = useMemo(() => {
switch (notificationType) {
case 'mentions':
return [kinds.ShortTextNote, ExtendedKind.COMMENT]
return [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT]
case 'reactions':
return [kinds.Reaction, kinds.Repost, ExtendedKind.POLL_RESPONSE]
case 'zaps':
@@ -51,7 +51,8 @@ const NotificationList = forwardRef((_, ref) => {
kinds.Reaction,
kinds.Zap,
ExtendedKind.COMMENT,
ExtendedKind.POLL_RESPONSE
ExtendedKind.POLL_RESPONSE,
ExtendedKind.VOICE_COMMENT
]
}
}, [notificationType])

View File

@@ -153,7 +153,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
if (event.kind !== kinds.ShortTextNote) {
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
})
}
@@ -166,7 +166,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
},
{
'#A': [rootInfo.id],
kinds: [ExtendedKind.COMMENT],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
}
)
@@ -176,7 +176,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
} else {
filters.push({
'#I': [rootInfo.id],
kinds: [ExtendedKind.COMMENT],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
})
}

View File

@@ -1,6 +1,6 @@
import { cn, isInViewport } from '@/lib/utils'
import { useAutoplay } from '@/providers/AutoplayProvider'
import videoManager from '@/services/video-manager.service'
import mediaManager from '@/services/media-manager.service'
import { useEffect, useRef } from 'react'
export default function VideoPlayer({ src, className }: { src: string; className?: string }) {
@@ -21,11 +21,11 @@ export default function VideoPlayer({ src, className }: { src: string; className
if (entry.isIntersecting) {
setTimeout(() => {
if (isInViewport(container)) {
videoManager.autoPlay(video)
mediaManager.autoPlay(video)
}
}, 200)
} else {
videoManager.pause(video)
mediaManager.pause(video)
}
},
{ threshold: 1 }
@@ -48,7 +48,7 @@ export default function VideoPlayer({ src, className }: { src: string; className
src={src}
onClick={(e) => e.stopPropagation()}
onPlay={(event) => {
videoManager.play(event.currentTarget)
mediaManager.play(event.currentTarget)
}}
muted
/>

View File

@@ -0,0 +1,41 @@
import * as SliderPrimitive from '@radix-ui/react-slider'
import * as React from 'react'
import { cn } from '@/lib/utils'
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & { hideThumb?: boolean }
>(({ className, ...props }, ref) => {
const [isHovered, setIsHovered] = React.useState(false)
return (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onTouchStart={() => setIsHovered(true)}
onTouchEnd={() => setIsHovered(false)}
>
<SliderPrimitive.Track
className={cn(
'relative w-full grow overflow-hidden rounded-full bg-primary/20 cursor-pointer transition-all',
isHovered ? 'h-3' : 'h-1.5'
)}
>
<SliderPrimitive.Range className="absolute h-full bg-primary rounded-full" />
</SliderPrimitive.Track>
{/* <SliderPrimitive.Thumb
className={cn(
'block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-all duration-300 cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/> */}
</SliderPrimitive.Root>
)
})
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }