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 (
+
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 @@
+