bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

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, "&lt;")
     12       .replace(/>/g, "&gt;")
     13       .replace(/\"/g, "&quot;")
     14       .replace(/'/g, "&#39;");
     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 })();