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 }) }) } }