diff --git a/components/embed.js b/components/embed.js index b336fc81ea..09f7d26909 100644 --- a/components/embed.js +++ b/components/embed.js @@ -5,6 +5,70 @@ 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' + +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) + } + + if (error) { + return ( +
+
+

+ Unable to play this audio file. +

+ + Download or open in new tab + +
+
+ ) + } + + return ( +
+ {meta?.title && ( +
+ + {meta.title} +
+ )} + +
+ ) +} function TweetSkeleton ({ className }) { return ( @@ -195,7 +259,9 @@ const Embed = memo(function Embed ({ src, provider, id, meta, className, topLeve ) } - + if (provider === 'audio') { + return + } if (provider === 'peertube') { return (
diff --git a/components/media-or-link.js b/components/media-or-link.js index c1fefdf22a..d56cb9674a 100644 --- a/components/media-or-link.js +++ b/components/media-or-link.js @@ -127,21 +127,17 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => 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 - // 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 + img.decode().then(() => { setIsImage(true) }).catch((e) => { console.warn('Cannot decode image:', src, e) 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 b151dab3ef..d757befbce 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -33,7 +33,13 @@ 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 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'] diff --git a/lib/url.js b/lib/url.js index 2ead638660..4bee1ee8be 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 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)
diff --git a/svgs/file-music-line.svg b/svgs/file-music-line.svg new file mode 100644 index 0000000000..86516770ea --- /dev/null +++ b/svgs/file-music-line.svg @@ -0,0 +1 @@ +