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

243
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
@@ -3219,6 +3220,217 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz",
"integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
@@ -3357,6 +3569,37 @@
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",

View File

@@ -31,6 +31,7 @@
"@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",

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 }

View File

@@ -65,6 +65,8 @@ export const ExtendedKind = {
POLL: 1068,
POLL_RESPONSE: 1018,
COMMENT: 1111,
VOICE: 1222,
VOICE_COMMENT: 1244,
FAVORITE_RELAYS: 10012,
BLOSSOM_SERVER_LIST: 10063,
GROUP_METADATA: 39000
@@ -76,8 +78,8 @@ export const WS_URL_REGEX =
/wss?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+(?<![.,;:'")\]}!?""''])/gu
export const IMAGE_REGEX =
/https?:\/\/[\w\p{L}\p{N}\p{M}&.-]+\/(?:[^/\s?]*\/)*([^/\s?]*\.(jpg|jpeg|png|gif|webp|bmp|tiff|heic|svg|avif))(?!\w)(?:\?[\w\p{L}\p{N}\p{M}&=.-]*)?(?<![.,;:'")\]}!?""''])/giu
export const VIDEO_REGEX =
/https?:\/\/[\w\p{L}\p{N}\p{M}&.-]+\/(?:[^/\s?]*\/)*([^/\s?]*\.(mp4|webm|ogg|mov))(?!\w)(?:\?[\w\p{L}\p{N}\p{M}&=.-]*)?(?<![.,;:'")\]}!?""''])/giu
export const MEDIA_REGEX =
/https?:\/\/[\w\p{L}\p{N}\p{M}&.-]+\/(?:[^/\s?]*\/)*([^/\s?]*\.(mp4|webm|ogg|mov|mp3|wav|flac|aac|m4a|opus|wma))(?!\w)(?:\?[\w\p{L}\p{N}\p{M}&=.-]*)?(?<![.,;:'")\]}!?""''])/giu
export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
export const EMOJI_SHORT_CODE_REGEX = /:[a-zA-Z0-9_-]+:/g
export const EMBEDDED_EVENT_REGEX = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g

View File

@@ -313,6 +313,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'عناوين المرحلات (اختياري، مفصولة بفواصل)',
'Remove poll': 'إزالة الاستطلاع',
'Refresh results': 'تحديث النتائج',
Poll: 'استطلاع'
Poll: 'استطلاع',
media: 'الوسائط'
}
}

View File

@@ -320,6 +320,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'Relay-URLs (optional, durch Kommas getrennt)',
'Remove poll': 'Umfrage entfernen',
'Refresh results': 'Ergebnisse aktualisieren',
Poll: 'Umfrage'
Poll: 'Umfrage',
media: 'Medien'
}
}

View File

@@ -313,6 +313,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)',
'Remove poll': 'Remove poll',
'Refresh results': 'Refresh results',
Poll: 'Poll'
Poll: 'Poll',
media: 'media'
}
}

View File

@@ -318,6 +318,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'URLs de relé (opcional, separadas por comas)',
'Remove poll': 'Eliminar encuesta',
'Refresh results': 'Actualizar resultados',
Poll: 'Encuesta'
Poll: 'Encuesta',
media: 'medios'
}
}

View File

@@ -315,6 +315,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'آدرس‌های رله (اختیاری، جدا شده با کاما)',
'Remove poll': 'حذف نظرسنجی',
'Refresh results': 'بارگیری مجدد نتایج',
Poll: 'نظرسنجی'
Poll: 'نظرسنجی',
media: 'رسانه'
}
}

View File

@@ -319,6 +319,7 @@ export default {
'URLs de relais (optionnel, séparées par des virgules)',
'Remove poll': 'Supprimer le sondage',
'Refresh results': 'Rafraîchir les résultats',
Poll: 'Sondage'
Poll: 'Sondage',
media: 'média'
}
}

View File

@@ -317,6 +317,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'URL relay (opzionale, separati da virgole)',
'Remove poll': 'Rimuovi sondaggio',
'Refresh results': 'Aggiorna risultati',
Poll: 'Sondaggio'
Poll: 'Sondaggio',
media: 'media'
}
}

View File

@@ -315,6 +315,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'リレーURL任意、カンマ区切り',
'Remove poll': '投票を削除',
'Refresh results': '結果を更新',
Poll: '投票'
Poll: '投票',
media: 'メディア'
}
}

View File

@@ -315,6 +315,7 @@ export default {
'Relay URLs (optional, comma-separated)': '릴레이 URL (선택사항, 쉼표로 구분)',
'Remove poll': '투표 제거',
'Refresh results': '결과 새로 고침',
Poll: '투표'
Poll: '투표',
media: '미디어'
}
}

View File

@@ -317,6 +317,7 @@ export default {
'Adresy URL przekaźników (opcjonalne, oddzielone przecinkami)',
'Remove poll': 'Usuń ankietę',
'Refresh results': 'Odśwież wyniki',
Poll: 'Ankieta'
Poll: 'Ankieta',
media: 'media'
}
}

View File

@@ -316,6 +316,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'URLs de relay (opcional, separadas por vírgulas)',
'Remove poll': 'Remover enquete',
'Refresh results': 'Atualizar resultados',
Poll: 'Enquete'
Poll: 'Enquete',
media: 'Mídia'
}
}

View File

@@ -317,6 +317,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'URLs de relay (opcional, separadas por vírgulas)',
'Remove poll': 'Remover sondagem',
'Refresh results': 'Atualizar resultados',
Poll: 'Sondagem'
Poll: 'Sondagem',
media: 'mídia'
}
}

View File

@@ -318,6 +318,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'URL релеев (необязательно, через запятую)',
'Remove poll': 'Удалить опрос',
'Refresh results': 'Обновить результаты',
Poll: 'Опрос'
Poll: 'Опрос',
media: 'медиа'
}
}

View File

@@ -312,6 +312,7 @@ export default {
'Relay URLs (optional, comma-separated)': 'URL รีเลย์ (ไม่บังคับ, คั่นด้วยจุลภาค)',
'Remove poll': 'ลบโพลล์',
'Refresh results': 'รีเฟรชผลลัพธ์',
Poll: 'โพลล์'
Poll: 'โพลล์',
media: 'สื่อ'
}
}

View File

@@ -313,6 +313,7 @@ export default {
'Relay URLs (optional, comma-separated)': '中继服务器 URL可选逗号分隔',
'Remove poll': '移除投票',
'Refresh results': '刷新结果',
Poll: '投票'
Poll: '投票',
media: '媒体'
}
}

View File

@@ -6,7 +6,7 @@ import {
IMAGE_REGEX,
LN_INVOICE_REGEX,
URL_REGEX,
VIDEO_REGEX,
MEDIA_REGEX,
WS_URL_REGEX
} from '@/constants'
@@ -14,7 +14,7 @@ export type TEmbeddedNodeType =
| 'text'
| 'image'
| 'images'
| 'video'
| 'media'
| 'event'
| 'mention'
| 'legacy-mention'
@@ -61,9 +61,9 @@ export const EmbeddedImageParser: TContentParser = {
regex: IMAGE_REGEX
}
export const EmbeddedVideoParser: TContentParser = {
type: 'video',
regex: VIDEO_REGEX
export const EmbeddedMediaParser: TContentParser = {
type: 'media',
regex: MEDIA_REGEX
}
export const EmbeddedWebsocketUrlParser: TContentParser = {

View File

@@ -494,28 +494,16 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
async function extractCommentMentions(content: string, parentEvent: Event) {
const quoteEventIds: string[] = []
const rootCoordinateTag =
parentEvent.kind === ExtendedKind.COMMENT
? parentEvent.tags.find(tagNameEquals('A'))
: isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent, true)
: undefined
const rootEventId =
parentEvent.kind === ExtendedKind.COMMENT
? parentEvent.tags.find(tagNameEquals('E'))?.[1]
: parentEvent.id
const rootKind =
parentEvent.kind === ExtendedKind.COMMENT
? parentEvent.tags.find(tagNameEquals('K'))?.[1]
: parentEvent.kind
const rootPubkey =
parentEvent.kind === ExtendedKind.COMMENT
? parentEvent.tags.find(tagNameEquals('P'))?.[1]
: parentEvent.pubkey
const rootUrl =
parentEvent.kind === ExtendedKind.COMMENT
? parentEvent.tags.find(tagNameEquals('I'))?.[1]
const isComment = [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
const rootCoordinateTag = isComment
? parentEvent.tags.find(tagNameEquals('A'))
: isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent, true)
: undefined
const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent.id
const rootKind = isComment ? parentEvent.tags.find(tagNameEquals('K'))?.[1] : parentEvent.kind
const rootPubkey = isComment ? parentEvent.tags.find(tagNameEquals('P'))?.[1] : parentEvent.pubkey
const rootUrl = isComment ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : undefined
const addToSet = (arr: string[], item: string) => {
if (!arr.includes(item)) arr.push(item)

View File

@@ -21,7 +21,10 @@ export function isNsfwEvent(event: Event) {
}
export function isReplyNoteEvent(event: Event) {
if (![kinds.ShortTextNote, ExtendedKind.COMMENT].includes(event.kind)) return false
if ([ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)) {
return true
}
if (event.kind !== kinds.ShortTextNote) return false
const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id)
if (cache !== undefined) return cache
@@ -44,12 +47,14 @@ export function isProtectedEvent(event: Event) {
}
export function getParentETag(event?: Event) {
if (!event || ![kinds.ShortTextNote, ExtendedKind.COMMENT].includes(event.kind)) return undefined
if (!event) return undefined
if (event.kind === ExtendedKind.COMMENT) {
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) {
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
}
if (event.kind !== kinds.ShortTextNote) return undefined
let tag = event.tags.find(([tagName, , , marker]) => {
return tagName === 'e' && marker === 'reply'
})
@@ -63,7 +68,12 @@ export function getParentETag(event?: Event) {
}
export function getParentATag(event?: Event) {
if (!event || ![kinds.ShortTextNote, ExtendedKind.COMMENT].includes(event.kind)) return undefined
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
) {
return undefined
}
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
}
@@ -86,12 +96,14 @@ export function getParentBech32Id(event?: Event) {
}
export function getRootETag(event?: Event) {
if (!event || ![kinds.ShortTextNote, ExtendedKind.COMMENT].includes(event.kind)) return undefined
if (!event) return undefined
if (event.kind === ExtendedKind.COMMENT) {
if (event.kind === ExtendedKind.COMMENT || ExtendedKind.VOICE_COMMENT) {
return event.tags.find(tagNameEquals('E'))
}
if (event.kind !== kinds.ShortTextNote) return undefined
let tag = event.tags.find(([tagName, , , marker]) => {
return tagName === 'e' && marker === 'root'
})
@@ -105,7 +117,12 @@ export function getRootETag(event?: Event) {
}
export function getRootATag(event?: Event) {
if (!event || event.kind !== ExtendedKind.COMMENT) return undefined
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
) {
return undefined
}
return event.tags.find(tagNameEquals('A'))
}

View File

@@ -557,13 +557,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const _additionalRelayUrls: string[] = additionalRelayUrls ?? []
if (
!specifiedRelayUrls?.length &&
[
kinds.ShortTextNote,
kinds.Reaction,
kinds.Repost,
ExtendedKind.COMMENT,
ExtendedKind.PICTURE
].includes(draftEvent.kind)
![kinds.Contacts, kinds.Mutelist].includes(draftEvent.kind)
) {
const mentions: string[] = []
draftEvent.tags.forEach(([tagName, tagValue]) => {

View File

@@ -50,10 +50,12 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
{
kinds: [
kinds.ShortTextNote,
ExtendedKind.COMMENT,
kinds.Reaction,
kinds.Repost,
kinds.Zap
kinds.Zap,
ExtendedKind.COMMENT,
ExtendedKind.POLL_RESPONSE,
ExtendedKind.VOICE_COMMENT
],
'#p': [pubkey],
since: notificationsSeenAt,

View File

@@ -0,0 +1,64 @@
class MediaManagerService {
static instance: MediaManagerService
private currentMedia: HTMLMediaElement | null = null
constructor() {
if (!MediaManagerService.instance) {
MediaManagerService.instance = this
}
return MediaManagerService.instance
}
pause(media: HTMLMediaElement) {
if (isPipElement(media)) {
return
}
if (this.currentMedia === media) {
this.currentMedia = null
}
media.pause()
}
autoPlay(media: HTMLMediaElement) {
if (
document.pictureInPictureElement &&
isMediaPlaying(document.pictureInPictureElement as HTMLMediaElement)
) {
return
}
this.play(media)
}
play(media: HTMLMediaElement) {
if (document.pictureInPictureElement && document.pictureInPictureElement !== media) {
;(document.pictureInPictureElement as HTMLMediaElement).pause()
}
if (this.currentMedia && this.currentMedia !== media) {
this.currentMedia.pause()
}
this.currentMedia = media
if (isMediaPlaying(media)) {
return
}
this.currentMedia.play().catch((error) => {
console.error('Error playing media:', error)
this.currentMedia = null
})
}
}
const instance = new MediaManagerService()
export default instance
function isMediaPlaying(media: HTMLMediaElement) {
return media.currentTime > 0 && !media.paused && !media.ended && media.readyState >= 2
}
function isPipElement(media: HTMLMediaElement) {
if (document.pictureInPictureElement === media) {
return true
}
return (media as any).webkitPresentationMode === 'picture-in-picture'
}

View File

@@ -1,64 +0,0 @@
class VideoManagerService {
static instance: VideoManagerService
private currentVideo: HTMLVideoElement | null = null
constructor() {
if (!VideoManagerService.instance) {
VideoManagerService.instance = this
}
return VideoManagerService.instance
}
pause(video: HTMLVideoElement) {
if (isPipElement(video)) {
return
}
if (this.currentVideo === video) {
this.currentVideo = null
}
video.pause()
}
autoPlay(video: HTMLVideoElement) {
if (
document.pictureInPictureElement &&
isVideoPlaying(document.pictureInPictureElement as HTMLVideoElement)
) {
return
}
this.play(video)
}
play(video: HTMLVideoElement) {
if (document.pictureInPictureElement && document.pictureInPictureElement !== video) {
;(document.pictureInPictureElement as HTMLVideoElement).pause()
}
if (this.currentVideo && this.currentVideo !== video) {
this.currentVideo.pause()
}
this.currentVideo = video
if (isVideoPlaying(video)) {
return
}
this.currentVideo.play().catch((error) => {
console.error('Error playing video:', error)
this.currentVideo = null
})
}
}
const instance = new VideoManagerService()
export default instance
function isVideoPlaying(video: HTMLVideoElement) {
return video.currentTime > 0 && !video.paused && !video.ended && video.readyState >= 2
}
function isPipElement(video: HTMLVideoElement) {
if (document.pictureInPictureElement === video) {
return true
}
return (video as any).webkitPresentationMode === 'picture-in-picture'
}