Download apps/desktop/src/plugins/radio/plugin.js from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 38.4 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/desktop/src/plugins/radio/plugin.js
- Command line
-
hf download hf://SaylorTwift/hermes-agent/apps/desktop/src/plugins/radio/plugin.js
-
curl -L -o plugin.js https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/desktop/src/plugins/radio/plugin.js
38.4 kB
| import { | |
| atom, Button, createBudgetedLoop, GlyphSpinner, icons, Popover, PopoverContent, PopoverTrigger, | |
| RowButton, SearchField, STATUSBAR_AREAS, Tip, usePluginI18n, | |
| useQuery, useValue | |
| } from '@hermes/plugin-sdk' | |
| import { useEffect, useRef, useState } from 'react' | |
| import { jsx, jsxs } from 'react/jsx-runtime' | |
| const ID = 'radio' | |
| const NIGHTRIDE = 'https://stream.nightride.fm/' | |
| const PRESETS = [ | |
| { id: 'chillsynth', name: 'Chillsynth', description: 'Soft focus · warm synths', provider: 'Nightride FM', url: `${NIGHTRIDE}chillsynth.mp3`, homepage: 'https://nightride.fm/?station=chillsynth' }, | |
| { id: 'nightride', name: 'Nightride', description: 'Synthwave · after hours', provider: 'Nightride FM', url: `${NIGHTRIDE}nightride.mp3`, homepage: 'https://nightride.fm/' }, | |
| { id: 'darksynth', name: 'Darksynth', description: 'Dark electronics · high energy', provider: 'Nightride FM', url: `${NIGHTRIDE}darksynth.mp3`, homepage: 'https://nightride.fm/?station=darksynth' }, | |
| { id: 'spacesynth', name: 'Spacesynth', description: 'Cosmic synths · retro futures', provider: 'Nightride FM', url: `${NIGHTRIDE}spacesynth.mp3`, homepage: 'https://nightride.fm/?station=spacesynth' }, | |
| { id: 'paradise-main', name: 'Main Mix', description: 'Eclectic · human selected', provider: 'Radio Paradise', url: 'https://stream.radioparadise.com/aac-128', homepage: 'https://radioparadise.com/' }, | |
| { id: 'paradise-mellow', name: 'Mellow Mix', description: 'A gentler pace', provider: 'Radio Paradise', url: 'https://stream.radioparadise.com/mellow-flac', homepage: 'https://radioparadise.com/' }, | |
| { id: 'eve-radio', name: 'EVE Radio', description: 'GamingNow · EVE community radio', provider: 'GamingNow', url: 'https://media01.gamingnow.net:8010/erweb.mp3', homepage: 'https://gamingnow.net/eve-radio/' } | |
| ] | |
| const EN = { | |
| radio: 'Radio', browse: 'Choose a station', play: 'Play radio', pause: 'Pause radio', next: 'Next station', | |
| live: 'Live', paused: 'Paused', connecting: 'Connecting', error: 'Stream unavailable', retry: 'Try again', | |
| search: 'Find a station…', volume: 'Volume', audioOnly: 'Audio playing · visualizer unavailable', | |
| mute: 'Mute radio', unmute: 'Unmute radio', save: 'Pin station', unsave: 'Unpin station', | |
| visit: 'Visit station website', artistProfile: 'Visit artist profile', | |
| noResults: 'No stations found', searchHint: 'Search by station name. Try jazz, ambient, or house.', | |
| searchError: 'Station search is unavailable. Your presets still work.', searching: 'Searching stations', | |
| directory: 'Search powered by Radio Browser', note: 'Live radio · next switches stations', | |
| streamError: 'This stream could not connect. Try again or choose another station.', | |
| close: 'Close radio', nowPlaying: 'Now playing', elsewhere: 'Paused — playing in another window', | |
| 'chillsynthDescription': 'Soft focus · warm synths', 'nightrideDescription': 'Synthwave · after hours', | |
| 'darksynthDescription': 'Dark electronics · high energy', 'spacesynthDescription': 'Cosmic synths · retro futures', | |
| 'paradise-mainDescription': 'Eclectic · human selected', 'paradise-mellowDescription': 'A gentler pace' | |
| } | |
| const LOCALES = { | |
| en: EN, | |
| ja: { ...EN, artistProfile: 'アーティストのプロフィールを開く', audioOnly: '再生中 · 波形を表示できません', radio: 'ラジオ', browse: 'ステーションを選択', play: 'ラジオを再生', pause: 'ラジオを一時停止', next: '次のステーション', live: 'ライブ', paused: '一時停止', connecting: '接続中', error: '再生できません', retry: '再試行', search: 'ステーションを検索…', volume: '音量', mute: 'ミュート', unmute: 'ミュート解除', save: 'ステーションを固定', unsave: '固定を解除', visit: '公式サイトを開く', noResults: '見つかりませんでした', searchHint: '名前で検索。jazz、ambient、house など。', searchError: '検索できません。おすすめは再生できます。', searching: '検索中', directory: 'Radio Browser による検索', note: 'ライブ放送 · 次へでステーションを切り替え', streamError: '接続できませんでした。再試行するか別のステーションを選んでください。', close: 'ラジオを閉じる', nowPlaying: '再生中', elsewhere: '別のウィンドウで再生中', 'chillsynthDescription': 'やさしい集中 · 暖かなシンセ', 'nightrideDescription': 'シンセウェーブ · 深夜', 'darksynthDescription': 'ダークな電子音 · 高揚感', 'spacesynthDescription': '宇宙的シンセ · レトロな未来', 'paradise-mainDescription': '多彩な選曲 · 人がキュレーション', 'paradise-mellowDescription': 'ゆったりした時間' }, | |
| zh: { ...EN, artistProfile: '访问艺人主页', audioOnly: '正在播放 · 无法显示波形', radio: '电台', browse: '选择电台', play: '播放电台', pause: '暂停电台', next: '下一个电台', live: '直播', paused: '已暂停', connecting: '正在连接', error: '无法播放', retry: '重试', search: '搜索电台…', volume: '音量', mute: '静音', unmute: '取消静音', save: '置顶电台', unsave: '取消置顶', visit: '访问电台网站', noResults: '没有找到电台', searchHint: '按名称搜索,例如 jazz、ambient 或 house。', searchError: '暂时无法搜索,精选电台仍可使用。', searching: '正在搜索', directory: '搜索由 Radio Browser 提供', note: '直播电台 · 下一首将切换电台', streamError: '无法连接,请重试或选择其他电台。', close: '关闭电台', nowPlaying: '正在播放', elsewhere: '已暂停,正在其他窗口播放', 'chillsynthDescription': '轻松专注 · 温暖合成器', 'nightrideDescription': '合成器浪潮 · 深夜', 'darksynthDescription': '暗黑电子 · 充满能量', 'spacesynthDescription': '宇宙合成器 · 复古未来', 'paradise-mainDescription': '多元风格 · 人工精选', 'paradise-mellowDescription': '放慢节奏' }, | |
| 'zh-hant': { ...EN, artistProfile: '造訪藝人主頁', audioOnly: '播放中 · 無法顯示波形', radio: '電台', browse: '選擇電台', play: '播放電台', pause: '暫停電台', next: '下一個電台', live: '直播', paused: '已暫停', connecting: '正在連線', error: '無法播放', retry: '重試', search: '搜尋電台…', volume: '音量', mute: '靜音', unmute: '取消靜音', save: '釘選電台', unsave: '取消釘選', visit: '造訪電台網站', noResults: '找不到電台', searchHint: '按名稱搜尋,例如 jazz、ambient 或 house。', searchError: '暫時無法搜尋,精選電台仍可使用。', searching: '正在搜尋', directory: '搜尋由 Radio Browser 提供', note: '直播電台 · 下一首會切換電台', streamError: '無法連線,請重試或選擇其他電台。', close: '關閉電台', nowPlaying: '正在播放', elsewhere: '已暫停,正在其他視窗播放', 'chillsynthDescription': '輕鬆專注 · 溫暖合成器', 'nightrideDescription': '合成器浪潮 · 深夜', 'darksynthDescription': '暗黑電子 · 充滿能量', 'spacesynthDescription': '宇宙合成器 · 復古未來', 'paradise-mainDescription': '多元風格 · 人工精選', 'paradise-mellowDescription': '放慢節奏' } | |
| } | |
| // Disk plugins are not scanned by Tailwind. Only plugin layout lives here; | |
| // Button, SearchField and Popover own their own chrome. | |
| const CSS = ` | |
| .hermes-radio-bar{display:flex;align-items:center;gap:2px;height:100%;color:var(--ui-text-tertiary)} | |
| .hermes-radio-waveform{position:relative;height:24px;width:100%;font-family:var(--font-mono,monospace);font-size:12px;line-height:12px;color:var(--ui-text-tertiary);opacity:.45;overflow:hidden;flex-shrink:0} | |
| .hermes-radio-waveform-layer{position:absolute;inset:0;display:grid;grid-template-columns:repeat(var(--radio-columns),minmax(0,1fr));grid-auto-rows:12px} | |
| .hermes-radio-waveform-layer>span{text-align:center;min-width:0} | |
| .hermes-radio-waveform-layer:nth-child(1){opacity:.12} | |
| .hermes-radio-waveform-layer:nth-child(2){opacity:.28} | |
| .hermes-radio-waveform[data-compact=true]{width:40px;height:12px;margin-inline:2px} | |
| .hermes-radio-next{display:flex;align-items:center;width:12px;height:12px} | |
| .hermes-radio-waveform[data-active=true]{color:var(--ui-accent);opacity:1} | |
| .hermes-radio-signal{height:48px;padding:5px 6px 0;min-width:0} | |
| .hermes-radio-track{display:flex;align-items:center;gap:5px;height:19px;min-width:0;font-size:11px;line-height:16px;color:var(--ui-text-secondary)} | |
| .hermes-radio-track-text{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;min-width:0} | |
| .hermes-radio-track[data-track=true]{color:var(--ui-text-primary)} | |
| .hermes-radio-artist{min-width:0;max-width:55%;flex-shrink:1;font-size:inherit;font-weight:inherit} | |
| .hermes-radio-artist>span{overflow:hidden;text-overflow:ellipsis} | |
| .hermes-radio-track-separator{flex-shrink:0;color:var(--ui-text-quaternary)} | |
| .hermes-radio-bar .radio-name{max-width:112px;min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;text-align:left} | |
| .hermes-radio-bar .hermes-radio-action{width:16px;height:16px;padding:0;flex-shrink:0} | |
| .hermes-radio-action-icon{display:flex;align-items:center;justify-content:center;width:12px;height:12px;flex-shrink:0;overflow:hidden} | |
| .hermes-radio-website{display:flex;width:24px;height:24px;flex-shrink:0} | |
| .hermes-radio-panel{width:292px;max-width:calc(100vw - 24px)} | |
| .hermes-radio-search{display:flex;align-items:center;height:32px;padding:0 4px 4px} | |
| .hermes-radio-search-field{flex:1;min-width:0} | |
| .hermes-radio-search-field input{flex:1;width:0;min-width:0} | |
| .hermes-radio-search .hermes-radio-search-field{border:0} | |
| .hermes-radio-search-loader{display:flex;align-items:center;justify-content:center;width:16px;flex-shrink:0;font-size:12px;color:var(--ui-text-tertiary)} | |
| .hermes-radio-list{height:168px;max-height:calc(100dvh - 180px);min-height:80px;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable} | |
| .hermes-radio-row{display:flex;align-items:center;gap:4px;border-radius:4px} | |
| .hermes-radio-row[data-current=true]{background:var(--chrome-action-hover)} | |
| .hermes-radio-row[data-current=true] .hermes-radio-row-name{color:var(--ui-accent)} | |
| .hermes-radio-row:hover{background:var(--chrome-action-hover)} | |
| .hermes-radio-row [aria-pressed=true]{color:var(--ui-accent)} | |
| .hermes-radio-pin{opacity:0;pointer-events:none} | |
| .hermes-radio-row:hover .hermes-radio-pin,.hermes-radio-row:focus-within .hermes-radio-pin{opacity:1;pointer-events:auto} | |
| @media(hover:none){.hermes-radio-pin{opacity:1;pointer-events:auto}} | |
| .hermes-radio-row-main{display:flex;align-items:center;gap:8px;flex:1;min-width:0;text-align:left;height:24px;padding:2px 6px;cursor:pointer;border-radius:4px} | |
| .hermes-radio-row-main:focus-visible{outline:1px solid var(--ui-accent);outline-offset:-1px} | |
| .hermes-radio-row-icon{display:flex;align-items:center;justify-content:center;width:14px;flex-shrink:0;color:var(--ui-text-tertiary);opacity:0} | |
| .hermes-radio-row:hover .hermes-radio-row-icon,.hermes-radio-row:focus-within .hermes-radio-row-icon,.hermes-radio-row[data-current=true] .hermes-radio-row-icon{opacity:1} | |
| .hermes-radio-row[data-current=true] .hermes-radio-row-icon{color:var(--ui-accent)} | |
| .hermes-radio-row-copy{min-width:0;flex:1} | |
| .hermes-radio-row-name{display:block;font-size:12px;line-height:17px;color:var(--ui-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| .hermes-radio-volume{display:flex;align-items:center;gap:4px;height:28px;padding:2px 2px 0} | |
| .hermes-radio-volume-space{flex:1;min-width:6px} | |
| .hermes-radio-volume input{width:76px;min-width:0;height:3px;accent-color:var(--ui-accent);cursor:pointer} | |
| .hermes-radio-error{font-size:11px;line-height:17px;color:var(--ui-text-secondary);padding:4px 4px 12px} | |
| ` | |
| function httpsUrl(value) { | |
| try { | |
| const url = new URL(value) | |
| return url.protocol === 'https:' && !url.username && !url.password ? url.href : null | |
| } catch { return null } | |
| } | |
| function validStation(value) { | |
| return value && typeof value.id === 'string' && typeof value.name === 'string' && httpsUrl(value.url) | |
| } | |
| async function fetchJson(url, signal) { | |
| const response = await fetch(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]), credentials: 'omit' }) | |
| if (!response.ok) throw new Error(`HTTP ${response.status}`) | |
| return response.json() | |
| } | |
| let directoryServers = ['https://de1.api.radio-browser.info'] | |
| let discovered = false | |
| async function searchStations(text, signal) { | |
| if (!discovered) { | |
| try { | |
| const servers = await fetchJson(`${directoryServers[0]}/json/servers`, signal) | |
| const hosts = [...new Set(servers.map(server => server.name).filter(name => /^[a-z0-9-]+\.api\.radio-browser\.info$/.test(name)))] | |
| if (hosts.length) directoryServers = hosts.map(name => `https://${name}`).sort(() => Math.random() - 0.5) | |
| discovered = true | |
| } catch (error) { | |
| if (signal.aborted) throw error | |
| } | |
| } | |
| const params = new URLSearchParams({ name: text, limit: '60', hidebroken: 'true', order: 'votes', reverse: 'true' }) | |
| for (const server of directoryServers.slice(0, 3)) { | |
| try { | |
| const rows = await fetchJson(`${server}/json/stations/search?${params}`, signal) | |
| const seen = new Set() | |
| return rows.flatMap(row => { | |
| const url = httpsUrl(row.url_resolved) | |
| if (!url || !row.stationuuid || !row.name || row.hls || seen.has(url)) return [] | |
| seen.add(url) | |
| return [{ id: row.stationuuid, name: row.name.trim(), url, provider: 'Radio Browser', description: row.tags?.split(',').slice(0, 2).join(' · ') || '', homepage: httpsUrl(row.homepage) }] | |
| }) | |
| } catch (error) { | |
| if (signal.aborted) throw error | |
| } | |
| } | |
| throw new Error('Directory unavailable') | |
| } | |
| function sameStation(a, b) { | |
| return a.id === b.id || a.url === b.url | |
| } | |
| function uniqueStations(items) { | |
| const ids = new Set() | |
| const urls = new Set() | |
| return items.filter(item => { | |
| if (ids.has(item.id) || urls.has(item.url)) return false | |
| ids.add(item.id) | |
| urls.add(item.url) | |
| return true | |
| }) | |
| } | |
| function createPlayer(ctx) { | |
| const saved = ctx.storage.get('local.favorites', []) | |
| const favorites = atom(uniqueStations(Array.isArray(saved) ? saved.filter(validStation) : [])) | |
| const previous = ctx.storage.get('local.station', null) | |
| const station = atom(validStation(previous) ? previous : PRESETS[0]) | |
| const storedVolume = ctx.storage.get('local.volume', 25) | |
| const volume = atom(Number.isFinite(storedVolume) ? Math.max(0, Math.min(100, storedVolume)) : 25) | |
| const status = atom('paused') | |
| const meterMode = atom('levels') | |
| const plainStreams = new Set() | |
| const open = atom(false) | |
| let audio = null | |
| let audioContext = null | |
| let source = null | |
| let analyser = null | |
| let output = null | |
| const waveformData = new Float32Array(2048) | |
| let generation = 0 | |
| let timeout = null | |
| let disposed = false | |
| let lastVolume = volume.get() || 25 | |
| const bus = new BroadcastChannel('hermes:radio:playback') | |
| const windowId = crypto.randomUUID() | |
| function stop(nextStatus = 'paused') { | |
| generation++ | |
| clearTimeout(timeout) | |
| source?.disconnect() | |
| analyser?.disconnect() | |
| output?.disconnect() | |
| output = null | |
| source = null | |
| analyser = null | |
| if (audioContext?.state === 'running') void audioContext.suspend() | |
| if (audio) { | |
| audio.pause() | |
| audio.removeAttribute('src') | |
| audio.load() | |
| audio.remove() | |
| audio = null | |
| } | |
| status.set(nextStatus) | |
| } | |
| async function play(next = station.get(), analyse = true) { | |
| if (disposed || !validStation(next)) return | |
| stop() | |
| const token = generation | |
| station.set(next) | |
| ctx.storage.set('local.station', next) | |
| status.set('connecting') | |
| bus.postMessage({ type: 'play', sender: windowId }) | |
| const element = new Audio() | |
| audio = element | |
| element.dataset.hermesRadioAudio = 'true' | |
| element.hidden = true | |
| element.preload = 'none' | |
| // Try real analysis for every station, not a host allowlist. If CORS or | |
| // the audio graph fails, retry once with ordinary media playback. | |
| const metered = analyse && !plainStreams.has(next.url) | |
| meterMode.set(metered ? 'levels' : 'activity') | |
| if (metered) element.crossOrigin = 'anonymous' | |
| element.volume = metered ? 1 : volume.get() / 100 | |
| document.body.append(element) | |
| const current = () => !disposed && token === generation | |
| const fail = () => { | |
| if (!current()) return | |
| if (metered) { | |
| plainStreams.add(next.url) | |
| void play(next, false) | |
| } else stop('error') | |
| } | |
| element.addEventListener('playing', () => { | |
| if (current()) { clearTimeout(timeout); status.set('live') } | |
| }) | |
| // Browser/media controls can pause the element outside our buttons. | |
| // Reflect that state instead of showing a frozen trace as live playback. | |
| element.addEventListener('pause', () => { | |
| if (current() && element.paused && !element.ended) stop() | |
| }) | |
| element.addEventListener('waiting', () => { | |
| if (current()) { status.set('connecting'); clearTimeout(timeout); timeout = setTimeout(fail, 15000) } | |
| }) | |
| element.addEventListener('error', fail) | |
| element.addEventListener('ended', fail) | |
| timeout = setTimeout(fail, 15000) | |
| element.src = next.url | |
| try { | |
| if (metered) { | |
| audioContext ??= new AudioContext() | |
| analyser = audioContext.createAnalyser() | |
| analyser.fftSize = waveformData.length | |
| source = audioContext.createMediaElementSource(element) | |
| output = audioContext.createGain() | |
| output.gain.value = volume.get() / 100 | |
| source.connect(analyser) | |
| analyser.connect(output) | |
| output.connect(audioContext.destination) | |
| await audioContext.resume() | |
| if (!current()) return | |
| } | |
| await element.play() | |
| } catch { fail() } | |
| } | |
| function toggle() { | |
| if (status.get() === 'live' || status.get() === 'connecting') stop() | |
| else void play() | |
| } | |
| function next() { | |
| const queue = uniqueStations([...favorites.get(), ...PRESETS, station.get()]) | |
| const index = queue.findIndex(item => sameStation(item, station.get())) | |
| void play(queue[(index + 1) % queue.length]) | |
| } | |
| function setVolume(value) { | |
| const nextVolume = Math.max(0, Math.min(100, value)) | |
| volume.set(nextVolume) | |
| if (output) output.gain.value = nextVolume / 100 | |
| else if (audio) audio.volume = nextVolume / 100 | |
| ctx.storage.set('local.volume', nextVolume) | |
| } | |
| function mute() { | |
| if (volume.get()) { lastVolume = volume.get(); setVolume(0) } | |
| else setVolume(lastVolume) | |
| } | |
| function favorite(item) { | |
| const current = favorites.get() | |
| const nextFavorites = current.some(value => sameStation(value, item)) ? current.filter(value => !sameStation(value, item)) : [...current, item] | |
| favorites.set(nextFavorites) | |
| ctx.storage.set('local.favorites', nextFavorites) | |
| } | |
| bus.onmessage = event => { | |
| if (event.data?.type === 'play' && event.data.sender !== windowId) stop('elsewhere') | |
| } | |
| function waveform(columns) { | |
| if (!analyser || status.get() !== 'live') return Array(columns).fill(0) | |
| analyser.getFloatTimeDomainData(waveformData) | |
| // A short, rising-edge-triggered trace reads like an oscilloscope rather | |
| // than unrelated frequency bars. Fixed gain leaves silence genuinely flat. | |
| const span = Math.floor(audioContext.sampleRate * .004) | |
| let start = 0 | |
| for (let i = 1; i < waveformData.length - span; i++) { | |
| if (waveformData[i - 1] < 0 && waveformData[i] >= 0) { start = i; break } | |
| } | |
| return Array.from({ length: columns }, (_, x) => { | |
| const from = start + Math.floor(x * span / columns) | |
| const to = start + Math.floor((x + 1) * span / columns) | |
| let sum = 0 | |
| for (let i = from; i < to; i++) sum += waveformData[i] | |
| return Math.max(-1, Math.min(1, sum / Math.max(1, to - from) * 2)) | |
| }) | |
| } | |
| ctx.onDispose(() => { | |
| disposed = true | |
| stop() | |
| bus.close() | |
| if (audioContext) void audioContext.close() | |
| }) | |
| return { station, favorites, volume, status, meterMode, open, play, stop, toggle, next, setVolume, mute, favorite, waveform } | |
| } | |
| function brailleTrace(samples, rows = 2) { | |
| const width = samples.length / 2 | |
| const cells = Array(width * rows).fill(0) | |
| const dots = [[1, 2, 4, 64], [8, 16, 32, 128]] | |
| const center = (rows * 4 - 1) / 2 | |
| const row = value => Math.max(0, Math.min(rows * 4 - 1, Math.round(center - value * center))) | |
| let previous = row(samples[0]) | |
| samples.forEach((value, x) => { | |
| const y = row(value) | |
| for (let point = Math.min(previous, y); point <= Math.max(previous, y); point++) { | |
| cells[Math.floor(point / 4) * width + Math.floor(x / 2)] |= dots[x % 2][point % 4] | |
| } | |
| previous = y | |
| }) | |
| return cells.map(cell => String.fromCharCode(0x2800 | cell)) | |
| } | |
| function Waveform({ player, compact = false }) { | |
| const width = compact ? 6 : 36 | |
| const rows = compact ? 1 : 2 | |
| const ref = useRef(null) | |
| const status = useValue(player.status) | |
| const mode = useValue(player.meterMode) | |
| const station = useValue(player.station) | |
| const active = status === 'live' | |
| const baseline = brailleTrace(Array(width * 2).fill(0), rows) | |
| useEffect(() => { | |
| const node = ref.current | |
| if (!node) return | |
| const blank = Array(width * rows).fill('⠀') | |
| const flat = brailleTrace(Array(width * 2).fill(0), rows) | |
| const layers = [...node.children] | |
| const paint = (layer, cells) => cells.forEach((cell, i) => { | |
| if (layers[layer].children[i].textContent !== cell) layers[layer].children[i].textContent = cell | |
| }) | |
| let history = [blank, blank] | |
| const reset = () => { history = [blank, blank]; paint(0, blank); paint(1, blank); paint(2, flat) } | |
| reset() | |
| const motion = matchMedia('(prefers-reduced-motion: reduce)') | |
| let step = 0 | |
| // Phosphor-style persistence: two dim previous traces, no glow or fake signal. | |
| // Inspired by hubertlim/oscilloscope_playground (MIT); see README. | |
| const loop = createBudgetedLoop(() => { | |
| if (!active || motion.matches) return | |
| if (mode === 'activity') { | |
| const cells = [...blank] | |
| cells[Math.floor(step++ / 2) % width] = '⠤' | |
| paint(0, blank); paint(1, blank); paint(2, cells) | |
| } else { | |
| const cells = brailleTrace(player.waveform(width * 2), rows) | |
| paint(0, history[0]); paint(1, history[1]); paint(2, cells) | |
| history = [history[1], cells] | |
| } | |
| }, { fps: 12, pauseWhenUnfocused: false, idleWhen: () => !active || motion.matches }) | |
| const updateMotion = () => { reset(); loop.wake() } | |
| motion.addEventListener('change', updateMotion) | |
| return () => { loop.dispose(); motion.removeEventListener('change', updateMotion) } | |
| }, [active, mode, player, rows, width, station.url]) | |
| return jsx('div', { ref, className: 'hermes-radio-waveform', style: { '--radio-columns': width }, 'data-radio-meter': width, 'data-compact': compact, 'data-meter-mode': mode, 'data-active': active, 'aria-hidden': true, | |
| children: [0, 1, 2].map(layer => jsx('span', { className: 'hermes-radio-waveform-layer', children: baseline.map((cell, i) => jsx('span', { children: layer === 2 ? cell : '⠀' }, i)) }, layer)) }) | |
| } | |
| function NextArrow() { | |
| return jsxs('span', { className: 'hermes-radio-next', 'aria-hidden': true, children: [ | |
| jsx(icons.Play, { style: { width: 7, height: 12, flexShrink: 0 }, fill: 'currentColor' }), | |
| jsx(icons.Play, { style: { width: 7, height: 12, flexShrink: 0, marginLeft: -2 }, fill: 'currentColor' }) | |
| ] }) | |
| } | |
| function SmallAction({ label, icon, onClick, size = 'micro', pressed, className, busy = false }) { | |
| return jsx(Tip, { label, children: jsx(Button, { | |
| variant: 'ghost', size, className: `hermes-radio-action ${className ?? ''}`, 'aria-label': label, 'aria-pressed': pressed, onClick, | |
| children: jsx('span', { className: 'hermes-radio-action-icon', children: busy ? jsx(GlyphSpinner, { ariaLabel: label }) : jsx(icon, {}) }) | |
| }) }) | |
| } | |
| function trackCredit(source) { | |
| const raw = typeof source?.title === 'string' ? source.title.trim() : '' | |
| const explicit = source?.artist || source?.metadata?.artist | |
| const parts = raw.match(/^(.+?)\s[-–—]\s(.+)$/) | |
| const artist = typeof explicit === 'string' ? explicit.trim() : parts?.[1].trim() || '' | |
| // Only separate an explicit artist prefix when it actually matches. | |
| const title = parts && parts[1].trim() === artist ? parts[2].trim() : raw | |
| return { artist: raw ? artist : '', title } | |
| } | |
| async function artistRequest(path, signal, ctx) { | |
| // The lock and persisted timestamp pace lookups across desktop windows. | |
| return navigator.locks.request('hermes:radio:artist-lookup', { signal }, async () => { | |
| const delay = Math.max(0, 1100 - (Date.now() - ctx.storage.get('local.artistRequestAt', 0))) | |
| if (delay) await new Promise(resolve => setTimeout(resolve, delay)) | |
| signal.throwIfAborted() | |
| ctx.storage.set('local.artistRequestAt', Date.now()) | |
| const response = await fetch(`https://musicbrainz.org/ws/2/${path}`, { | |
| signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]), credentials: 'omit', | |
| headers: { 'User-Agent': 'HermesRadio/1.0 (https://github.com/NousResearch/hermes-agent)' } | |
| }) | |
| if (!response.ok) throw new Error(`Artist lookup: HTTP ${response.status}`) | |
| return response.json() | |
| }) | |
| } | |
| function artistProfile(relations) { | |
| const links = (relations ?? []).filter(item => !item.ended).flatMap(item => { | |
| const href = httpsUrl(item.url?.resource) | |
| if (!href) return [] | |
| const url = new URL(href) | |
| if (url.hostname === 'open.spotify.com' && /^\/artist\/[a-zA-Z0-9]{22}\/?$/.test(url.pathname)) return [{ href, rank: 0 }] | |
| if (item.type === 'bandcamp' && /^[^.]+\.bandcamp\.com$/.test(url.hostname) && url.pathname === '/') return [{ href, rank: 1 }] | |
| if (item.type === 'official homepage' && !['open.spotify.com', 'bandcamp.com'].includes(url.hostname)) return [{ href, rank: 2 }] | |
| return [] | |
| }) | |
| return links.sort((a, b) => a.rank - b.rank)[0]?.href ?? null | |
| } | |
| function normalizedArtist(name) { | |
| return name.normalize('NFKC').trim().toLowerCase() | |
| } | |
| async function resolveArtist(artist, signal, ctx) { | |
| // Apple's public artist search supplies direct profile URLs without API keys. | |
| // Prefer it over a slow/unavailable MusicBrainz lookup; never use song results. | |
| const params = new URLSearchParams({ term: artist, entity: 'musicArtist', limit: '10' }) | |
| try { | |
| const result = await fetchJson(`https://itunes.apple.com/search?${params}`, signal) | |
| const matches = (result.results ?? []).filter(item => item.wrapperType === 'artist' && normalizedArtist(item.artistName) === normalizedArtist(artist)) | |
| if (matches.length > 1) return null | |
| if (matches.length === 1) { | |
| const href = httpsUrl(matches[0].artistLinkUrl) | |
| if (href) { | |
| const url = new URL(href) | |
| if (url.hostname === 'music.apple.com' && /^\/[a-z]{2}\/artist\/[^/]+\/\d+$/.test(url.pathname)) { | |
| url.search = '' | |
| return url.href | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| if (signal.aborted) throw error | |
| } | |
| return resolveMusicBrainzArtist(artist, signal, ctx) | |
| } | |
| async function resolveMusicBrainzArtist(artist, signal, ctx) { | |
| const escaped = artist.replace(/[+\-!(){}\[\]^"~*?:\\/&|]/g, '\\$&') | |
| const params = new URLSearchParams({ query: `artist:"${escaped}"`, fmt: 'json', limit: '5' }) | |
| const result = await artistRequest(`artist/?${params}`, signal, ctx) | |
| const matches = (result.artists ?? []).filter(item => normalizedArtist(item.name) === normalizedArtist(artist)) | |
| // Never turn the top fuzzy match or an ambiguous artist name into a credit. | |
| if (matches.length !== 1 || matches[0].score !== 100 || result.artists.length === 5) return null | |
| const id = matches[0].id | |
| if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(id)) return null | |
| const profile = await artistRequest(`artist/${id}?inc=url-rels&fmt=json`, signal, ctx) | |
| return artistProfile(profile.relations) | |
| } | |
| function Signal({ player, ctx }) { | |
| const t = usePluginI18n(ID) | |
| const station = useValue(player.station) | |
| const status = useValue(player.status) | |
| const mode = useValue(player.meterMode) | |
| const stream = new URL(station.url) | |
| const endpoints = { | |
| 'stream.nightride.fm': `${NIGHTRIDE}status-json.xsl`, | |
| 'media01.gamingnow.net': 'https://media01.gamingnow.net:8010/status-json.xsl' | |
| } | |
| const endpoint = endpoints[stream.hostname] | |
| const metadata = useQuery({ | |
| queryKey: [ID, 'metadata', endpoint], | |
| queryFn: ({ signal }) => fetchJson(endpoint, signal), | |
| enabled: status === 'live' && Boolean(endpoint), | |
| staleTime: 20000, refetchInterval: status === 'live' ? 25000 : false, retry: 1 | |
| }) | |
| const sources = metadata.data?.icestats?.source | |
| const source = (Array.isArray(sources) ? sources : sources ? [sources] : []).find(source => source.listenurl?.endsWith(stream.pathname)) | |
| const track = status === 'live' && typeof source?.title === 'string' ? source.title : '' | |
| const credit = trackCredit(track ? source : null) | |
| const artist = useQuery({ | |
| queryKey: [ID, 'artist-profile', credit.artist], | |
| queryFn: ({ signal }) => resolveArtist(credit.artist, signal, ctx), | |
| enabled: Boolean(credit.artist), staleTime: 86400000, gcTime: 86400000, | |
| retry: 1, retryDelay: 3000, refetchOnWindowFocus: false | |
| }) | |
| const artistUrl = credit.artist ? artist.data : null | |
| const text = status === 'live' ? track || (mode === 'activity' ? t('audioOnly') : '') : t(status) | |
| return jsxs('div', { className: 'hermes-radio-signal', children: [ | |
| jsx(Waveform, { player }), | |
| jsxs('div', { className: 'hermes-radio-track', 'data-track': Boolean(track), role: 'status', children: [ | |
| status === 'connecting' && jsx(GlyphSpinner, { ariaLabel: t('connecting') }), | |
| credit.artist && (artistUrl ? jsx(Tip, { label: `${t('artistProfile')}: ${credit.artist}`, children: jsx(Button, { | |
| variant: 'text', size: 'inline', asChild: true, className: 'hermes-radio-artist', | |
| children: jsxs('a', { href: artistUrl, target: '_blank', rel: 'noopener noreferrer', | |
| 'aria-label': `${t('artistProfile')}: ${credit.artist}`, | |
| onClick: event => { event.preventDefault(); void ctx.os.openExternal(artistUrl) }, | |
| children: [jsx('span', { children: credit.artist }), jsx(icons.ExternalLink, { style: { width: 10, height: 10 }, 'aria-hidden': true })] | |
| }) | |
| }) }) : jsx('span', { className: 'hermes-radio-track-text', children: credit.artist })), | |
| credit.artist && jsx('span', { className: 'hermes-radio-track-separator', 'aria-hidden': true, children: '—' }), | |
| jsx(Tip, { label: text || undefined, children: jsx('span', { className: 'hermes-radio-track-text', children: credit.artist ? credit.title : text }) }) | |
| ] }) | |
| ] }) | |
| } | |
| function StationRow({ station, player }) { | |
| const t = usePluginI18n(ID) | |
| const current = sameStation(useValue(player.station), station) | |
| const status = useValue(player.status) | |
| const active = status === 'live' || status === 'connecting' | |
| const failed = current && status === 'error' | |
| const saved = useValue(player.favorites).some(item => sameStation(item, station)) | |
| return jsxs('div', { className: 'hermes-radio-row', 'data-current': current, 'data-pinned': saved, children: [ | |
| jsxs(RowButton, { className: 'hermes-radio-row-main', 'aria-label': `${t(failed ? 'retry' : current && active ? 'pause' : 'play')}: ${station.name}`, title: failed ? t('streamError') : undefined, 'aria-current': current ? 'true' : undefined, | |
| onClick: () => current ? player.toggle() : void player.play(station), children: [ | |
| jsx('span', { className: 'hermes-radio-row-icon', role: failed ? 'status' : undefined, 'aria-label': failed ? t('error') : undefined, children: jsx(failed ? icons.AlertCircle : current && active ? icons.Pause : icons.Play, { size: 12 }) }), | |
| jsx('span', { className: 'hermes-radio-row-copy', children: jsx('span', { className: 'hermes-radio-row-name', children: station.name }) }) | |
| ] }), | |
| jsx(SmallAction, { label: `${t(saved ? 'unsave' : 'save')}: ${station.name}`, icon: icons.Pin, onClick: () => player.favorite(station), size: 'icon-xs', pressed: saved, className: 'hermes-radio-pin' }) | |
| ] }) | |
| } | |
| function Stations({ player }) { | |
| const t = usePluginI18n(ID) | |
| const favorites = useValue(player.favorites) | |
| const current = useValue(player.station) | |
| const [browseStation, setBrowseStation] = useState(() => player.station.get()) | |
| const listRef = useRef(null) | |
| const [search, setSearch] = useState('') | |
| const [query, setQuery] = useState('') | |
| const text = search.trim() | |
| useEffect(() => { | |
| if (listRef.current) listRef.current.scrollTop = 0 | |
| }, [text]) | |
| useEffect(() => { | |
| const timer = setTimeout(() => setQuery(text), 350) | |
| return () => clearTimeout(timer) | |
| }, [text]) | |
| const result = useQuery({ | |
| queryKey: [ID, 'search', query], queryFn: ({ signal }) => searchStations(query, signal), | |
| enabled: query.length >= 2 && text === query, staleTime: 300000, retry: false | |
| }) | |
| const matches = item => [item.name, item.description, item.provider].some(value => value?.toLowerCase().includes(text.toLowerCase())) | |
| const local = uniqueStations([...favorites, ...PRESETS]).filter(matches) | |
| const remote = text.length >= 2 && query === text ? result.data ?? [] : [] | |
| const results = uniqueStations([...local, ...remote]) | |
| // Keep the previously prepended row until the search changes: selecting a | |
| // result must not remove a row above it and move the target under the pointer. | |
| const anchored = results.some(item => sameStation(item, browseStation)) ? results : [browseStation, ...results] | |
| const items = anchored.some(item => sameStation(item, current)) ? anchored : [current, ...anchored] | |
| const pending = text.length >= 2 && (text !== query || result.isFetching) | |
| const searchFailed = text.length >= 2 && query === text && result.isError | |
| return jsxs('div', { children: [ | |
| jsx('div', { className: 'hermes-radio-search', 'data-filled': Boolean(search), children: jsx(SearchField, { | |
| value: search, onChange: value => { setBrowseStation(player.station.get()); setSearch(value) }, placeholder: t('search'), | |
| containerClassName: 'hermes-radio-search-field', | |
| trailingAction: pending ? jsx('span', { className: 'hermes-radio-search-loader', children: jsx(GlyphSpinner, { ariaLabel: t('searching') }) }) : null | |
| }) }), | |
| jsxs('div', { ref: listRef, className: 'hermes-radio-list', 'data-radio-pending': pending, children: [ | |
| ...items.map(station => jsx(StationRow, { station, player }, station.id)), | |
| searchFailed && jsx('div', { className: 'hermes-radio-error', role: 'status', children: t('searchError') }), | |
| !pending && !searchFailed && text && results.length === 0 && jsx('div', { className: 'hermes-radio-error', role: 'status', children: t('noResults') }) | |
| ] }) | |
| ] }) | |
| } | |
| function Transport({ player, ctx }) { | |
| const t = usePluginI18n(ID) | |
| const volume = useValue(player.volume) | |
| const station = useValue(player.station) | |
| const status = useValue(player.status) | |
| const active = status === 'live' || status === 'connecting' | |
| return jsxs('div', { className: 'hermes-radio-volume', children: [ | |
| jsx(SmallAction, { label: t(active ? 'pause' : 'play'), icon: active ? icons.Pause : icons.Play, busy: status === 'connecting', size: 'icon-xs', onClick: player.toggle }), | |
| jsx(SmallAction, { label: t('next'), icon: NextArrow, size: 'icon-xs', onClick: player.next }), | |
| jsx('span', { className: 'hermes-radio-volume-space' }), | |
| jsx(SmallAction, { label: t(volume ? 'mute' : 'unmute'), icon: volume ? icons.Volume2 : icons.VolumeX, onClick: player.mute, size: 'icon-xs' }), | |
| jsx('input', { type: 'range', min: 0, max: 100, step: 1, value: volume, 'aria-label': t('volume'), onChange: event => player.setVolume(Number(event.target.value)) }), | |
| jsx('span', { className: 'hermes-radio-website', children: httpsUrl(station.homepage) && jsx(SmallAction, { label: t('visit'), icon: icons.ExternalLink, size: 'icon-xs', onClick: () => void ctx.os.openExternal(station.homepage) }) }) | |
| ] }) | |
| } | |
| function RadioBar({ player, ctx }) { | |
| const t = usePluginI18n(ID) | |
| const station = useValue(player.station) | |
| const status = useValue(player.status) | |
| const open = useValue(player.open) | |
| const triggerRef = useRef(null) | |
| const [browseWidth, setBrowseWidth] = useState(null) | |
| const active = status === 'live' || status === 'connecting' | |
| // Fit the name at rest. Freeze only while browsing so a selection cannot | |
| // move the open picker; there is no permanent empty label slot. | |
| const onOpenChange = value => { | |
| if (value) setBrowseWidth(triggerRef.current.getBoundingClientRect().width) | |
| player.open.set(value) | |
| } | |
| return jsxs('div', { className: 'hermes-radio-bar', 'data-tour': 'radio-player', 'data-radio-status': status, children: [ | |
| jsx(Waveform, { player, compact: true }), | |
| jsxs(Popover, { open, onOpenChange, children: [ | |
| jsx(PopoverTrigger, { asChild: true, children: jsx(Button, { | |
| ref: triggerRef, variant: 'ghost', size: 'micro', style: { width: open ? browseWidth : undefined }, | |
| 'aria-label': `${t('browse')}: ${station.name}`, | |
| children: jsx('span', { className: 'radio-name', children: station.name }) | |
| }) }), | |
| jsx(PopoverContent, { side: 'top', align: 'end', className: 'hermes-radio-panel', 'aria-label': t('radio'), 'data-tour': 'radio-panel', children: | |
| jsxs('div', { children: [ | |
| jsx(Stations, { player }), jsx(Signal, { player, ctx }), jsx(Transport, { player, ctx }) | |
| ] }) | |
| }) | |
| ] }), | |
| jsx(SmallAction, { label: t(active ? 'pause' : 'play'), icon: active ? icons.Pause : icons.Play, busy: status === 'connecting', onClick: player.toggle }), | |
| jsx(SmallAction, { label: t('next'), icon: NextArrow, onClick: player.next }) | |
| ] }) | |
| } | |
| export { artistProfile, resolveArtist, trackCredit } | |
| export default { | |
| id: ID, | |
| name: 'Radio', | |
| description: 'Free live radio with pinned stations, search, and an audio-reactive waveform.', | |
| defaultEnabled: false, | |
| register(ctx) { | |
| ctx.i18n.register(LOCALES) | |
| const style = document.createElement('style') | |
| style.textContent = CSS | |
| document.head.append(style) | |
| ctx.onDispose(() => style.remove()) | |
| const player = createPlayer(ctx) | |
| ctx.register({ id: 'player', area: STATUSBAR_AREAS.right, order: 10, render: () => jsx(RadioBar, { player, ctx }) }) | |
| } | |
| } | |