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
28 changes: 27 additions & 1 deletion components/embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,33 @@ const Embed = memo(function Embed ({ src, provider, id, meta, className, topLeve
</div>
)
}

if (provider === 'audio') {
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'
}}
>
🎵 {meta.title}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is "notes" the best graphic for representing audio? lots of audio might not be music, strictly speaking...

</div>
)}
<audio controls preload='metadata' style={{ width: '100%' }} src={src}>
<source src={src} type={`audio/${meta?.audioType || 'mpeg'}`} />
Your browser does not support the audio element.
<a href={src} target='_blank' rel='noreferrer'>
Download audio file
</a>
</audio>
</div>
)
}
if (provider === 'peertube') {
return (
<div className={classNames(styles.videoWrapper, className)}>
Expand Down
84 changes: 51 additions & 33 deletions components/media-or-link.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ 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 = () => {
Expand All @@ -45,29 +45,40 @@ const Media = memo(function Media ({
className={classNames(className, styles.mediaContainer, { [styles.loaded]: loaded })}
style={style}
>
{video
? <video
{audio
? <audio
ref={ref}
src={src}
preload={bestResSrc !== src ? 'metadata' : undefined}
controls
poster={bestResSrc !== src ? bestResSrc : undefined}
preload='metadata'
width={width}
height={height}
onError={onError}
onLoadedMetadata={handleLoadedMedia}
/>
: <img
ref={ref}
src={src}
srcSet={srcSet}
sizes={sizes}
width={width}
height={height}
onClick={onClick}
onError={onError}
onLoad={handleLoadedMedia}
/>}
: video
? <video
ref={ref}
src={src}
preload={bestResSrc !== src ? 'metadata' : undefined}
controls
poster={bestResSrc !== src ? bestResSrc : undefined}
width={width}
height={height}
onError={onError}
onLoadedMetadata={handleLoadedMedia}
/>
: <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 +112,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,28 +135,34 @@ 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 || isImage || isAudio) return

// check if it's a video by trying to load it
const video = document.createElement('video')
video.onloadedmetadata = () => {
setIsVideo(true)
setIsImage(false)
}
video.onerror = () => {
// hack
// if it's not a video it will throw an error, so we can assume it's an image
const img = new window.Image()
img.src = src
img.decode().then(() => { // decoding beforehand to prevent wrong image cropping
setIsImage(true)
}).catch((e) => {
console.warn('Cannot decode image:', src, e)
})
const audio = document.createElement('audio')
audio.onloadedmetadata = () => {
setIsAudio(true)
setIsImage(false)
setIsVideo(false)
}
audio.onerror = () => {
const img = new window.Image()
img.src = src
img.decode().then(() => {
setIsImage(true)
}).catch((e) => {
console.warn('Cannot decode image:', src, e)
})
}
audio.src = src
}
video.src = src

Expand All @@ -154,7 +171,7 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
video.onerror = null
video.src = ''
}
}, [src, setIsImage, setIsVideo, showMedia, isImage])
}, [src, setIsImage, setIsVideo, setIsAudio, showMedia, isImage, isAudio])

const srcSet = useMemo(() => {
if (Object.keys(srcSetObj).length === 0) return undefined
Expand Down Expand Up @@ -203,7 +220,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: showMedia && isAudio
}
}
63 changes: 61 additions & 2 deletions components/text.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@
margin-top: .25rem;
}

.text li > :is(.twitterContainer, .nostrContainer, .wavlakeWrapper, .spotifyWrapper, .onlyImages) {
.text li > :is(.twitterContainer, .nostrContainer, .wavlakeWrapper, .spotifyWrapper, .audioWrapper, .onlyImages) {
display: inline-flex;
vertical-align: top;
width: 100%;
Expand Down Expand Up @@ -319,12 +319,71 @@
font-size: smaller;
}

.twitterContainer, .nostrContainer, .videoWrapper, .wavlakeWrapper, .spotifyWrapper {
.twitterContainer, .nostrContainer, .videoWrapper, .wavlakeWrapper, .spotifyWrapper, .audioWrapper {
margin-top: calc(var(--grid-gap) * 0.5);
margin-bottom: calc(var(--grid-gap) * 0.5);
background-color: var(--theme-bg);
}

.audioWrapper {
width: 100%;
max-width: 500px;
padding: 0.75rem;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--theme-border);
background: var(--theme-bg);
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
margin: 0.5rem 0 !important;
transition: box-shadow 0.2s ease;
}

.audioWrapper:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}

.audioWrapper audio {
width: 100%;
height: 40px;
background: transparent;
border-radius: 8px;
outline: none;
}

.audioWrapper audio::-webkit-media-controls-panel {
background-color: var(--theme-bg);
border-radius: 6px;
}

.audioWrapper audio::-webkit-media-controls-play-button,
.audioWrapper audio::-webkit-media-controls-pause-button {
background-color: var(--bs-primary);
border-radius: 50%;
margin-right: 6px;
width: 32px;
height: 32px;
}

.audioWrapper audio::-webkit-media-controls-timeline {
background-color: var(--theme-border);
border-radius: 3px;
margin: 0 6px;
height: 4px;
}

.audioWrapper audio::-webkit-media-controls-current-time-display,
.audioWrapper audio::-webkit-media-controls-time-remaining-display {
color: var(--theme-color);
font-size: 11px;
font-family: monospace;
}

.topLevel .audioWrapper, :global(.topLevel) .audioWrapper {
max-width: 600px;
margin: 0.75rem 0 !important;
padding: 1rem;
}

.videoWrapper {
max-width: 320px;
}
Expand Down
9 changes: 8 additions & 1 deletion lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,15 @@ export const UPLOAD_TYPES_ALLOW = [
'video/quicktime',
'video/mp4',
'video/mpeg',
'video/webm'
'video/webm',
'audio/mpeg',
'audio/wav',
'audio/ogg',
'audio/mp4',
'audio/aac',
'audio/flac'
]
export const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'opus']
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for some folks, the above lines might not be obviously fine; however, line 44 is about file types, while 37-42 are for MIME types, and there isn't any one-to-one mapping between categories of types.

export const AVATAR_TYPES_ALLOW = UPLOAD_TYPES_ALLOW.filter(t => t.startsWith('image/'))
export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE', 'BOOST']
export const BOUNTY_MIN = 1000
Expand Down
18 changes: 17 additions & 1 deletion lib/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,20 @@ export function parseEmbedUrl (href) {

const { hostname, pathname, searchParams } = new URL(href)

// nostr prefixes: [npub1, nevent1, nprofile1, note1]
const audioExtensions = /\.(mp3|wav|ogg|flac|aac|m4a|opus|webm)(\?.*)?$/i
if (pathname && audioExtensions.test(pathname)) {
const extension = pathname.match(audioExtensions)[1].toLowerCase()
return {
provider: 'audio',
id: null,
meta: {
href,
audioType: extension,
title: decodeURIComponent(pathname.split('/').pop().split('.')[0])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there no better guess for the reasonable text label of uncaptioned audio? I'm thinking, some combination of comment author, context [i.e. SN item ID], and filename only if it's not some hash or blob identifier, otherwise some reasonable timestamp.

}
}
}

const nostr = href.match(/\/(?<id>(?<type>npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)/)
if (nostr?.groups?.id) {
let id = nostr.groups.id
Expand Down Expand Up @@ -266,6 +279,9 @@ export function isMisleadingLink (text, href) {
return misleading
}

// Add after IMG_URL_REGEXP
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the above comment is forensically interesting [i.e., "look, Copilot is talking to the operator"], and probably does not belong in the production codebase.

export const AUDIO_URL_REGEXP = /^(https?:\/\/.*\.(?:mp3|wav|ogg|flac|aac|m4a))$/i

// eslint-disable-next-line
export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i

Expand Down
3 changes: 2 additions & 1 deletion pages/settings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ export default function Settings ({ ssrData }) {
<div className='d-flex align-items-center'>show images, video, and 3rd party embeds
<Info>
<ul>
<li>if checked and a link is an image, video or can be embedded in another way, we will do it</li>
<li>if checked and a link is an image, video, audio or can be embedded in another way, we will do it</li>
<li>we support embeds from following sites:</li>
<ul>
<li>njump.me</li>
Expand All @@ -503,6 +503,7 @@ export default function Settings ({ ssrData }) {
<li>wavlake.com</li>
<li>bitcointv.com</li>
<li>peertube.tv</li>
<li>direct audio files (.mp3, .wav, .ogg, .flac, .aac, .m4a, .opus)</li>
</ul>
</ul>
</Info>
Expand Down