diff --git a/components/embed.js b/components/embed.js index b336fc81ea..411f880884 100644 --- a/components/embed.js +++ b/components/embed.js @@ -195,7 +195,33 @@ const Embed = memo(function Embed ({ src, provider, id, meta, className, topLeve ) } - + if (provider === 'audio') { + return ( +
+ {meta?.title && ( +
+ 🎵 {meta.title} +
+ )} + +
+ ) + } if (provider === 'peertube') { return (
diff --git a/components/media-or-link.js b/components/media-or-link.js index c1fefdf22a..1e6e6054a9 100644 --- a/components/media-or-link.js +++ b/components/media-or-link.js @@ -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 = () => { @@ -45,29 +45,40 @@ const Media = memo(function Media ({ className={classNames(className, styles.mediaContainer, { [styles.loaded]: loaded })} style={style} > - {video - ?
) }) @@ -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 ( 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 @@ -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 @@ -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 } } diff --git a/components/text.module.css b/components/text.module.css index 586e255d27..947ab689d3 100644 --- a/components/text.module.css +++ b/components/text.module.css @@ -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%; @@ -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; } diff --git a/lib/constants.js b/lib/constants.js index fd5ecf81e0..68683ac7e5 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -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'] 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 diff --git a/lib/url.js b/lib/url.js index 2ead638660..60637ad19c 100644 --- a/lib/url.js +++ b/lib/url.js @@ -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]) + } + } + } + const nostr = href.match(/\/(?(?npub1|nevent1|nprofile1|note1|naddr1)[02-9ac-hj-np-z]+)/) if (nostr?.groups?.id) { let id = nostr.groups.id @@ -266,6 +279,9 @@ export function isMisleadingLink (text, href) { return misleading } +// Add after IMG_URL_REGEXP +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 diff --git a/pages/settings/index.js b/pages/settings/index.js index c22872344a..bdf4287173 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -492,7 +492,7 @@ export default function Settings ({ ssrData }) {
show images, video, and 3rd party embeds
    -
  • if checked and a link is an image, video or can be embedded in another way, we will do it
  • +
  • if checked and a link is an image, video, audio or can be embedded in another way, we will do it
  • we support embeds from following sites:
    • njump.me
    • @@ -503,6 +503,7 @@ export default function Settings ({ ssrData }) {
    • wavlake.com
    • bitcointv.com
    • peertube.tv
    • +
    • direct audio files (.mp3, .wav, .ogg, .flac, .aac, .m4a, .opus)