client.js (17410B)
1 (() => { 2 if (!window?.BzlPluginHost?.register) return; 3 4 const PLUGIN_ID = "radio"; 5 const LS_ON = "bzl_radio_on"; 6 const LS_TUNED = "bzl_radio_stationId"; 7 8 function esc(s) { 9 return String(s ?? "") 10 .replace(/&/g, "&") 11 .replace(/</g, "<") 12 .replace(/>/g, ">") 13 .replace(/\"/g, """) 14 .replace(/'/g, "'"); 15 } 16 17 function safeJsonParse(str) { 18 try { 19 return JSON.parse(str); 20 } catch { 21 return null; 22 } 23 } 24 25 function getSessionTokenSafe() { 26 try { 27 // Core stores the session token in localStorage. 28 return localStorage.getItem("bzl_session_token") || ""; 29 } catch { 30 return ""; 31 } 32 } 33 34 async function uploadMp3(file, toast) { 35 if (!file) return ""; 36 const lowered = String(file.name || "").toLowerCase(); 37 const isMp3 = lowered.endsWith(".mp3") || String(file.type || "").toLowerCase() === "audio/mpeg"; 38 if (!isMp3) { 39 toast("Radio", "Only MP3 files are allowed."); 40 return ""; 41 } 42 const token = getSessionTokenSafe(); 43 if (!token) { 44 toast("Radio", "Sign in required to upload MP3s."); 45 return ""; 46 } 47 try { 48 const res = await fetch("/api/upload?kind=audio", { 49 method: "POST", 50 headers: { 51 Authorization: `Bearer ${token}`, 52 "Content-Type": "audio/mpeg" 53 }, 54 body: file 55 }); 56 const payload = await res.json().catch(() => ({})); 57 if (!res.ok) { 58 toast("Radio", payload?.error || "Upload failed."); 59 return ""; 60 } 61 const url = String(payload?.url || ""); 62 if (!url) { 63 toast("Radio", "Upload failed (no URL)."); 64 return ""; 65 } 66 if (!/\.mp3(\?|#|$)/i.test(url)) { 67 toast("Radio", "Upload rejected (not an MP3 URL)."); 68 return ""; 69 } 70 return url; 71 } catch { 72 toast("Radio", "Upload failed (network error)."); 73 return ""; 74 } 75 } 76 77 window.BzlPluginHost.register(PLUGIN_ID, (ctx) => { 78 const ws = window.__bzlWs; 79 let mountEl = null; 80 let audioEl = null; 81 let fileInputEl = null; 82 83 let stations = []; 84 let isOn = false; 85 let tunedStationId = ""; 86 let playingStationId = ""; 87 let lastPlayedTrackUrl = ""; 88 let lastPlayedTrackSrc = ""; 89 let needsManualPlay = false; 90 91 function readPrefs() { 92 try { 93 isOn = localStorage.getItem(LS_ON) === "1"; 94 tunedStationId = String(localStorage.getItem(LS_TUNED) || "").trim().toLowerCase(); 95 } catch { 96 isOn = false; 97 tunedStationId = ""; 98 } 99 } 100 101 function writeOn(next) { 102 isOn = Boolean(next); 103 try { 104 localStorage.setItem(LS_ON, isOn ? "1" : "0"); 105 } catch {} 106 } 107 108 function writeTuned(id) { 109 tunedStationId = String(id || "").trim().toLowerCase(); 110 try { 111 localStorage.setItem(LS_TUNED, tunedStationId); 112 } catch {} 113 } 114 115 function stationById(id) { 116 const sid = String(id || "").trim().toLowerCase(); 117 return stations.find((s) => String(s?.id || "") === sid) || null; 118 } 119 120 function ensureTunedExists() { 121 if (!stations.length) { 122 tunedStationId = ""; 123 return; 124 } 125 if (tunedStationId && stationById(tunedStationId)) return; 126 tunedStationId = String(stations[0]?.id || ""); 127 } 128 129 function pickRandomTrack(station) { 130 const tracks = Array.isArray(station?.tracks) ? station.tracks : []; 131 if (!tracks.length) return null; 132 if (tracks.length === 1) return tracks[0]; 133 for (let i = 0; i < 6; i++) { 134 const t = tracks[Math.floor(Math.random() * tracks.length)]; 135 if (t && String(t.url || "") !== lastPlayedTrackUrl) return t; 136 } 137 return tracks[Math.floor(Math.random() * tracks.length)]; 138 } 139 140 function stopPlayback() { 141 playingStationId = ""; 142 lastPlayedTrackUrl = ""; 143 lastPlayedTrackSrc = ""; 144 needsManualPlay = false; 145 if (!audioEl) return; 146 try { 147 audioEl.pause(); 148 audioEl.removeAttribute("src"); 149 audioEl.load(); 150 } catch {} 151 updateNowEl(); 152 } 153 154 async function startPlaybackFor(stationId) { 155 if (!audioEl) return; 156 const station = stationById(stationId); 157 if (!station) return; 158 const track = pickRandomTrack(station); 159 if (!track || !track.url) { 160 stopPlayback(); 161 return; 162 } 163 playingStationId = String(station.id || ""); 164 lastPlayedTrackUrl = String(track.url || ""); 165 needsManualPlay = false; 166 try { 167 lastPlayedTrackSrc = new URL(String(track.url || ""), window.location.origin).href; 168 } catch { 169 lastPlayedTrackSrc = String(track.url || ""); 170 } 171 audioEl.src = lastPlayedTrackSrc; 172 try { 173 audioEl.load(); 174 } catch {} 175 try { 176 await audioEl.play(); 177 } catch { 178 // Autoplay can be blocked; keep controls visible and prompt user to press play. 179 needsManualPlay = true; 180 } 181 updateNowEl(); 182 } 183 184 function updateNowEl() { 185 if (!mountEl) return; 186 const nowWrap = mountEl.querySelector(".radioNow"); 187 if (!nowWrap) return; 188 ensureTunedExists(); 189 const tuned = tunedStationId ? stationById(tunedStationId) : null; 190 const trackCount = tuned ? Number(tuned.trackCount || (tuned.tracks || []).length || 0) : 0; 191 const isPlaying = Boolean(isOn && playingStationId && playingStationId === tunedStationId); 192 const nowTrack = isPlaying ? (tuned?.tracks || []).find((t) => String(t.url || "") === lastPlayedTrackUrl) : null; 193 194 nowWrap.innerHTML = ` 195 ${ 196 !tuned 197 ? `<span class="muted">No stations available.</span>` 198 : !isOn 199 ? `<span class="muted">Radio is off. Tune around, then toggle it on to listen.</span>` 200 : trackCount === 0 201 ? `<span class="muted">This station has no tracks yet. Upload some MP3s.</span>` 202 : nowTrack 203 ? `Now playing: <b>${esc(nowTrack.title || "Track")}</b> <span class="muted">(${esc( 204 nowTrack.addedBy ? `@${nowTrack.addedBy}` : "unknown" 205 )})</span>` 206 : needsManualPlay 207 ? `<span class="muted">Press play to start listening (browser blocked autoplay).</span>` 208 : `<span class="muted">Ready.</span>` 209 } 210 `; 211 } 212 213 function stepTune(dir) { 214 if (!stations.length) return; 215 const currentIdx = Math.max( 216 0, 217 stations.findIndex((s) => String(s?.id || "") === tunedStationId) 218 ); 219 const nextIdx = (currentIdx + (dir < 0 ? -1 : 1) + stations.length) % stations.length; 220 const nextId = String(stations[nextIdx]?.id || ""); 221 writeTuned(nextId); 222 render(); 223 if (isOn) { 224 stopPlayback(); 225 startPlaybackFor(nextId); 226 } 227 } 228 229 function render() { 230 if (!mountEl) return; 231 ensureTunedExists(); 232 233 const tuned = tunedStationId ? stationById(tunedStationId) : null; 234 const canUpload = Boolean(ctx.getUser && ctx.getUser()); 235 const tunedName = tuned ? tuned.name : "No stations yet"; 236 const tunedAuthor = tuned ? tuned.author : ""; 237 const trackCount = tuned ? Number(tuned.trackCount || (tuned.tracks || []).length || 0) : 0; 238 const isPlaying = Boolean(isOn && playingStationId && playingStationId === tunedStationId); 239 const nowTrack = isPlaying ? (tuned?.tracks || []).find((t) => String(t.url || "") === lastPlayedTrackUrl) : null; 240 241 mountEl.innerHTML = ` 242 <style> 243 .radioWrap { display:flex; flex-direction:column; gap:10px; } 244 .radioTop { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; } 245 .radioRow { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } 246 .radioStation { display:flex; flex-direction:column; gap:2px; padding:10px 12px; border:1px solid rgba(246,240,255,0.12); background:rgba(255,255,255,0.02); border-radius:14px; } 247 .radioStationName { font-weight:900; letter-spacing:0.2px; } 248 .radioMeta { opacity:0.75; } 249 .radioNow { padding:8px 10px; border:1px dashed rgba(246,240,255,0.16); border-radius:12px; background:rgba(0,0,0,0.12); } 250 .radioBtns button { padding:7px 10px; border-radius:10px; } 251 .radioArrow { font-size:16px; line-height:1; } 252 .radioSmall { font-size: 12px; opacity: 0.8; } 253 .radioList { max-height: 220px; overflow:auto; border:1px solid rgba(246,240,255,0.10); border-radius:12px; } 254 .radioStationItem { padding:8px 10px; display:flex; justify-content:space-between; gap:10px; border-bottom:1px solid rgba(246,240,255,0.06); cursor:pointer; } 255 .radioStationItem:last-child { border-bottom:0; } 256 .radioStationItem.active { background: rgba(255, 184, 77, 0.08); } 257 </style> 258 <div class="radioWrap"> 259 <div class="radioTop"> 260 <div class="radioRow"> 261 <label class="row" style="gap:8px;align-items:center"> 262 <input type="checkbox" data-radioon="1" ${isOn ? "checked" : ""} /> 263 <span style="font-weight:900">Radio</span> 264 </label> 265 <div class="radioBtns radioRow"> 266 <button type="button" class="ghost smallBtn radioArrow" data-tune="-1" title="Tune left">←</button> 267 <button type="button" class="ghost smallBtn radioArrow" data-tune="1" title="Tune right">→</button> 268 </div> 269 </div> 270 <div class="radioRow"> 271 <button type="button" class="ghost smallBtn" data-newstation="1">New station</button> 272 <button type="button" class="ghost smallBtn" data-upload="1" ${!tuned ? "disabled" : ""}>Upload MP3s</button> 273 <input type="file" accept=\".mp3,audio/mpeg\" multiple style="display:none" data-filepick="1" /> 274 </div> 275 </div> 276 277 <div class="radioStation"> 278 <div class="radioStationName">${esc(tunedName)}</div> 279 <div class="radioMeta small muted">${tunedAuthor ? `by @${esc(tunedAuthor)} • ${trackCount} track${trackCount === 1 ? "" : "s"}` : "Create a station to get started."}</div> 280 </div> 281 282 <div class="radioNow small"> 283 ${ 284 !tuned 285 ? `<span class="muted">No stations available.</span>` 286 : !isOn 287 ? `<span class="muted">Radio is off. Tune around, then toggle it on to listen.</span>` 288 : trackCount === 0 289 ? `<span class="muted">This station has no tracks yet. Upload some MP3s.</span>` 290 : nowTrack 291 ? `Now playing: <b>${esc(nowTrack.title || "Track")}</b> <span class="muted">(${esc(nowTrack.addedBy ? `@${nowTrack.addedBy}` : "unknown")})</span>` 292 : needsManualPlay 293 ? `<span class="muted">Press play to start listening (browser blocked autoplay).</span>` 294 : `<span class="muted">Ready.</span>` 295 } 296 </div> 297 298 <audio controls preload="none" style="width:100%"></audio> 299 300 <div class="radioSmall muted">Stations</div> 301 <div class="radioList"> 302 ${ 303 stations.length 304 ? stations 305 .map((s) => { 306 const active = String(s.id || "") === tunedStationId; 307 const count = Number(s.trackCount || (s.tracks || []).length || 0); 308 return `<div class="radioStationItem ${active ? "active" : ""}" data-station="${esc(s.id)}"> 309 <span><b>${esc(s.name || "Station")}</b> <span class="muted">by @${esc(s.author || "")}</span></span> 310 <span class="muted">${count}</span> 311 </div>`; 312 }) 313 .join("") 314 : `<div class="small muted" style="padding:10px">No stations yet.</div>` 315 } 316 </div> 317 </div> 318 `; 319 320 audioEl = mountEl.querySelector("audio"); 321 fileInputEl = mountEl.querySelector('[data-filepick="1"]'); 322 323 if (audioEl) { 324 // This plugin re-renders via innerHTML. Preserve the last selected track across renders so 325 // the native audio controls don't end up disabled (no src). 326 if (lastPlayedTrackSrc) { 327 const current = String(audioEl.getAttribute("src") || ""); 328 if (current !== lastPlayedTrackSrc) { 329 audioEl.src = lastPlayedTrackSrc; 330 try { 331 audioEl.load(); 332 } catch {} 333 } 334 } 335 } 336 337 const onEl = mountEl.querySelector('[data-radioon="1"]'); 338 const uploadBtn = mountEl.querySelector('[data-upload="1"]'); 339 340 if (onEl) { 341 onEl.addEventListener("change", () => { 342 writeOn(Boolean(onEl.checked)); 343 render(); 344 if (!isOn) stopPlayback(); 345 else if (tunedStationId) startPlaybackFor(tunedStationId); 346 }); 347 } 348 349 mountEl.querySelectorAll("[data-tune]").forEach((btn) => { 350 btn.addEventListener("click", () => stepTune(Number(btn.getAttribute("data-tune") || "0"))); 351 }); 352 353 mountEl.querySelectorAll("[data-station]").forEach((row) => { 354 row.addEventListener("click", () => { 355 const id = String(row.getAttribute("data-station") || "").trim().toLowerCase(); 356 if (!id) return; 357 writeTuned(id); 358 render(); 359 if (isOn) { 360 stopPlayback(); 361 startPlaybackFor(id); 362 } 363 }); 364 }); 365 366 const newBtn = mountEl.querySelector('[data-newstation="1"]'); 367 if (newBtn) { 368 newBtn.addEventListener("click", () => { 369 const name = prompt("Station name:"); 370 if (!name) return; 371 ctx.send("createStation", { name: String(name).trim() }); 372 }); 373 } 374 375 if (uploadBtn && fileInputEl) { 376 uploadBtn.addEventListener("click", () => { 377 if (!canUpload) { 378 ctx.toast("Radio", "Sign in required to upload MP3s."); 379 return; 380 } 381 if (!tunedStationId) return; 382 fileInputEl.value = ""; 383 fileInputEl.click(); 384 }); 385 fileInputEl.addEventListener("change", async () => { 386 const files = Array.from(fileInputEl.files || []); 387 if (!files.length || !tunedStationId) return; 388 const uploaded = []; 389 for (const f of files) { 390 const url = await uploadMp3(f, ctx.toast); 391 if (!url) continue; 392 uploaded.push({ url, title: String(f.name || "track").replace(/\.mp3$/i, "") }); 393 } 394 if (!uploaded.length) return; 395 ctx.send("addTracks", { stationId: tunedStationId, tracks: uploaded }); 396 }); 397 } 398 399 if (audioEl) { 400 audioEl.addEventListener("ended", () => { 401 if (!isOn) return; 402 if (!tunedStationId) return; 403 startPlaybackFor(tunedStationId); 404 }); 405 audioEl.addEventListener("error", () => { 406 if (!isOn) return; 407 stopPlayback(); 408 updateNowEl(); 409 }); 410 audioEl.addEventListener("play", () => { 411 needsManualPlay = false; 412 updateNowEl(); 413 }); 414 } 415 416 // Keep audio element in sync with current state. 417 if (!isOn) stopPlayback(); 418 else if (tunedStationId && (!playingStationId || playingStationId !== tunedStationId) && !needsManualPlay) { 419 stopPlayback(); 420 startPlaybackFor(tunedStationId); 421 } 422 } 423 424 function onWsMessage(evt) { 425 let msg; 426 try { 427 msg = safeJsonParse(evt.data); 428 } catch { 429 return; 430 } 431 if (!msg || typeof msg !== "object") return; 432 const type = String(msg.type || ""); 433 if (type === "plugin:radio:stations") { 434 stations = Array.isArray(msg.stations) ? msg.stations : []; 435 stations.sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0)); 436 ensureTunedExists(); 437 render(); 438 return; 439 } 440 if (type === "plugin:radio:createOk") { 441 const id = String(msg.stationId || "").trim().toLowerCase(); 442 if (id) { 443 writeTuned(id); 444 render(); 445 } 446 return; 447 } 448 if (type === "plugin:radio:addOk") { 449 ctx.toast("Radio", `Added ${Number(msg.added || 0)} track(s).`); 450 return; 451 } 452 if (type === "plugin:radio:error") { 453 ctx.toast("Radio", String(msg.message || "Radio error.")); 454 } 455 } 456 457 readPrefs(); 458 459 ctx.ui?.registerPanel?.({ 460 id: "radio", 461 title: "Radio", 462 icon: "📻", 463 defaultRack: "right", 464 role: "aux", 465 presetHints: { 466 defaultSocial: { place: "docked.bottom" }, 467 browse: { place: "docked.bottom" }, 468 mapsSession: { place: "right" } 469 }, 470 render(mount) { 471 mountEl = mount; 472 stations = []; 473 playingStationId = ""; 474 lastPlayedTrackUrl = ""; 475 if (ws && ws.addEventListener) ws.addEventListener("message", onWsMessage); 476 ctx.send("stateReq", {}); 477 render(); 478 return () => { 479 if (ws && ws.removeEventListener) ws.removeEventListener("message", onWsMessage); 480 stopPlayback(); 481 mountEl = null; 482 }; 483 } 484 }); 485 }); 486 })();