diff --git a/package-lock.json b/package-lock.json index 5269a0d3..c27b8ded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ab5265da..11bbfeb4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AudioPlayer/index.tsx b/src/components/AudioPlayer/index.tsx new file mode 100644 index 00000000..16ff9a8a --- /dev/null +++ b/src/components/AudioPlayer/index.tsx @@ -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(null) + const [isPlaying, setIsPlaying] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const seekTimeoutRef = useRef() + 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 ( +
e.stopPropagation()} + > +
+ ) +} + +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')}` +} diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 8d0bbd2b..de56ad53 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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 } ) } - if (node.type === 'video') { - return + if (node.type === 'media') { + return } if (node.type === 'url') { return diff --git a/src/components/ContentPreview/Content.tsx b/src/components/ContentPreview/Content.tsx index 34832855..dd4de293 100644 --- a/src/components/ContentPreview/Content.tsx +++ b/src/components/ContentPreview/Content.tsx @@ -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')}]` diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index ea668fd2..0fb39dfe 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -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 } diff --git a/src/components/MediaPlayer/index.tsx b/src/components/MediaPlayer/index.tsx new file mode 100644 index 00000000..71b868be --- /dev/null +++ b/src/components/MediaPlayer/index.tsx @@ -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 + } + + return +} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index a1c25b75..a831970e 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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 = @@ -96,6 +99,8 @@ export default function Note({ ) + } else if (event.kind === ExtendedKind.VOICE || event.kind === ExtendedKind.VOICE_COMMENT) { + content = } else { content = } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index d75b5546..a5bd99da 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -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({ diff --git a/src/components/NotificationList/NotificationItem/index.tsx b/src/components/NotificationList/NotificationItem/index.tsx index 6ef697d9..8349da96 100644 --- a/src/components/NotificationList/NotificationItem/index.tsx +++ b/src/components/NotificationList/NotificationItem/index.tsx @@ -31,7 +31,10 @@ export function NotificationItem({ if (notification.kind === kinds.Zap) { return } - if (notification.kind === ExtendedKind.COMMENT) { + if ( + notification.kind === ExtendedKind.COMMENT || + notification.kind === ExtendedKind.VOICE_COMMENT + ) { return } if (notification.kind === ExtendedKind.POLL_RESPONSE) { diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index c5a3dacc..75a9d552 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -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]) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 3f90d30f..1f88bb7f 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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 }) } diff --git a/src/components/VideoPlayer/index.tsx b/src/components/VideoPlayer/index.tsx index 83a5959e..29f66ea2 100644 --- a/src/components/VideoPlayer/index.tsx +++ b/src/components/VideoPlayer/index.tsx @@ -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 /> diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 00000000..d4b549bb --- /dev/null +++ b/src/components/ui/slider.tsx @@ -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, + React.ComponentPropsWithoutRef & { hideThumb?: boolean } +>(({ className, ...props }, ref) => { + const [isHovered, setIsHovered] = React.useState(false) + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onTouchStart={() => setIsHovered(true)} + onTouchEnd={() => setIsHovered(false)} + > + + + + {/* */} + + ) +}) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/src/constants.ts b/src/constants.ts index 3f75943e..9e96d8f5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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}&.-/?=#\-@%+_:!~*]+(? { if (!arr.includes(item)) arr.push(item) diff --git a/src/lib/event.ts b/src/lib/event.ts index 09ed6e82..7a29fe02 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -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')) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 546122cf..1bea41bf 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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]) => { diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index 8cccfafc..8dccccb8 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -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, diff --git a/src/services/media-manager.service.ts b/src/services/media-manager.service.ts new file mode 100644 index 00000000..5e1b3fbf --- /dev/null +++ b/src/services/media-manager.service.ts @@ -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' +} diff --git a/src/services/video-manager.service.ts b/src/services/video-manager.service.ts deleted file mode 100644 index ab5f0bc0..00000000 --- a/src/services/video-manager.service.ts +++ /dev/null @@ -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' -}