Last active
March 6, 2026 16:24
-
-
Save ewerybody/f926e9c4c76d9a6c96f3b8c60ba7e391 to your computer and use it in GitHub Desktop.
Bandcamp Inline player layout tweak, trackbar clickable, space to toggle play.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Bandcamp Player Tweaks | |
| // @namespace https://github.com/ewerybody/ | |
| // @version 1.14.9 | |
| // @description Inline player layout fix, waveform seek, settings panel. | |
| // @author ewerybody | |
| // @match https://*.bandcamp.com/* | |
| // @match https://bandcamp.com/* | |
| // @grant none | |
| // @run-at document-end | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const DEFAULTS = { | |
| bg: '#222', fg: '#ccc', accent: '#f0f', | |
| play_head_width: 2, prog_bar_height: 20, | |
| show_tooltip: true, space_bar_play: true, | |
| }; | |
| const BORDER_RADIUS = 2; | |
| const POLL_MS = 500; | |
| const COOKIE_KEY = 'bc-player-tweaks-cfg'; | |
| const pre_style = document.createElement('style'); | |
| pre_style.textContent = '.inline_player table { visibility: hidden; }'; | |
| document.head.appendChild(pre_style); | |
| function load_settings() { | |
| try { | |
| const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${COOKIE_KEY}=([^;]*)`)); | |
| return Object.assign({}, DEFAULTS, match ? JSON.parse(decodeURIComponent(match[1])) : {}); | |
| } catch { return Object.assign({}, DEFAULTS); } | |
| } | |
| function save_settings(cfg) { | |
| try { | |
| const val = encodeURIComponent(JSON.stringify(cfg)); | |
| const exp = new Date(Date.now() + 365 * 864e5).toUTCString(); // 1 year | |
| document.cookie = `${COOKIE_KEY}=${val}; domain=.bandcamp.com; path=/; expires=${exp}; SameSite=Lax`; | |
| } catch { } | |
| } | |
| function wait_for(selector, cb, interval = POLL_MS, maxTries = 40) { | |
| let tries = 0; | |
| const t = setInterval(() => { | |
| const el = document.querySelector(selector); | |
| if (el) { clearInterval(t); cb(el); } | |
| else if (++tries >= maxTries) { clearInterval(t); } | |
| }, interval); | |
| } | |
| function get_page_colors() { | |
| const pgBd = document.getElementById('pgBd'); | |
| if (pgBd) { | |
| const cs = getComputedStyle(pgBd); | |
| const bg = css_color_to_hex(cs.backgroundColor); | |
| const fg = css_color_to_hex(cs.color); | |
| const accentEl = document.querySelector('.custom-link-color, #trackInfo a, .tralbum-tags a'); | |
| const accent = accentEl ? css_color_to_hex(getComputedStyle(accentEl).color) : fg; | |
| if (bg && fg) return { bg, fg, accent: accent ?? fg }; | |
| } | |
| return { bg: DEFAULTS.bg, fg: DEFAULTS.fg, accent: DEFAULTS.accent }; | |
| } | |
| function css_color_to_hex(color) { | |
| const m = color.match(/(\d+),\s*(\d+),\s*(\d+)/); | |
| if (!m) return null; | |
| return '#' + [m[1], m[2], m[3]].map(n => parseInt(n).toString(16).padStart(2, '0')).join(''); | |
| } | |
| const cfg = load_settings(); | |
| wait_for('audio', audio => init(audio, cfg)); | |
| wait_for('#track_table', hoist_track_table); | |
| function apply_styles(style_element, cfg) { | |
| const pcs = get_page_colors(); | |
| style_element.textContent = ` | |
| .inline_player { | |
| padding: 0 !important; | |
| margin: 1em 0 0 0 !important; | |
| position: relative !important; | |
| } | |
| #bc-controls-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 2px; padding: 4px 0px; | |
| } | |
| #bc-controls-row .playbutton { flex-shrink: 0; } | |
| #bc-info-wrap { | |
| flex: 1; display: flex; flex-direction: column; | |
| min-width: 0; padding: 0 6px; | |
| justify-content: center; overflow: hidden; | |
| } | |
| #bc-info-wrap .title, | |
| #bc-info-wrap .track-title { | |
| white-space: nowrap !important; | |
| overflow: hidden !important; | |
| text-overflow: ellipsis !important; font-weight: 600 !important; | |
| } | |
| #bc-info-wrap .time, | |
| #bc-info-wrap .time-control { | |
| font-size: 11px !important; opacity: 0.65 !important; | |
| font-variant-numeric: tabular-nums !important; | |
| } | |
| #bc-inline-cfg-btn { | |
| flex-shrink: 0; | |
| color: ${pcs.accent}; background: none; border: none; cursor: pointer; | |
| font-size: 14px; line-height: 1; padding: 0 4px; | |
| opacity: 1.0; transition: opacity 0.15s; | |
| } | |
| #bc-inline-cfg-btn:hover { opacity: 1; } | |
| #bc-controls-row .prevbutton, | |
| #bc-controls-row .nextbutton { flex-shrink: 0; } | |
| #bc-seek-row { width: 100%; padding: 4px 0; } | |
| #bc-seek-row .progbar_empty, | |
| #bc-seek-row .progbar { | |
| cursor: pointer !important; | |
| display: block !important; | |
| width: 100% !important; height: ${cfg.prog_bar_height}px !important; | |
| overflow: visible !important; | |
| margin: 0 !important; box-sizing: border-box !important; | |
| border-radius: ${BORDER_RADIUS}px; | |
| } | |
| #bc-seek-row .progbar .thumb { | |
| width: ${cfg.play_head_width}px !important; | |
| height: ${cfg.prog_bar_height}px !important; | |
| top: -4px; padding: 6px 0 0 0 !important; | |
| min-width: unset !important; max-width: unset !important; | |
| min-height: unset !important; max-height: unset !important; | |
| margin-left: -${Math.floor(cfg.play_head_width / 2)}px !important; | |
| border: none !important; | |
| box-sizing: content-box !important; | |
| background: ${pcs.accent} !important; | |
| opacity: 0.9 !important; | |
| box-shadow: none !important; | |
| } | |
| #bc-seek-tooltip { | |
| position: fixed; background: ${pcs.fg}; color: ${pcs.bg}; | |
| font-size: 11px; font-family: 'Helvetica Neue', Arial, sans-serif; | |
| padding: 2px 6px; border-radius: ${BORDER_RADIUS}px; pointer-events: none; | |
| z-index: 99998; display: none; transform: translate(-50%, -130%); | |
| } | |
| #bc-settings-panel { | |
| position: absolute; z-index: 999999; right: 0; top: 100%; | |
| background: ${pcs.bg}; color: ${pcs.fg}; | |
| font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 13px; | |
| border-radius: ${BORDER_RADIUS}px; padding: 20px 24px; width: 300px; | |
| box-shadow: 0 6px 24px ${pcs.fg}80, 0 0 0 1px ${pcs.fg}80; | |
| } | |
| #bc-settings-panel h2 { margin: 0 0 4px; font-size: 14px; font-weight: 700; color: ${pcs.fg}; } | |
| .bc-settings-hint { font-size: 11px; opacity: 0.4; margin-bottom: 16px; line-height: 1.5; color: ${pcs.fg}} | |
| .bc-setting-row { | |
| display: flex; align-items: center; justify-content: space-between; | |
| margin-bottom: 12px; gap: 10px; | |
| } | |
| .bc-setting-row label { flex: 1; opacity: 0.85; color: ${pcs.fg}; } | |
| .bc-setting-row label.bc-muted { opacity: 0.35; } | |
| .bc-setting-row input[type=number] { | |
| width: 58px; background: #2e2e2e; border: 1px solid #444; | |
| border-radius: ${BORDER_RADIUS}px; color: #eee; padding: 3px 6px; font-size: 13px; flex-shrink: 0; | |
| } | |
| .bc-setting-row input[type=checkbox] { | |
| width: 15px; height: 15px; cursor: pointer; accent-color: ${pcs.accent}; flex-shrink: 0; | |
| } | |
| .bc-settings-buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } | |
| .bc-settings-buttons button { | |
| padding: 5px 14px; border-radius: ${BORDER_RADIUS}px; border: none; | |
| cursor: pointer; font-size: 12px; font-weight: 600; | |
| } | |
| #bc-settings-save { background: ${pcs.accent}; color: ${pcs.bg}; } | |
| #bc-settings-cancel { background: #333; color: #aaa; } | |
| #bc-settings-reset { background: none; color: #555; border: 1px solid #333 !important; | |
| margin-right: auto; font-weight: 400 !important; } | |
| `; | |
| } | |
| function init(audio, cfg) { | |
| const style = document.createElement('style'); | |
| style.id = 'bc-player-tweaks-styles'; | |
| document.head.appendChild(style); | |
| apply_styles(style, cfg); | |
| const tooltip = document.createElement('div'); | |
| tooltip.id = 'bc-seek-tooltip'; | |
| document.body.appendChild(tooltip); | |
| function show_tooltip(e, secs) { | |
| if (!cfg.show_tooltip) return; | |
| tooltip.textContent = format_time(secs); | |
| tooltip.style.left = e.clientX + 'px'; | |
| tooltip.style.top = e.clientY + 'px'; | |
| tooltip.style.display = 'block'; | |
| } | |
| function hide_tooltip() { tooltip.style.display = 'none'; } | |
| // restructure inline player DOM | |
| // Hide BC's table, pull out known elements into two clean wrapper divs. | |
| wait_for('.inline_player', inlinePlayer => { | |
| const table = inlinePlayer.querySelector('table'); | |
| if (!table) return; | |
| const playBtn = inlinePlayer.querySelector('.playbutton'); | |
| const prevBtn = inlinePlayer.querySelector('.prevbutton'); | |
| const nextBtn = inlinePlayer.querySelector('.nextbutton'); | |
| const progbar = inlinePlayer.querySelector('.progbar, .progbar_empty'); | |
| // find info td — the one that contains neither buttons nor progbar | |
| let infoTd = null; | |
| for (const td of inlinePlayer.querySelectorAll('td')) { | |
| if (!td.contains(playBtn) && !td.contains(prevBtn) && | |
| !td.contains(nextBtn) && !td.contains(progbar)) { | |
| infoTd = td; break; | |
| } | |
| } | |
| const ctrlRow = document.createElement('div'); | |
| ctrlRow.id = 'bc-controls-row'; | |
| if (playBtn) ctrlRow.appendChild(playBtn); | |
| const infoWrap = document.createElement('div'); | |
| infoWrap.id = 'bc-info-wrap'; | |
| if (infoTd) while (infoTd.firstChild) infoWrap.appendChild(infoTd.firstChild); | |
| ctrlRow.appendChild(infoWrap); | |
| const cfgBtn = document.createElement('button'); | |
| cfgBtn.id = 'bc-inline-cfg-btn'; | |
| cfgBtn.textContent = '⛭'; | |
| cfgBtn.title = 'Player settings'; | |
| cfgBtn.addEventListener('click', e => open_settings_panel(cfg, style, e.currentTarget)); | |
| ctrlRow.appendChild(cfgBtn); | |
| if (prevBtn) ctrlRow.appendChild(prevBtn); | |
| if (nextBtn) ctrlRow.appendChild(nextBtn); | |
| const seekRow = document.createElement('div'); | |
| seekRow.id = 'bc-seek-row'; | |
| if (progbar) seekRow.appendChild(progbar); | |
| // hide BC's table, append our rows | |
| table.style.display = 'none'; | |
| inlinePlayer.appendChild(ctrlRow); | |
| inlinePlayer.appendChild(seekRow); | |
| }); | |
| wait_for('#bc-seek-row .progbar, #bc-seek-row .progbar_empty', progbar => { | |
| progbar.addEventListener('click', e => { | |
| if (audio.duration) audio.currentTime = frac_of(e, progbar) * audio.duration; | |
| if (audio.paused) document.querySelector('.playbutton')?.click(); | |
| }, true); | |
| progbar.addEventListener('mousemove', e => { if (audio.duration) show_tooltip(e, frac_of(e, progbar) * audio.duration); }); | |
| progbar.addEventListener('mouseleave', hide_tooltip); | |
| }); | |
| pre_style.remove(); | |
| // register space for play toggle | |
| document.addEventListener('keydown', e => { | |
| if (!cfg.space_bar_play) return; | |
| if (e.key !== ' ') return; | |
| const tag = document.activeElement?.tagName; | |
| if (tag === 'INPUT' || tag === 'TEXTAREA') return; | |
| e.preventDefault(); | |
| document.querySelector('.playbutton')?.click(); | |
| }); | |
| } // end of init() | |
| function open_settings_panel(cfg, style_element, anchor) { | |
| const existing = document.getElementById('bc-settings-panel'); | |
| if (existing) { existing.remove(); return; } | |
| const panel = document.createElement('div'); | |
| panel.id = 'bc-settings-panel'; | |
| panel.innerHTML = ` | |
| <h2>Player Tweaks</h2> | |
| <p class="bc-settings-hint">Changes preview live. Save to persist.</p> | |
| <div class="bc-setting-row"> | |
| <label>Playhead width (px)</label> | |
| <input type="number" id="bcs-play-head-width" value="${cfg.play_head_width}" min="1" max="12"> | |
| </div> | |
| <div class="bc-setting-row"> | |
| <label>Seek bar height (px)</label> | |
| <input type="number" id="bcs-prog-bar-height" value="${cfg.prog_bar_height}" min="4" max="48"> | |
| </div> | |
| <div class="bc-setting-row"> | |
| <label>Show seek tooltip</label> | |
| <input type="checkbox" id="bcs-show-tooltip" ${cfg.show_tooltip ? 'checked' : ''}> | |
| </div> | |
| <div class="bc-setting-row"> | |
| <label>Toggle Play/Pause on Space</label> | |
| <input type="checkbox" id="bcs-space-bar-play" ${cfg.space_bar_play ? 'checked' : ''}> | |
| </div> | |
| <div class="bc-settings-buttons"> | |
| <button id="bc-settings-reset">Reset</button> | |
| <button id="bc-settings-cancel">Cancel</button> | |
| <button id="bc-settings-save">Save</button> | |
| </div> | |
| `; | |
| const player = document.querySelector('.inline_player'); | |
| player.appendChild(panel); | |
| function read_panel() { | |
| return { | |
| play_head_width: parseInt(document.getElementById('bcs-play-head-width').value, 10) || 2, | |
| prog_bar_height: parseInt(document.getElementById('bcs-prog-bar-height').value, 10) || 12, | |
| show_tooltip: document.getElementById('bcs-show-tooltip').checked, | |
| space_bar_play: document.getElementById('bcs-space-bar-play').checked, | |
| }; | |
| } | |
| function close_cancel() { apply_styles(style_element, cfg); panel.remove(); } | |
| panel.addEventListener('input', () => { | |
| const next = read_panel(); | |
| cfg.show_tooltip = next.show_tooltip; | |
| apply_styles(style_element, next); | |
| }); | |
| function on_key_down(e) { | |
| if (e.key === 'Escape') { | |
| close_cancel(); | |
| document.removeEventListener('keydown', on_key_down); | |
| } | |
| } | |
| document.addEventListener('keydown', on_key_down); | |
| function on_outside_click(e) { | |
| if (!panel.contains(e.target) && e.target !== anchor) { | |
| close_cancel(); | |
| document.removeEventListener('click', on_outside_click, true); | |
| } | |
| } | |
| setTimeout(() => document.addEventListener('click', on_outside_click, true), 0); | |
| document.getElementById('bc-settings-save').addEventListener('click', () => { | |
| const next = read_panel(); | |
| Object.assign(cfg, next); | |
| save_settings(cfg); | |
| cleanup(); | |
| panel.remove(); | |
| }); | |
| document.getElementById('bc-settings-cancel').addEventListener('click', () => { | |
| cleanup(); | |
| close_cancel(); | |
| }); | |
| document.getElementById('bc-settings-reset').addEventListener('click', () => { | |
| document.getElementById('bcs-play-head-width').value = DEFAULTS.play_head_width; | |
| document.getElementById('bcs-prog-bar-height').value = DEFAULTS.prog_bar_height; | |
| document.getElementById('bcs-show-tooltip').checked = DEFAULTS.show_tooltip; | |
| document.getElementById('bcs-space-bar-play').checked = DEFAULTS.space_bar_play; | |
| apply_styles(style_element, read_panel()); | |
| }); | |
| function cleanup() { | |
| document.removeEventListener('click', on_outside_click, true); | |
| document.removeEventListener('keydown', on_key_down); | |
| } | |
| } | |
| // move track table right under the inline player | |
| function hoist_track_table(table) { | |
| const player = document.querySelector('.inline_player'); | |
| if (!player) return; | |
| if (player.nextElementSibling === table) return; | |
| player.insertAdjacentElement('afterend', table); | |
| } | |
| function frac_of(e, el) { | |
| const rect = el.getBoundingClientRect(); | |
| return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); | |
| } | |
| function format_time(secs) { | |
| if (!isFinite(secs)) return '0:00'; | |
| const m = Math.floor(secs / 60); | |
| const s = Math.floor(secs % 60); | |
| return `${m}:${s.toString().padStart(2, '0')}`; | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment