From 25b910ca99efa46c60896c1e182001cc9d017c41 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 5 Oct 2026 08:30:51 -0700 Subject: Audio links (mp3/m4a/ogg/opus/wav/flac/aac) render as a player, like Amethyst Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi --- src/features/messages.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 3bd9ac2..801ad28 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5545,6 +5545,7 @@ export function messagesFeature(ctx) { // moov-first file — with a short cap, since it gates the row. Remembered // on disk so a clip seen once is never probed again. const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i; + const AUDIO_RE = /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac)(\?[^\s]*)?$/i; const VIDEO_PROBE_MS = 3000, VIDEO_DIMS_KEEP = 400, VIDEO_DIMS_KEY = 'coinos-video-dims'; const videoDims = new Map(); // url -> { width, height } const videoUnknown = new Set(); // probed this session and no answer: paint 16:9, don't ask again @@ -6887,6 +6888,7 @@ export function messagesFeature(ctx) { if (VIDEO_RE.test(url)) { return videoNode(url, { stable: !!feedPaint }); } + if (AUDIO_RE.test(url)) return audioNode(url, label); if (isImage || /\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(url)) { const size = feedPaint ? feedMedia(url) : null; if (feedPaint && !size) return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label || url); @@ -6918,6 +6920,29 @@ export function messagesFeature(ctx) { return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, shown); } + // An audio file (a podcast episode on Blossom, a voice note) as a player, + // the way Amethyst shows one. Nothing is fetched until play; once playing + // the box is the listener's and no repaint touches it. + function audioNode(url, label) { + let name = label; + if (!name) { + try { + const u = new URL(url); + const file = decodeURIComponent(u.pathname.split('/').pop() || ''); + // a Blossom name is the file's hash: the host says more than that does + name = /^[0-9a-f]{64}\./i.test(file) ? u.hostname : file || u.hostname; + } catch { name = url; } + } + return h('div', { class: 'note-audio', onClick: (e) => e.stopPropagation() }, + h('div', { class: 'note-audio-head' }, + h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'), + h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)), + h('audio', { + controls: true, preload: 'none', src: url, + onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; }, + })); + } + // ---- encrypted attachments (NIP-92 imeta + NIP-17 file fields) ---- // A picture in a Concord channel never reaches the media server in the // clear: the sender encrypts it under a one-off key, uploads the blob to -- cgit v1.2.3