feat: audio
This commit is contained in:
113
src/components/AudioPlayer/index.tsx
Normal file
113
src/components/AudioPlayer/index.tsx
Normal 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')}`
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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')}]`
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
|
||||
49
src/components/MediaPlayer/index.tsx
Normal file
49
src/components/MediaPlayer/index.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
41
src/components/ui/slider.tsx
Normal file
41
src/components/ui/slider.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user