diff --git a/package-lock.json b/package-lock.json index 52e42b68..714ac317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", "katex": "^0.16.25", + "lamejs": "^1.2.1", "lru-cache": "^11.0.2", "lucide-react": "^0.469.0", "marked": "^17.0.5", @@ -10955,6 +10956,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/lamejs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/lamejs/-/lamejs-1.2.1.tgz", + "integrity": "sha512-s7bxvjvYthw6oPLCm5pFxvA84wUROODB8jEO2+CE1adhKgrIvVOlmMgY8zyugxGrvRaDHNJanOiS21/emty6dQ==", + "license": "LGPL-3.0", + "dependencies": { + "use-strict": "1.0.1" + } + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", @@ -15129,6 +15139,12 @@ } } }, + "node_modules/use-strict": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/use-strict/-/use-strict-1.0.1.tgz", + "integrity": "sha512-IeiWvvEXfW5ltKVMkxq6FvNf2LojMKvB2OCeja6+ct24S1XOmQw2dGr2JyndwACWAGJva9B7yPHwAmeA9QCqAQ==", + "license": "ISC" + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/package.json b/package.json index 8e54058b..7775fd34 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.4", "katex": "^0.16.25", + "lamejs": "^1.2.1", "lru-cache": "^11.0.2", "lucide-react": "^0.469.0", "marked": "^17.0.5", diff --git a/src/components/MediaPlayer/index.tsx b/src/components/MediaPlayer/index.tsx index 2732d432..a8879476 100644 --- a/src/components/MediaPlayer/index.tsx +++ b/src/components/MediaPlayer/index.tsx @@ -42,11 +42,20 @@ export default function MediaPlayer({ const url = new URL(src) const extension = url.pathname.split('.').pop()?.toLowerCase() - if (extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma'].includes(extension)) { + if ( + extension && + ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mka'].includes(extension) + ) { setMediaType('audio') return } + // Matroska is video-first for feeds; avoids waiting on metadata probe (codec support still browser-dependent). + if (extension === 'mkv') { + setMediaType('video') + return + } + const video = document.createElement('video') video.src = src video.preload = 'metadata' diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index d4bbd705..93bcede8 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -72,6 +72,7 @@ import { Film, Laugh } from 'lucide-react' +import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media' import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' @@ -99,6 +100,7 @@ import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import EmojiPickerDialog from '../EmojiPickerDialog' import GifPicker from '../GifPicker' @@ -1304,6 +1306,95 @@ export default function PostContent({ uploadedMediaFileMap.current.clear() } + const inferKindFromEditorMediaUrl = (url: string): number | null => { + const path = url.split(/[?#]/)[0].toLowerCase() + if (/\.(jpg|jpeg|png|gif|webp|heic|avif|apng)$/i.test(path)) return ExtendedKind.PICTURE + if (/\.(mp3|m4a|mka|ogg|opus|wav|aac|flac)$/i.test(path)) return ExtendedKind.VOICE + if (/\.(mp4|webm|mov|mkv|m4v|ogv|avi|mpeg|mpg)$/i.test(path)) return ExtendedKind.SHORT_VIDEO + return null + } + + const mimeFromUrlPathForKind = (url: string, kind: number): string => { + const path = url.split(/[?#]/)[0].toLowerCase() + if (kind === ExtendedKind.PICTURE) { + if (path.endsWith('.png')) return 'image/png' + if (path.endsWith('.webp')) return 'image/webp' + if (path.endsWith('.gif')) return 'image/gif' + return 'image/jpeg' + } + if (kind === ExtendedKind.VOICE) { + if (path.endsWith('.mka')) return 'audio/x-matroska' + if (path.endsWith('.ogg')) return 'audio/ogg' + if (path.endsWith('.webm')) return 'audio/webm' + return 'audio/mpeg' + } + if (path.endsWith('.mkv')) return 'video/x-matroska' + if (path.endsWith('.webm')) return 'video/webm' + return 'video/mp4' + } + + const textLooksLikeImetaWithUrl = (s: string): boolean => + /\bimeta\b[\s\S]{0,400}?\burl\s+https?:\/\//i.test(s) + + const firstHttpUrlInNoteText = (s: string): string | undefined => { + const m = s.match(/https?:\/\/[^\s<>\])}'"]+/) + return m?.[0] + } + + const canUseMediaKindFromUrlButton = useMemo(() => { + if (parentEvent || isDiscussionThread || isPublicMessage) return false + if (mediaNoteKind !== null && mediaUrl) return false + if (mediaImetaTags.length > 0) return true + if (mediaUrl) return true + if (textLooksLikeImetaWithUrl(text)) return true + const u = firstHttpUrlInNoteText(text) + return !!(u && inferKindFromEditorMediaUrl(u) !== null) + }, [ + parentEvent, + isDiscussionThread, + isPublicMessage, + mediaNoteKind, + mediaUrl, + mediaImetaTags, + text + ]) + + /** When the editor already contains a media URL (e.g. after drop/paste) but kind stayed 1. */ + const handleUseMediaNoteKindFromUrl = () => { + if (parentEvent || isDiscussionThread || isPublicMessage) return + if (mediaNoteKind !== null && mediaUrl) { + toast.info(t('Already publishing as a media note')) + return + } + const raw = textareaRef.current?.getText() ?? text + const m = raw.match(/https?:\/\/[^\s<>\])}'"]+/) + const found = m?.[0] + if (!found) { + toast.info(t('No media URL in note — upload or paste a link first')) + return + } + const kind = inferKindFromEditorMediaUrl(found) + if (kind === null) { + toast.info(t('Cannot infer media type from URL — use Note type → Media Note to upload')) + return + } + setIsPoll(false) + setIsHighlight(false) + setIsLongFormArticle(false) + setIsWikiArticle(false) + setIsWikiArticleMarkdown(false) + setIsPublicationContent(false) + setIsCitationInternal(false) + setIsCitationExternal(false) + setIsCitationHardcopy(false) + setIsCitationPrompt(false) + setIsDiscussionThread(false) + setMediaUrl(found) + setMediaNoteKind(kind) + const mime = mimeFromUrlPathForKind(found, kind) + setMediaImetaTags([['imeta', `url ${found}`, `m ${mime}`]]) + } + const isPlainShortNoteToolbar = useMemo( () => !parentEvent && @@ -1402,7 +1493,7 @@ export default function PostContent({ const handleUploadStart = (file: File, cancel: () => void) => { setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }]) // Track file for media upload - if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type.startsWith('video/')) { + if (fileLooksLikeUploadableMedia(file)) { const mapKey = `${file.name}-${file.size}-${file.lastModified}` uploadedMediaFileMap.current.set(mapKey, file) @@ -1412,7 +1503,7 @@ export default function PostContent({ const fileName = file.name.toLowerCase() // Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4 const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg' - const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName) + const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName) // For replies/PMs, webm/ogg/mp3/m4a files should be treated as audio since the microphone button only accepts audio/* // Even if the MIME type is incorrect, if it came through the audio uploader, it's audio const isWebmFile = /\.webm$/i.test(fileName) @@ -1593,6 +1684,8 @@ export default function PostContent({ const fileName = uploadingFile.name.toLowerCase() if (/\.webm$/i.test(fileName)) { mimeType = 'audio/webm' + } else if (/\.mka$/i.test(fileName)) { + mimeType = 'audio/x-matroska' } else if (/\.mp4$/i.test(fileName)) { mimeType = 'audio/mp4' } @@ -1601,6 +1694,8 @@ export default function PostContent({ const fileName = uploadingFile.name.toLowerCase() if (/\.webm$/i.test(fileName)) { mimeType = 'video/webm' + } else if (/\.mkv$/i.test(fileName)) { + mimeType = 'video/x-matroska' } else if (/\.mp4$/i.test(fileName)) { mimeType = 'video/mp4' } @@ -1692,7 +1787,7 @@ export default function PostContent({ // For replies/PMs, webm/ogg/mp3 files should be treated as audio since the microphone button only accepts audio/* // Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4 const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg' - const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName) + const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName) // m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this) const isM4aFile = /\.m4a$/i.test(fileName) const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime @@ -1732,6 +1827,8 @@ export default function PostContent({ if (/\.m4a$/i.test(fileName)) { // m4a files are always audio, use audio/mp4 or audio/x-m4a mimeType = 'audio/mp4' + } else if (/\.mka$/i.test(fileName)) { + mimeType = 'audio/x-matroska' } else if (/\.webm$/i.test(fileName) && !mimeType.startsWith('audio/')) { mimeType = 'audio/webm' } else if (/\.ogg$/i.test(fileName) && !mimeType.startsWith('audio/')) { @@ -2665,6 +2762,7 @@ export default function PostContent({ onUploadStart={handleUploadStart} onUploadProgress={handleUploadProgress} onUploadEnd={handleUploadEnd} + onUploadSuccess={handleMediaUploadSuccess} kind={getDeterminedKind} highlightData={isHighlight ? highlightData : undefined} pollCreateData={isPoll ? pollCreateData : undefined} @@ -2705,6 +2803,23 @@ export default function PostContent({ mediaNoteKind !== null ? t('Media Note') : t('Short Note') return ( +
+
) })() : undefined } @@ -2962,7 +3078,7 @@ export default function PostContent({ onUploadStart={handleUploadStart} onUploadEnd={handleUploadEnd} onProgress={handleUploadProgress} - accept="image/*,audio/*,video/*" + accept="image/*,audio/*,video/*,.mkv,.mka,video/x-matroska,audio/x-matroska" className="sr-only" >