brain.fm Audio Download Widget

Finds <audio> on the page and shows a top-right download button for it.

// ==UserScript==
// @name         brain.fm Audio Download Widget
// @author       Hawk
// @namespace    https://brain.fm
// @license      CC-BY-NC-SA-4.0; https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
// @license      GPL-3.0-or-later; http://www.gnu.org/licenses/gpl-3.0.txt
// @version      1.0.0
// @description  Finds <audio> on the page and shows a top-right download button for it.
// @match        https://my.brain.fm/*
// @match        https://my.brain.fm/player/*
// @match        https://brain.fm/*
// @match        https://brain.fm/player/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=brain.fm
// @run-at       document-idle
// @run-at       document-end
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      brain.fm
// @connect      www.brain.fm
// @connect      audio.brain.fm
// @connect      audio1.brain.fm
// @connect      audio2.brain.fm
// @connect      audio*.brain.fm
// @connect      *.brain.fm
// ==/UserScript==

(function () {
  'use strict';

  const WIDGET_ID = 'audio-dl-widget';
  const POS_KEY = 'audio-dl-widget-pos';
  const BTN_DEFAULT = 'Download';

  const STYLE = `
    #${WIDGET_ID} {
      position: fixed;
      top: 16px;
      right: 16px;
      z-index: 2147483647;
      display: inline-flex;
      align-items: center;
      gap: 8px;
      padding: 8px 10px;
      background: rgba(28,28,30,0.9);
      color: #fff;
      border: 1px solid rgba(255,255,255,0.12);
      border-radius: 10px;
      box-shadow: 0 8px 24px rgba(0,0,0,0.28);
      font: 13px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
      backdrop-filter: blur(6px);
      cursor: grab;
      -webkit-user-select: none;
      user-select: none;
    }
    #${WIDGET_ID}.dragging { cursor: grabbing; }
    #${WIDGET_ID} .dl-btn {
      all: unset;
      cursor: pointer;
      background: #2563eb;           /* clean blue */
      color: #fff;
      padding: 8px 12px;
      border-radius: 8px;
      font-weight: 600;
      transition: background 0.2s ease, transform 0.06s ease, opacity 0.2s ease;
      white-space: nowrap;
    }
    #${WIDGET_ID} .dl-btn:hover { background: #1e4fd1; }
    #${WIDGET_ID} .dl-btn:active { transform: translateY(1px); }
    #${WIDGET_ID} .dl-btn[disabled] { opacity: 0.65; cursor: not-allowed; }

    #${WIDGET_ID} .meta {
      max-width: 38vw;
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
      opacity: 0.92;
    }

    #${WIDGET_ID} .icon-btn {
      all: unset;
      width: 28px; height: 28px;
      display: inline-flex; align-items: center; justify-content: center;
      border-radius: 8px;
      background: rgba(255,255,255,0.08);
      color: #fff; cursor: pointer;
      transition: background 0.2s ease, transform 0.06s ease, opacity 0.2s ease;
      font-weight: 700;
    }
    #${WIDGET_ID} .icon-btn:hover { background: rgba(255,255,255,0.15); }
    #${WIDGET_ID} .icon-btn:active { transform: translateY(1px); }

    #${WIDGET_ID}.error { animation: errflash 0.9s ease; }
    @keyframes errflash {
      0%{box-shadow:0 0 0 rgba(255,0,0,0.0);}
      15%{box-shadow:0 0 0 3px rgba(255,0,0,0.55);}
      100%{box-shadow:0 8px 24px rgba(0,0,0,0.28);}
    }
  `;

  if (typeof GM_addStyle === 'function') GM_addStyle(STYLE);
  else {
    const style = document.createElement('style');
    style.textContent = STYLE;
    document.head.appendChild(style);
  }

  let widget, btn, meta, refreshBtn;
  let dragging = false;
  let startX = 0,
    startY = 0,
    offsetX = 0,
    offsetY = 0;
  let timer10s = null;
  let downloading = false;

  function createWidget() {
    if (document.getElementById(WIDGET_ID)) return;

    widget = document.createElement('div');
    widget.id = WIDGET_ID;

    btn = document.createElement('button');
    btn.className = 'dl-btn';
    btn.textContent = BTN_DEFAULT;
    btn.addEventListener('click', onDownloadClick);

    meta = document.createElement('div');
    meta.className = 'meta';
    meta.textContent = 'Looking for audio...';

    refreshBtn = document.createElement('button');
    refreshBtn.className = 'icon-btn';
    refreshBtn.title = 'Refresh';
    refreshBtn.textContent = '↻';
    refreshBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      updateAll();
    });

    widget.appendChild(btn);
    widget.appendChild(meta);
    widget.appendChild(refreshBtn);
    document.body.appendChild(widget);

    restorePosition();
    makeDraggable();

    // Poll every 10s
    timer10s = setInterval(updateAll, 10000);
  }

  function makeDraggable() {
    widget.addEventListener('pointerdown', (e) => {
      if (e.button !== 0) return;
      // don't drag when interacting with controls
      if (e.target.closest('button, a, input, select, textarea, label')) return;

      const rect = widget.getBoundingClientRect();
      startX = e.clientX;
      startY = e.clientY;
      offsetX = startX - rect.left;
      offsetY = startY - rect.top;
      dragging = true;
      widget.setPointerCapture(e.pointerId);
      widget.classList.add('dragging');
      widget.style.transition = 'none';
    });

    widget.addEventListener('pointermove', (e) => {
      if (!dragging || !widget.hasPointerCapture(e.pointerId)) return;
      const rect = widget.getBoundingClientRect();
      let left = e.clientX - offsetX;
      let top = e.clientY - offsetY;
      left = Math.max(4, Math.min(left, window.innerWidth - rect.width - 4));
      top = Math.max(4, Math.min(top, window.innerHeight - rect.height - 4));
      widget.style.left = `${left}px`;
      widget.style.top = `${top}px`;
      widget.style.right = 'auto';
      widget.style.bottom = 'auto';
    });

    const endDrag = (e) => {
      if (!dragging) return;
      dragging = false;
      if (widget.hasPointerCapture(e.pointerId)) widget.releasePointerCapture(e.pointerId);
      widget.classList.remove('dragging');
      widget.style.transition = '';
      savePosition();
    };

    widget.addEventListener('pointerup', endDrag);
    widget.addEventListener('pointercancel', endDrag);
    widget.addEventListener('dragstart', (e) => e.preventDefault(), true);
  }

  function savePosition() {
    const rect = widget.getBoundingClientRect();
    const pos = {
      left: rect.left,
      top: rect.top
    };
    try {
      localStorage.setItem(POS_KEY, JSON.stringify(pos));
    }
    catch {}
  }

  function restorePosition() {
    try {
      const raw = localStorage.getItem(POS_KEY);
      if (!raw) return;
      const pos = JSON.parse(raw);
      if (typeof pos?.left === 'number' && typeof pos?.top === 'number') {
        widget.style.left = `${Math.max(4, Math.min(pos.left, window.innerWidth - widget.offsetWidth - 4))}px`;
        widget.style.top = `${Math.max(4, Math.min(pos.top, window.innerHeight - widget.offsetHeight - 4))}px`;
        widget.style.right = 'auto';
        widget.style.bottom = 'auto';
      }
    }
    catch {}
  }

  function sanitizeFilename(name) {
    const cleaned = name.replace(/[/\\?%*:|"<>]/g, '_').trim();
    return cleaned || 'audio';
  }

  function guessExtensionFromUrl(urlStr) {
    const lower = urlStr.toLowerCase();
    if (lower.includes('.wav')) return 'wav';
    if (lower.includes('.ogg') || lower.includes('.oga')) return 'ogg';
    if (lower.includes('.m4a')) return 'm4a';
    if (lower.includes('.aac')) return 'aac';
    if (lower.includes('.flac')) return 'flac';
    if (lower.includes('.opus')) return 'opus';
    if (lower.includes('.mp3')) return 'mp3';
    return '';
  }

  function extFromContentType(ct) {
    if (!ct) return '';
    ct = ct.toLowerCase();
    if (ct.includes('audio/mpeg') || ct.includes('audio/mp3')) return 'mp3';
    if (ct.includes('audio/ogg')) return 'ogg';
    if (ct.includes('audio/opus')) return 'opus';
    if (ct.includes('audio/aac')) return 'aac';
    if (ct.includes('audio/wav') || ct.includes('audio/x-wav')) return 'wav';
    if (ct.includes('audio/flac')) return 'flac';
    if (ct.includes('audio/mp4') || ct.includes('audio/m4a')) return 'm4a';
    return '';
  }

  function inferFilenameFromUrl(urlStr) {
    try {
      const u = new URL(url, location.href);
      let base = decodeURIComponent(u.pathname.split('/').pop() || '').trim();
      if (!base) base = 'audio';
      if (!/\.[a-z0-9]{2,5}$/i.test(base)) {
        const ext = guessExtensionFromUrl(urlStr) || 'mp3';
        base += `.${ext}`;
      }
      return sanitizeFilename(base);
    }
    catch {
      return 'audio.mp3';
    }
  }

  function getAudioCandidates() {
    const audios = Array.from(document.querySelectorAll('audio'));
    const out = [];
    for (const audio of audios) {
      let src = '';
      if (audio.hasAttribute('src')) {
        const raw = audio.getAttribute('src') || '';
        if (raw.trim()) {
          try {
            src = new URL(raw, location.href).href;
          }
          catch {
            src = raw;
          }
        }
      }
      if (!src) {
        const source = audio.querySelector('source[src]');
        if (source) {
          const raw = source.getAttribute('src') || '';
          if (raw.trim()) {
            try {
              src = new URL(raw, location.href).href;
            }
            catch {
              src = raw;
            }
          }
        }
      }
      if (!src && audio.currentSrc) src = audio.currentSrc;
      if (src) out.push({
        audio,
        src
      });
    }
    return out;
  }

  function selectPrimaryAudio() {
    const list = getAudioCandidates();
    if (!list.length) return null;
    const playing = list.find(x => !x.audio.paused && x.audio.currentTime > 0 && !x.audio.ended);
    return playing || list[0];
  }

  // Track info from page HTML (provided example)
  function getTrackInfoFromPage() {
    const $ = (sel) => document.querySelector(sel);
    const title = textOf($('[data-testid="currentTrackTitle"]'));
    const genre = textOf($('[data-testid="trackGenre"]'));
    const effect = textOf($('[data-testid="trackNeuralEffect"]'));
    return {
      title: title || '',
      type: genre || '',
      effect: effect || ''
    };
  }

  function textOf(el) {
    return el ? el.textContent.trim() : '';
  }

  function buildDescriptiveBaseName(fallbackBase) {
    const info = getTrackInfoFromPage();
    const parts = [];
    if (info.title) parts.push(info.title);
    if (info.type) parts.push(info.type);
    // If you also want the effect, uncomment:
    // if (info.effect) parts.push(info.effect);
    const base = parts.length ? parts.join(' - ') : fallbackBase;
    return sanitizeFilename(base || 'audio');
  }

  function parseFilenameFromContentDisposition(cd) {
    if (!cd) return '';
    let m = cd.match(/filename\*\s*=\s*[^']*'[^']*'([^;]+)$/i);
    if (m) {
      try {
        return decodeURIComponent(m[1]);
      }
      catch {
        return m[1];
      }
    }
    m = cd.match(/filename\s*=\s*("?)([^";]+)\1/i);
    if (m) return m[2];
    return '';
  }

  function getHeader(headersStr, key) {
    if (!headersStr) return '';
    const lines = headersStr.split(/\r?\n/);
    key = key.toLowerCase();
    for (const line of lines) {
      const i = line.indexOf(':');
      if (i > -1) {
        const k = line.slice(0, i).trim().toLowerCase();
        if (k === key) return line.slice(i + 1).trim();
      }
    }
    return '';
  }

  function buildFullFilenameFromHints(url, fallbackBaseName, headers) {
    let ext = guessExtensionFromUrl(url);
    const ct = getHeader(headers, 'content-type');
    if (!ext) ext = extFromContentType(ct);
    if (!ext) {
      const inferred = inferFilenameFromUrl(url);
      ext = (inferred.split('.').pop() || '').toLowerCase() || 'mp3';
    }
    const cdName = parseFilenameFromContentDisposition(getHeader(headers, 'content-disposition'));
    if (cdName) {
      const name = /\.[a-z0-9]{2,5}$/i.test(cdName) ? cdName : `${cdName}.${ext || 'mp3'}`;
      return sanitizeFilename(name);
    }
    const base = buildDescriptiveBaseName(fallbackBaseName.replace(/\.[^.]+$/, ''));
    return sanitizeFilename(`${base}.${ext || 'mp3'}`);
  }

  function setWidgetState() {
    const item = selectPrimaryAudio();
    if (!item) {
      btn.disabled = true;
      meta.textContent = 'No audio found';
      btn.title = 'No audio found';
      meta.title = 'No audio found';
      return;
    }
    const fallback = inferFilenameFromUrl(item.src);
    const base = buildDescriptiveBaseName(fallback.replace(/\.[^.]+$/, ''));
    const fullName = buildFullFilenameFromHints(item.src, fallback, '');
    meta.textContent = base;
    btn.title = fullName;
    meta.title = fullName;
    btn.disabled = downloading ? true : false;
    if (!downloading && btn.textContent !== BTN_DEFAULT) btn.textContent = BTN_DEFAULT;
  }

  async function onDownloadClick() {
    if (downloading) return;
    const item = selectPrimaryAudio();
    if (!item) return;

    const url = item.src;
    const fallback = inferFilenameFromUrl(url);
    const niceName = buildFullFilenameFromHints(url, fallback, '');

    // Show only "Starting..." while we fetch
    downloading = true;
    btn.disabled = true;
    btn.textContent = 'Starting...';

    // Try GM_download
    if (typeof GM_download === 'function') {
      try {
        GM_download({
          url,
          name: niceName,
          saveAs: true,
          onload: () => {
            finish(true, 'Saved');
          },
          onerror: () => {
            gmXhrDownload(url, fallback).then(
              () => finish(true, 'Saved'),
              () => {
                finish(false, 'Failed');
                anchorDownload(url, fallback);
              }
            );
          },
          ontimeout: () => {
            gmXhrDownload(url, fallback).then(
              () => finish(true, 'Saved'),
              () => {
                finish(false, 'Timeout');
                anchorDownload(url, fallback);
              }
            );
          },
          timeout: 120000
        });
        return;
      }
      catch {
        // fall through
      }
    }

    // GM_xhr fallback
    try {
      await gmXhrDownload(url, fallback);
      finish(true, 'Saved');
    }
    catch {
      finish(false, 'Opening…');
      anchorDownload(url, fallback);
    }
  }

  function finish(success, label) {
    downloading = false;
    btn.disabled = false;
    btn.textContent = label || (success ? 'Saved' : 'Failed');
    setTimeout(() => {
      setWidgetState();
    }, 1300);
  }

  function gmXhrDownload(url, fallbackBase) {
    return new Promise((resolve, reject) => {
      const GMXHR =
        typeof GM_xmlhttpRequest === 'function' ?
        GM_xmlhttpRequest :
        (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function') ?
        (opts) => GM.xmlHttpRequest(opts) :
        null;

      if (!GMXHR) {
        flashError();
        reject(new Error('GM_xhr not available'));
        return;
      }

      GMXHR({
        method: 'GET',
        url,
        responseType: 'arraybuffer',
        headers: {
          Referer: location.href,
          Accept: 'audio/*;q=0.9,*/*;q=0.5',
          'Cache-Control': 'no-cache'
        },
        onload: (resp) => {
          try {
            const buf = resp.response;
            if (!buf) throw new Error('Empty response');
            const ct = getHeader(resp.responseHeaders, 'content-type') || 'application/octet-stream';
            const name = buildFullFilenameFromHints(url, fallbackBase, resp.responseHeaders);
            const blob = new Blob([new Uint8Array(buf)], {
              type: ct
            });

            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = name;
            a.rel = 'noopener';
            document.body.appendChild(a);
            a.click();
            URL.revokeObjectURL(a.href);
            a.remove();
            resolve();
          }
          catch (e) {
            flashError();
            reject(e);
          }
        },
        onerror: (e) => {
          flashError();
          reject(e);
        },
        ontimeout: (e) => {
          flashError();
          reject(e);
        },
        timeout: 120000
      });
    });
  }

  function anchorDownload(url, fallbackBase) {
    const name = buildFullFilenameFromHints(url, fallbackBase, '');
    const a = document.createElement('a');
    a.href = url;
    a.download = name; // may be ignored cross-origin
    a.rel = 'noopener';
    a.target = '_blank';
    document.body.appendChild(a);
    a.click();
    a.remove();
  }

  function flashError() {
    widget.classList.add('error');
    setTimeout(() => widget.classList.remove('error'), 900);
  }

  function updateAll() {
    setWidgetState();
    attachListenersToAudios();
  }

  // Observe DOM (audio src updates + track info text)
  let mo;

  function startObserver() {
    if (mo) return;
    mo = new MutationObserver(debounce(updateAll, 200));
    mo.observe(document.documentElement || document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      characterData: true
    });
  }

  // Media event listeners
  const listened = new WeakSet();

  function attachListenersToAudios() {
    document.querySelectorAll('audio').forEach(a => {
      if (listened.has(a)) return;
      listened.add(a);
      ['loadedmetadata', 'canplay', 'play', 'pause', 'ended', 'emptied', 'stalled', 'suspend']
      .forEach(ev => a.addEventListener(ev, () => setWidgetState(), {
        passive: true
      }));
      a.querySelectorAll('source').forEach(s => {
        ['load', 'error'].forEach(ev =>
          s.addEventListener(ev, () => setWidgetState(), {
            passive: true
          })
        );
      });
    });
  }

  function debounce(fn, wait) {
    let t;
    return (...args) => {
      clearTimeout(t);
      t = setTimeout(() => fn.apply(null, args), wait);
    };
  }

  // Init
  createWidget();
  updateAll();
  startObserver();
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') updateAll();
  });
  setTimeout(updateAll, 1500);
})();