From 955948a9bbac94b12bbd7a6b57134060a82ca5ac Mon Sep 17 00:00:00 2001 From: dergigi Date: Fri, 19 Jun 2026 09:15:30 +0200 Subject: [PATCH 1/2] feat: render audio players for linked note audio --- src/components/AudioPlayer.tsx | 55 ++++++++++++++++++++++++++++ src/components/NoteMedia.tsx | 13 ++++++- src/components/VideoWithBlurhash.tsx | 14 ++++++- src/lib/utils/mediaUtils.ts | 9 ++++- src/lib/utils/textUtils.ts | 7 ++-- src/lib/utils/urlUtils.ts | 27 +++++++++++--- 6 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 src/components/AudioPlayer.tsx diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx new file mode 100644 index 00000000..2bda15c6 --- /dev/null +++ b/src/components/AudioPlayer.tsx @@ -0,0 +1,55 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faExternalLink, faMagnifyingGlass, faMusic } from '@fortawesome/free-solid-svg-icons'; +import { isAbsoluteHttpUrl } from '@/lib/utils/urlUtils'; + +interface AudioPlayerProps { + src: string; + onClickSearch?: () => void; +} + +export default function AudioPlayer({ src, onClickSearch }: AudioPlayerProps) { + if (!isAbsoluteHttpUrl(src)) { + return null; + } + + return ( +
+
+ + + Audio + +
+ {onClickSearch ? ( + + ) : null} + +
+
+ +
+ ); +} diff --git a/src/components/NoteMedia.tsx b/src/components/NoteMedia.tsx index 93f90a51..f0740658 100644 --- a/src/components/NoteMedia.tsx +++ b/src/components/NoteMedia.tsx @@ -3,6 +3,7 @@ import { extractMediaFromContent, getSearchQueryFromMedia, isValidMediaUrl, getTrimmedMediaUrl } from '@/lib/utils/mediaUtils'; import ImageWithBlurhash from '@/components/ImageWithBlurhash'; import VideoWithBlurhash from '@/components/VideoWithBlurhash'; +import AudioPlayer from '@/components/AudioPlayer'; import UrlPreview from '@/components/UrlPreview'; interface NoteMediaProps { @@ -45,12 +46,22 @@ export default function NoteMedia({ content, onSearch, onUrlLoaded }: NoteMediaP className="relative w-full overflow-hidden rounded-md border border-[#3d3d3d] bg-black" > onSearch(getSearchQueryFromMedia(item.src))} /> ); } + + if (item.type === 'audio') { + return ( + onSearch(getSearchQueryFromMedia(item.src))} + /> + ); + } if (item.type === 'url') { return ( diff --git a/src/components/VideoWithBlurhash.tsx b/src/components/VideoWithBlurhash.tsx index a0e12e70..d63e9837 100644 --- a/src/components/VideoWithBlurhash.tsx +++ b/src/components/VideoWithBlurhash.tsx @@ -2,9 +2,10 @@ import { useState, useEffect, useRef } from 'react'; import { Blurhash } from 'react-blurhash'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faImage, faExternalLink, faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; -import { isAbsoluteHttpUrl } from '@/lib/utils/urlUtils'; +import { isAbsoluteHttpUrl, isAmbiguousAudioVideoUrl } from '@/lib/utils/urlUtils'; import SearchIconButton from './SearchIconButton'; import ReverseImageSearchButton from './ReverseImageSearchButton'; +import AudioPlayer from './AudioPlayer'; interface VideoWithBlurhashProps { src: string; @@ -24,6 +25,7 @@ export default function VideoWithBlurhash({ const [statusCode, setStatusCode] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [showControls, setShowControls] = useState(false); + const [renderAsAudio, setRenderAsAudio] = useState(false); const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(() => { if (dim && dim.width > 0 && dim.height > 0) { return dim; @@ -31,12 +33,14 @@ export default function VideoWithBlurhash({ return null; }); const videoRef = useRef(null); + const allowAudioFallback = isAmbiguousAudioVideoUrl(src); useEffect(() => { setVideoLoaded(false); setVideoError(false); setIsPlaying(false); setShowControls(false); + setRenderAsAudio(false); if (dim && dim.width > 0 && dim.height > 0) { setDimensions(dim); } @@ -70,6 +74,10 @@ export default function VideoWithBlurhash({ return null; } + if (renderAsAudio) { + return ; + } + const aspectStyle = dimensions && dimensions.width > 0 && dimensions.height > 0 ? { aspectRatio: `${dimensions.width} / ${dimensions.height}` } : { minHeight: '200px' as const }; @@ -133,6 +141,10 @@ export default function VideoWithBlurhash({ }`} onLoadedMetadata={(event) => { const element = event.currentTarget; + if (allowAudioFallback && (!element.videoWidth || !element.videoHeight)) { + setRenderAsAudio(true); + return; + } if (element?.videoWidth && element?.videoHeight) { setDimensions({ width: element.videoWidth, height: element.videoHeight }); } diff --git a/src/lib/utils/mediaUtils.ts b/src/lib/utils/mediaUtils.ts index 5b193b0b..f3e3982d 100644 --- a/src/lib/utils/mediaUtils.ts +++ b/src/lib/utils/mediaUtils.ts @@ -1,9 +1,9 @@ -import { extractImageUrls, extractVideoUrls, extractNonMediaUrls } from '@/lib/utils/urlUtils'; +import { extractAudioUrls, extractImageUrls, extractVideoUrls, extractNonMediaUrls } from '@/lib/utils/urlUtils'; import { isAbsoluteHttpUrl, getFilenameFromUrl } from '@/lib/utils/urlUtils'; import { trimImageUrl } from '@/lib/utils'; export interface MediaItem { - type: 'image' | 'video' | 'url'; + type: 'image' | 'video' | 'audio' | 'url'; src: string; index: number; } @@ -21,6 +21,11 @@ export const extractMediaFromContent = (content: string): MediaItem[] => { items.push({ type: 'video', src: src.trim(), index }); }); + // Extract audio links (limit to 2) + extractAudioUrls(content).slice(0, 2).forEach((src, index) => { + items.push({ type: 'audio', src: src.trim(), index }); + }); + // Extract non-media URLs (limit to 2) extractNonMediaUrls(content).slice(0, 2).forEach((src, index) => { items.push({ type: 'url', src: src.trim(), index }); diff --git a/src/lib/utils/textUtils.ts b/src/lib/utils/textUtils.ts index d76eae58..d1dddf72 100644 --- a/src/lib/utils/textUtils.ts +++ b/src/lib/utils/textUtils.ts @@ -11,16 +11,17 @@ export const normalizeWhitespace = (text: string): string => { }; /** - * Strips media URLs (images and videos) from text content + * Strips media URLs (images, videos, and audio links) from text content * Removes various media file extensions and query parameters */ export const stripMediaUrls = (text: string): string => { if (!text) return ''; const cleaned = text .replace(/(https?:\/\/[^\s'"<>]+?\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg))(?:[?#][^\s]*)?/gi, '') + .replace(/(https?:\/\/[^\s'"<>]+?\.(?:m4a|mp3|wav|flac|aac|opus))(?:[?#][^\s]*)?/gi, '') .replace(/(https?:\/\/[^\s'"<>]+?\.(?:mp4|webm|ogg|ogv|mov|m4v))(?:[?#][^\s]*)?/gi, '') - .replace(/\?[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '') - .replace(/\?name=[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, ''); + .replace(/\?[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '') + .replace(/\?name=[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, ''); return normalizeWhitespace(cleaned); }; diff --git a/src/lib/utils/urlUtils.ts b/src/lib/utils/urlUtils.ts index 0309f870..361bd754 100644 --- a/src/lib/utils/urlUtils.ts +++ b/src/lib/utils/urlUtils.ts @@ -256,23 +256,42 @@ function extractUrlsBase(text: string, filterFn?: (url: string) => boolean): str return urls; } +const imageExtRegex = /\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg)(?:$|[?#])/i; +const audioExtRegex = /\.(?:m4a|mp3|wav|flac|aac|opus)(?:$|[?#])/i; +const ambiguousAudioVideoExtRegex = /\.(?:ogg|webm)(?:$|[?#])/i; +const videoExtRegex = /\.(?:mp4|webm|ogg|ogv|mov|m4v)(?:$|[?#])/i; + /** * Extract image URLs from text content * @param text - The text to search for image URLs * @returns Array of image URLs found */ export function extractImageUrls(text: string): string[] { - const imageExtRegex = /\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg)(?:$|[?#])/i; return extractUrlsBase(text, (url) => imageExtRegex.test(url)); } +/** + * Extract audio URLs from text content + * @param text - The text to search for audio URLs + * @returns Array of audio URLs found + */ +export function extractAudioUrls(text: string): string[] { + return extractUrlsBase(text, (url) => audioExtRegex.test(url)); +} + +/** + * Check if a media URL could be either audio or video based on its extension + */ +export function isAmbiguousAudioVideoUrl(url: string): boolean { + return ambiguousAudioVideoExtRegex.test(url); +} + /** * Extract video URLs from text content * @param text - The text to search for video URLs * @returns Array of video URLs found */ export function extractVideoUrls(text: string): string[] { - const videoExtRegex = /\.(?:mp4|webm|ogg|ogv|mov|m4v)(?:$|[?#])/i; return extractUrlsBase(text, (url) => videoExtRegex.test(url)); } @@ -282,9 +301,7 @@ export function extractVideoUrls(text: string): string[] { * @returns Array of non-media URLs found */ export function extractNonMediaUrls(text: string): string[] { - const imageExtRegex = /\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg)(?:$|[?#])/i; - const videoExtRegex = /\.(?:mp4|webm|ogg|ogv|mov|m4v)(?:$|[?#])/i; - return extractUrlsBase(text, (url) => !imageExtRegex.test(url) && !videoExtRegex.test(url)); + return extractUrlsBase(text, (url) => !imageExtRegex.test(url) && !audioExtRegex.test(url) && !videoExtRegex.test(url)); } /** From 8078f0fff69f09789a1c6b1ee3073cb7178427be Mon Sep 17 00:00:00 2001 From: dergigi Date: Fri, 19 Jun 2026 10:40:38 +0200 Subject: [PATCH 2/2] fix: show audio file extensions in player header --- src/components/AudioPlayer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx index 2bda15c6..5c9cf678 100644 --- a/src/components/AudioPlayer.tsx +++ b/src/components/AudioPlayer.tsx @@ -1,6 +1,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faExternalLink, faMagnifyingGlass, faMusic } from '@fortawesome/free-solid-svg-icons'; -import { isAbsoluteHttpUrl } from '@/lib/utils/urlUtils'; +import { getFilenameFromUrl, isAbsoluteHttpUrl } from '@/lib/utils/urlUtils'; interface AudioPlayerProps { src: string; @@ -12,12 +12,14 @@ export default function AudioPlayer({ src, onClickSearch }: AudioPlayerProps) { return null; } + const extension = getFilenameFromUrl(src).match(/\.([a-z0-9]+)$/i)?.[1]?.toUpperCase(); + return (
- Audio + {extension ? {extension} : null}
{onClickSearch ? (