Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions components/embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,75 @@ import styles from './text.module.css'
import { Button } from 'react-bootstrap'
import { TwitterTweetEmbed } from 'react-twitter-embed'
import YouTube from 'react-youtube'
import FileMusicLine from '@/svgs/file-music-line.svg'
import { registerAudioElement } from '@/lib/audio-manager'

function AudioEmbed ({ src, meta, className }) {
const [error, setError] = useState(false)
const audioRef = useRef(null)

const handleError = (e) => {
console.warn('Audio loading error:', e)
setError(true)
}
useEffect(() => {
if (!audioRef.current) return
return registerAudioElement(audioRef.current)
}, [audioRef.current])

if (error) {
return (
<div className={classNames(styles.audioWrapper, className)}>
<div style={{ padding: '1rem', textAlign: 'center' }}>
<p style={{ marginBottom: '0.5rem', color: 'var(--theme-color)' }}>
Unable to play this audio file.
</p>
<a
href={src}
target='_blank'
rel='noreferrer noopener'
style={{ color: 'var(--bs-primary)' }}
>
Download or open in new tab
</a>
</div>
</div>
)
}

return (
<div className={classNames(styles.audioWrapper, className)}>
{meta?.title && (
<div style={{
fontSize: '14px',
fontWeight: '500',
marginBottom: '8px',
color: 'var(--theme-color)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FileMusicLine width={16} height={16} style={{ flexShrink: 0 }} />
{meta.title}
</div>
)}
<audio
ref={audioRef}
controls
preload='none'
style={{ width: '100%' }}
onError={handleError}
>
<source src={src} type={`audio/${meta?.audioType || 'mpeg'}`} />
Your browser does not support the audio element.
</audio>
</div>
)
}

function TweetSkeleton ({ className }) {
return (
Expand Down Expand Up @@ -211,6 +280,10 @@ const Embed = memo(function Embed ({ src, provider, id, meta, className, topLeve
)
}

if (provider === 'audio') {
return <AudioEmbed src={src} meta={meta} className={className} />
}

return null
})

Expand Down
103 changes: 63 additions & 40 deletions components/media-or-link.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import styles from './text.module.css'
import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url'
import { useMe } from './me'
import { UNKNOWN_LINK_REL, PUBLIC_MEDIA_CHECK_URL } from '@/lib/constants'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import classNames from 'classnames'
import { useCarousel } from './carousel'
import { registerAudioElement } from '@/lib/audio-manager'

function LinkRaw ({ href, children, src, rel }) {
const isRawURL = /^https?:\/\//.test(children?.[0])
Expand All @@ -21,14 +22,18 @@ function LinkRaw ({ href, children, src, rel }) {

const Media = memo(function Media ({
src, bestResSrc, srcSet, sizes, width,
height, onClick, onError, style, className, video
height, onClick, onError, style, className, video, audio
}) {
const [loaded, setLoaded] = useState(!video)
const [loaded, setLoaded] = useState(!video && !audio)
const ref = useRef(null)

const handleLoadedMedia = () => {
setLoaded(true)
}
useEffect(() => {
if (!audio || !ref.current) return
return registerAudioElement(ref.current)
}, [audio, ref.current])

// events are not fired on elements during hydration
// https://github.com/facebook/react/issues/15446
Expand Down Expand Up @@ -57,17 +62,27 @@ const Media = memo(function Media ({
onError={onError}
onLoadedMetadata={handleLoadedMedia}
/>
: <img
ref={ref}
src={src}
srcSet={srcSet}
sizes={sizes}
width={width}
height={height}
onClick={onClick}
onError={onError}
onLoad={handleLoadedMedia}
/>}
: audio
? <audio
ref={ref}
src={src}
preload='metadata'
controls
onError={onError}
onLoadedMetadata={handleLoadedMedia}
style={{ width: '100%' }}
/>
: <img
ref={ref}
src={src}
srcSet={srcSet}
sizes={sizes}
width={width}
height={height}
onClick={onClick}
onError={onError}
onLoad={handleLoadedMedia}
/>}
</div>
)
})
Expand Down Expand Up @@ -101,7 +116,7 @@ export default function MediaOrLink ({ linkFallback = true, ...props }) {
if (!media.src) return null

if (!error) {
if (media.image || media.video) {
if (media.image || media.video || media.audio) {
return (
<Media
{...media} onClick={handleClick} onError={handleError}
Expand All @@ -124,39 +139,46 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
const { dimensions, video, format, ...srcSetObj } = srcSetIntital || {}
const [isImage, setIsImage] = useState(video === false && trusted)
const [isVideo, setIsVideo] = useState(video)
const [isAudio, setIsAudio] = useState(false)
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])

useEffect(() => {
// don't load the video at all if user doesn't want these
if (!showMedia || isVideo || isImage) return
if (!showMedia || isVideo || isAudio || isImage) return

const controller = new AbortController()

const checkMedia = async () => {
try {
const res = await fetch(`${PUBLIC_MEDIA_CHECK_URL}/${encodeURIComponent(src)}`, { signal: controller.signal })
if (!res.ok) return

const data = await res.json()

if (data.isVideo) {
setIsVideo(true)
setIsImage(false)
} else if (data.isImage) {
const video = document.createElement('video')
video.onloadedmetadata = () => {
setIsVideo(true)
setIsAudio(false)
setIsImage(false)
}
video.onerror = () => {
const audio = new window.Audio()
audio.onloadedmetadata = () => {
setIsAudio(true)
setIsVideo(false)
setIsImage(false)
}
audio.onerror = () => {
const img = new window.Image()
img.src = src
img.decode().then(() => {
setIsImage(true)
}
} catch (error) {
if (error.name === 'AbortError') return
console.error('cannot check media type', error)
setIsAudio(false)
setIsVideo(false)
}).catch((e) => {
console.warn('Cannot decode media:', src, e)
})
}
audio.src = src
}
checkMedia()
video.src = src

return () => {
// abort the fetch
try { controller.abort() } catch {}
video.onloadedmetadata = null
video.onerror = null
video.src = ''
}
}, [src, setIsImage, setIsVideo, showMedia])
}, [src, setIsImage, setIsVideo, setIsAudio, showMedia, isImage, isAudio])

const srcSet = useMemo(() => {
if (Object.keys(srcSetObj).length === 0) return undefined
Expand Down Expand Up @@ -205,7 +227,8 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
style,
width,
height,
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo,
video: !me?.privates?.imgproxyOnly && showMedia && isVideo
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo && !isAudio,
video: !me?.privates?.imgproxyOnly && showMedia && isVideo,
audio: !me?.privates?.imgproxyOnly && showMedia && isAudio
}
}
17 changes: 17 additions & 0 deletions lib/audio-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
let currentPlayingAudio = null
export const registerAudioElement = (audioElement) => {
if (!audioElement) return () => {}
const handlePlay = () => {
if (currentPlayingAudio && currentPlayingAudio !== audioElement) {
currentPlayingAudio.pause()
}
currentPlayingAudio = audioElement
}
audioElement.addEventListener('play', handlePlay)
return () => {
audioElement.removeEventListener('play', handlePlay)
if (currentPlayingAudio === audioElement) {
currentPlayingAudio = null
}
}
}
1 change: 1 addition & 0 deletions svgs/file-music-line.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.