Extends Bandcamp with improved player controls, keyboard shortcuts, wishlist actions, BPM tap tempo, and speed adjustment.
// ==UserScript==
// @name DIG BC
// @namespace http://violentmonkey.net/
// @version 1.59
// @match https://*.bandcamp.com/album/*
// @match https://*.bandcamp.com/track/*
// @match https://bandcamp.com/*
// @match https://bandcamp.com/*/wishlist
// @match https://bandcamp.com/*/collection
// @grant none
// @license MIT
// @description Extends Bandcamp with improved player controls, keyboard shortcuts, wishlist actions, BPM tap tempo, and speed adjustment.
// @author bloxb
// ==/UserScript==
(function () {
'use strict';
const SEEK_SMALL = 30;
const SEEK_LARGE = 60;
const VOL_STEP = 0.05;
const VOL_KEY = 'bcp_volume';
const MUTE_KEY = 'bcp_muted';
const SPEED_KEY = 'bcp_speed';
const PRESERVE_PITCH_KEY = 'bcp_preserve_pitch';
const TICK_MS = 500;
const TAP_RESET_MS = 2500;
const TAP_MAX_SAMPLES = 8;
const SPEED_MIN = 0.5;
const SPEED_MAX = 1.5;
const SPEED_STEP = 0.05;
const SPEED_DEFAULT = 1.0;
let controls = null;
let lastTagStr = '';
let lastAlbumStr = '';
let lastMetaText = '';
let lastTitleText = '';
let preloadDone = false;
let preloadReady = false;
let shortcutsOverlay = null;
let bpmTapTimes = [];
let bpmTapResetTimer = null;
let currentTappedBpm = null;
function getAudio() {
return document.querySelector('audio');
}
function getNativeBtn(sel) {
return document.querySelector(sel);
}
function fmt(s) {
if (!isFinite(s) || isNaN(s)) return '0:00';
s = Math.floor(s);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function fmtSpeed(v) {
return `${Number(v).toFixed(2)}x`;
}
function seekRelative(sec) {
const a = getAudio();
if (!a) return;
a.currentTime = Math.max(0, Math.min(a.duration || Infinity, a.currentTime + sec));
}
function cleanText(text) {
return (text || '').replace(/\s+/g, ' ').trim();
}
function stripTrailingDuration(text) {
return cleanText(text).replace(/\s+\d{1,2}:\d{2}(?::\d{2})?$/, '');
}
function isTrackPage() {
return /\/track\//.test(window.location.pathname);
}
function isCollectionPage() {
return /\/(wishlist|collection)/.test(window.location.pathname) || /^\/[^/]+$/.test(window.location.pathname);
}
function hasMultipleTracks() {
const tracks = getAllTracks().filter(Boolean);
return tracks.length > 1;
}
function isCloseOverlayKey(event) {
return event.key === 'Escape' ||
event.key === 'Esc' ||
event.code === 'Escape' ||
event.keyCode === 27 ||
event.key === 'CapsLock' ||
event.code === 'CapsLock';
}
function isElementVisible(el) {
if (!el) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function isTypingTarget(el) {
if (!el || !el.tagName) return false;
if (el.isContentEditable) return true;
const tag = el.tagName.toUpperCase();
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (typeof el.closest === 'function') {
// Check if element or any parent has these attributes/classes
if (el.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""], [role="textbox"], .search, .search-field, .search_input, .site-search-form, [type="search"], .search-instant-inline')) {
return true;
}
// Check if we're inside a search form
if (el.closest('form.site-search-form') || el.closest('[role="search"]')) {
return true;
}
}
// Direct check for input elements
if (tag === 'INPUT' && (el.type === 'text' || el.type === 'search' || el.type === 'email' || el.type === 'password')) {
return true;
}
return false;
}
function getRealActiveElement() {
let el = document.activeElement;
while (el && el.shadowRoot && el.shadowRoot.activeElement) {
el = el.shadowRoot.activeElement;
}
return el;
}
function isTypingEvent(e) {
if (!e) return false;
const activeEl = getRealActiveElement();
if (isTypingTarget(activeEl)) return true;
if (e.composedPath) {
const path = e.composedPath();
for (const el of path) {
if (isTypingTarget(el)) return true;
}
}
return isTypingTarget(e.target) || isTypingTarget(document.activeElement);
}
async function copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (e) {}
try {
const tempInput = document.createElement('input');
tempInput.value = text;
document.body.appendChild(tempInput);
tempInput.select();
tempInput.setSelectionRange(0, tempInput.value.length);
const ok = document.execCommand('copy');
document.body.removeChild(tempInput);
return ok;
} catch (e) {
return false;
}
}
function getPlayerHeight() {
const bar = document.getElementById('bc-sticky-player');
return bar ? Math.ceil(bar.getBoundingClientRect().height) : 88;
}
function showPopup(message) {
const popup = document.createElement('div');
popup.textContent = message;
popup.style.position = 'fixed';
popup.style.bottom = `${getPlayerHeight() + 12}px`;
popup.style.right = '10px';
popup.style.padding = '10px';
popup.style.backgroundColor = '#f7f7f7';
popup.style.color = '#222';
popup.style.border = '1px solid #cfd6dc';
popup.style.borderRadius = '5px';
popup.style.boxShadow = '0 2px 10px rgba(0,0,0,0.08)';
popup.style.zIndex = '1000001';
popup.style.fontSize = '14px';
popup.style.maxWidth = '420px';
popup.style.wordBreak = 'break-word';
document.body.appendChild(popup);
setTimeout(() => popup.remove(), 2000);
}
function ensureShortcutsOverlay() {
if (shortcutsOverlay) return;
shortcutsOverlay = document.createElement('div');
shortcutsOverlay.id = 'bcp-shortcuts-overlay';
shortcutsOverlay.innerHTML = `
<div id="bcp-shortcuts-backdrop"></div>
<div id="bcp-shortcuts-modal">
<div id="bcp-shortcuts-header">
<span>Bandcamp Player Shortcuts</span>
<button id="bcp-shortcuts-close" type="button">✕</button>
</div>
<div id="bcp-shortcuts-body">
<div class="bcp-shortcut-row"><span class="bcp-k">Space</span><span>Play / Pause</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">Shift + Space</span><span>Scroll down</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">↑</span><span>Previous track</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">↓</span><span>Next track</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">Shift + ↑ / ↓</span><span>Volume up / down</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">← / →</span><span>Seek ±30s</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">Shift + ← / →</span><span>Seek ±60s</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">C</span><span>Copy current track as “Title - Artist”</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">F</span><span>Open current track page</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">W</span><span>Add current item to wishlist</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">Shift + W</span><span>Remove current item from wishlist</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">? / ß</span><span>Show this shortcuts help</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">Escape / CapsLock</span><span>Close help / dropdown</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">Click BPM</span><span>Tap tempo</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">Speed slider</span><span>Adjust playback speed from 0.5x to 1.5x</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">Double-click speed label</span><span>Reset speed to 1.00x</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">PP toggle</span><span>Toggle preserve pitch if supported</span></div>
<div class="bcp-shortcut-row"><span class="bcp-k">HELP</span><span>Open this shortcuts help</span></div>
</div>
</div>
`;
const style = document.createElement('style');
style.textContent = `
#bcp-shortcuts-overlay {
position: fixed;
inset: 0;
z-index: 1000000;
display: none;
}
#bcp-shortcuts-overlay.open {
display: block;
}
#bcp-shortcuts-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
#bcp-shortcuts-modal {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(680px, 92vw);
max-height: 80vh;
overflow: auto;
background: #f7f7f7;
border: 1px solid #cfd6dc;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.18);
font-family: Helvetica, Arial, sans-serif;
color: #223;
}
#bcp-shortcuts-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid #d8dee3;
background: #f1f3f5;
font-weight: 600;
}
#bcp-shortcuts-close {
border: 1px solid #c8d0d7;
background: #fff;
color: #4f5b66;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
#bcp-shortcuts-close:hover {
border-color: #1da0c3;
color: #1da0c3;
background: #eef9fc;
}
#bcp-shortcuts-body {
padding: 12px 14px 14px;
}
.bcp-shortcut-row {
display: grid;
grid-template-columns: 180px 1fr;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #e5eaee;
font-size: 14px;
}
.bcp-shortcut-row:last-child {
border-bottom: 0;
}
.bcp-k {
display: inline-block;
font-family: "SFMono-Regular", Consolas, Menlo, monospace;
background: #fff;
border: 1px solid #cfd6dc;
border-radius: 4px;
padding: 2px 8px;
color: #1d2b36;
white-space: nowrap;
}
`;
document.head.appendChild(style);
document.body.appendChild(shortcutsOverlay);
shortcutsOverlay.querySelector('#bcp-shortcuts-backdrop').addEventListener('click', hideShortcutsOverlay);
shortcutsOverlay.querySelector('#bcp-shortcuts-close').addEventListener('click', hideShortcutsOverlay);
}
function showShortcutsOverlay() {
ensureShortcutsOverlay();
shortcutsOverlay.classList.add('open');
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
}
function hideShortcutsOverlay() {
if (!shortcutsOverlay) return;
shortcutsOverlay.classList.remove('open');
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
}
function isShortcutsOverlayOpen() {
return !!shortcutsOverlay && shortcutsOverlay.classList.contains('open');
}
function getWishlistStateElements() {
return {
add:
document.querySelector('#wishlist-msg') ||
document.querySelector('.wishlist-msg'),
inWishlist:
document.querySelector('#wishlisted-msg') ||
document.querySelector('.wishlisted-msg'),
purchased:
document.querySelector('#purchased-msg') ||
document.querySelector('.purchased-msg')
};
}
function clickElement(el) {
if (!el) return false;
el.dispatchEvent(new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true
}));
return true;
}
function addCurrentTrackToWishlist() {
const { add, inWishlist, purchased } = getWishlistStateElements();
function getClickableWishlistTarget(el) {
if (!el) return null;
return el.querySelector('.trigger') ||
el.querySelector('a') ||
el;
}
if (isElementVisible(add)) {
const target = getClickableWishlistTarget(add);
clickElement(target);
showPopup('Added to wishlist');
return;
}
if (isElementVisible(inWishlist)) {
showPopup('Already in wishlist');
return;
}
if (isElementVisible(purchased)) {
showPopup('You already own this item');
return;
}
const fallback =
document.querySelector('.wishlist-msg .trigger') ||
document.querySelector('.wishlist-msg a') ||
document.querySelector('.wishlist-msg') ||
document.querySelector('#collect-item .action[title*="wishlist" i]') ||
document.querySelector('#collect-item .collect-msg a') ||
document.querySelector('.wishlist-button') ||
document.querySelector('.collect-item-button');
if (fallback && isElementVisible(fallback.closest('.wishlist-msg') || fallback)) {
clickElement(fallback);
showPopup('Wishlist action triggered');
return;
}
showPopup('Wishlist button not found');
}
function removeCurrentTrackFromWishlist() {
const { inWishlist } = getWishlistStateElements();
if (!isElementVisible(inWishlist)) {
showPopup('Not in wishlist');
return;
}
// Try to find direct unwishlist / remove elements
const removeSelectors = [
'.unwishlist-anchor',
'.unwish',
'.remove-unwishlist',
'#collect-item .uncollect-msg a',
'.wishlist-msg .unwish',
'a[title*="remove from wishlist" i]',
'button[title*="remove from wishlist" i]',
'.wishlisted-msg .trigger',
'.wishlisted-msg a'
];
for (const sel of removeSelectors) {
const el = document.querySelector(sel);
if (el && isElementVisible(el)) {
clickElement(el);
showPopup('Removed from wishlist');
return;
}
}
// If not found directly, let's look for any link/button with "remove" inside the wishlisted message
if (inWishlist) {
const links = Array.from(inWishlist.querySelectorAll('a, span, button'));
for (const link of links) {
const txt = (link.textContent || '').trim().toLowerCase();
if (txt.includes('remove') || txt.includes('unwish') || txt.includes('delete')) {
clickElement(link);
showPopup('Removed from wishlist');
return;
}
}
// Fallback: click the trigger in the wishlisted message to toggle or open edit
const trigger = inWishlist.querySelector('.trigger') || inWishlist.querySelector('a') || inWishlist;
if (trigger) {
clickElement(trigger);
// On some layouts, clicking this toggles it off, or opens the edit popup.
// If it opens the edit popup, let's look for a remove link inside the newly visible popup after a brief delay
setTimeout(() => {
const popupRemove =
document.querySelector('.unwishlist-anchor') ||
document.querySelector('a[href*="unwishlist"]') ||
Array.from(document.querySelectorAll('a, span, button')).find(el => {
const txt = (el.textContent || '').trim().toLowerCase();
return isElementVisible(el) && (txt.includes('remove from wishlist') || txt === 'remove' || txt.includes('unwish'));
});
if (popupRemove) {
clickElement(popupRemove);
showPopup('Removed from wishlist');
}
}, 150);
showPopup('Wishlist action triggered');
return;
}
}
showPopup('Remove button not found');
}
function getCollectionActiveItem() {
const selectors = [
'.collection-item-container.playing',
'.collection-item-container.current',
'.collection-item.playing',
'.collection-item.current',
'.track_play_auxiliary.playing',
'.track_play_auxiliary.current',
'.item-playing',
'.is-playing',
'.playing',
'.current_track'
];
for (const selector of selectors) {
const el = document.querySelector(selector);
if (el) {
return el.closest('.collection-item-container, .collection-item, li, tr, div');
}
}
const activePressed = document.querySelector('.track_play_auxiliary[aria-pressed="true"], .track_play_auxiliary.active');
if (activePressed) {
return activePressed.closest('.collection-item-container, .collection-item, li, tr, div');
}
return null;
}
function getCollectionItemTitleFromEl(el) {
if (!el) return '';
const selectors = [
'.collection-item-title',
'.item-title',
'.trackTitle',
'.title',
'.heading',
'.fav-track-title',
'h4',
'h3',
'a[href*="/track/"]',
'a[href*="/album/"]'
];
for (const selector of selectors) {
const node = el.querySelector(selector);
if (node) {
const text = stripTrailingDuration(node.textContent);
if (text) return text;
}
}
return '';
}
function getCollectionItemArtistFromEl(el) {
if (!el) return '';
const selectors = [
'.collection-item-artist',
'.artist',
'.itemsubtext',
'.subhead',
'.byline',
'a[href*=".bandcamp.com"]'
];
for (const selector of selectors) {
const node = el.querySelector(selector);
if (node) {
const text = cleanText(node.textContent);
if (text && text.toLowerCase() !== 'track' && text.toLowerCase() !== 'favorite track') {
return text.replace(/^by\s+/i, '');
}
}
}
return '';
}
function getCollectionItemLinkFromEl(el) {
if (!el) return null;
const link = el.querySelector('a[href*="/track/"], a[href*="/album/"]');
return link?.href || null;
}
function getAlbumInfo() {
let artist = '';
let album = '';
if (window.TralbumData) {
artist = window.TralbumData.artist || '';
album = window.TralbumData.current?.title || '';
}
if (isCollectionPage()) {
const activeItem = getCollectionActiveItem();
const activeArtist = getCollectionItemArtistFromEl(activeItem);
const activeTitle = getCollectionItemTitleFromEl(activeItem);
if (!artist && activeArtist) artist = activeArtist;
if (!album && activeTitle) album = activeTitle;
}
if (!artist) {
const el = document.querySelector('#band-name-location .title, .albumTitle ~ .artist, span[itemprop="byArtist"] span, #name-section p span');
if (el) artist = el.textContent.trim();
}
if (!artist) {
const el = document.querySelector('p.artist-name, .artist span, .band-name');
if (el) artist = el.textContent.trim();
}
if (!album) {
const el = document.querySelector('h2.trackTitle, .albumTitle, [itemprop="name"]');
if (el) album = el.textContent.trim();
}
return { artist, album };
}
function getHeaderArtistName() {
if (isCollectionPage()) {
const activeItem = getCollectionActiveItem();
const artist = getCollectionItemArtistFromEl(activeItem);
if (artist) return artist;
}
const artistLink =
document.querySelector('#name-section h3 a') ||
document.querySelector('#band-name-location a') ||
document.querySelector('#name-section h3 span a') ||
document.querySelector('span[itemprop="byArtist"] a') ||
document.querySelector('span[itemprop="byArtist"] span');
if (artistLink) return cleanText(artistLink.textContent);
const info = getAlbumInfo();
return cleanText(info.artist) || 'Unknown Artist';
}
function looksLikeNonArtistLabel(text) {
const t = cleanText(text).toLowerCase();
const badWords = [
'remix', 'mix', 'edit', 'version', 'live', 'instrumental',
'demo', 'vip', 'rework', 'dub', 'remaster', 'original mix',
'extended mix', 'radio edit', 'acoustic', 'intro', 'outro'
];
return badWords.some(word =>
t === word ||
t.endsWith(' ' + word) ||
t.includes('(' + word) ||
t.includes('[' + word)
);
}
function splitOnDash(title) {
return cleanText(title).split(/\s[-–—]\s/).map(cleanText).filter(Boolean);
}
function parseTitleAndArtist(rawTitle, fallbackArtist) {
const title = cleanText(rawTitle);
const headerArtist = cleanText(fallbackArtist);
const headerLower = headerArtist.toLowerCase();
const dashParts = splitOnDash(title);
if (dashParts.length === 2) {
const [left, right] = dashParts;
const leftLower = left.toLowerCase();
const rightLower = right.toLowerCase();
if (leftLower === headerLower && !looksLikeNonArtistLabel(left)) {
return { title: right, artist: headerArtist };
}
if (rightLower === headerLower && !looksLikeNonArtistLabel(right)) {
return { title: left, artist: headerArtist };
}
if (looksLikeNonArtistLabel(left) || looksLikeNonArtistLabel(right)) {
return { title, artist: headerArtist };
}
if (left.length <= 40 && right.length > left.length) {
return { title: right, artist: left };
}
if (right.length <= 40 && left.length > right.length) {
return { title: left, artist: right };
}
}
return { title, artist: headerArtist };
}
function getCollectionTracks() {
const cards = Array.from(document.querySelectorAll('.collection-item-container, .collection-item'));
const tracks = cards.map(card => getCollectionItemTitleFromEl(card)).filter(Boolean);
if (tracks.length) return tracks;
const aux = Array.from(document.querySelectorAll('a.track_play_auxiliary')).map(el => {
const wrap = el.closest('.collection-item-container, .collection-item, li, div');
return getCollectionItemTitleFromEl(wrap);
}).filter(Boolean);
return aux;
}
function getAllTracks() {
if (window.TralbumData?.trackinfo?.length) {
return window.TralbumData.trackinfo.map(t => stripTrailingDuration(t.title || ''));
}
if (isCollectionPage()) {
const tracks = getCollectionTracks();
if (tracks.length) return tracks;
}
const rows = document.querySelectorAll('.track_row_view');
if (rows.length) {
return Array.from(rows).map(r => {
const t = r.querySelector('.track-title, .title');
return t ? stripTrailingDuration(t.textContent) : '';
});
}
const h2 = document.querySelector('#name-section h2.trackTitle, h2.trackTitle');
return h2 ? [stripTrailingDuration(h2.textContent)] : [];
}
function getCurrentIndex() {
const audio = getAudio();
if (audio?.src && window.TralbumData?.trackinfo?.length) {
const src = audio.src;
const idx = window.TralbumData.trackinfo.findIndex(t =>
t.file && Object.values(t.file).some(url => {
const key = url.split('?')[0].split('/').pop().split('.')[0];
return key && src.includes(key);
})
);
if (idx !== -1) return idx;
}
if (isCollectionPage()) {
const active = getCollectionActiveItem();
if (active) {
const items = Array.from(document.querySelectorAll('.collection-item-container, .collection-item'));
const idx = items.indexOf(active);
if (idx !== -1) return idx;
}
}
const rows = document.querySelectorAll('.track_row_view');
let found = -1;
rows.forEach((r, i) => {
if (r.classList.contains('current_track') || r.classList.contains('playing')) found = i;
});
if (found !== -1) return found;
if (isTrackPage()) return 0;
return found;
}
function getCurrentTrackRawTitle() {
const cur = getCurrentIndex();
if (isCollectionPage()) {
const active = getCollectionActiveItem();
const title = getCollectionItemTitleFromEl(active);
if (title) return title;
}
if (cur >= 0) {
const rows = document.querySelectorAll('.track_row_view');
if (rows[cur]) {
const row = rows[cur];
const preferredTitleEl =
row.querySelector('.title-col .linked-title') ||
row.querySelector('.title-col a span') ||
row.querySelector('.track-title a') ||
row.querySelector('.track-title') ||
row.querySelector('.title');
if (preferredTitleEl) {
return stripTrailingDuration(preferredTitleEl.textContent);
}
}
}
if (window.TralbumData?.trackinfo?.length && cur >= 0 && window.TralbumData.trackinfo[cur]) {
return stripTrailingDuration(window.TralbumData.trackinfo[cur].title || '');
}
const titleEl = document.querySelector('#name-section h2.trackTitle, h2.trackTitle');
return titleEl ? stripTrailingDuration(titleEl.textContent) : '';
}
async function copyCurrentTrackInfo() {
const rawTitle = getCurrentTrackRawTitle();
const headerArtist = getHeaderArtistName();
if (!rawTitle) {
showPopup('Could not determine current track');
return;
}
const parsed = parseTitleAndArtist(rawTitle, headerArtist);
const bpmPart = currentTappedBpm ? ` - BPM ${currentTappedBpm}` : '';
const text = `${parsed.title} - ${parsed.artist}${bpmPart}`;
const ok = await copyToClipboard(text);
showPopup(ok ? `Copied: ${text}` : `Copy failed: ${text}`);
}
function getCurrentTrackUrl() {
if (isCollectionPage()) {
const active = getCollectionActiveItem();
const url = getCollectionItemLinkFromEl(active);
if (url) return url;
}
const cur = getCurrentIndex();
const rows = document.querySelectorAll('.track_row_view');
if (cur >= 0 && rows[cur]) {
const link = rows[cur].querySelector('.title-col a, .track-title a, a.linked-title');
if (link && link.href) return link.href;
}
if (isTrackPage()) {
return window.location.href;
}
if (window.TralbumData?.url && window.location.origin) {
return new URL(window.TralbumData.url, window.location.origin).href;
}
return null;
}
function setBpmDisplay(text) {
const el = document.getElementById('bcp-bpm');
if (el) el.textContent = text;
}
function scheduleBpmTapReset() {
if (bpmTapResetTimer) clearTimeout(bpmTapResetTimer);
bpmTapResetTimer = setTimeout(() => {
bpmTapTimes = [];
currentTappedBpm = null;
setBpmDisplay('BPM TAP');
}, TAP_RESET_MS);
}
function tapBpm() {
const now = performance.now();
if (bpmTapTimes.length && now - bpmTapTimes[bpmTapTimes.length - 1] > TAP_RESET_MS) {
bpmTapTimes = [];
currentTappedBpm = null;
}
bpmTapTimes.push(now);
if (bpmTapTimes.length > TAP_MAX_SAMPLES) {
bpmTapTimes.shift();
}
scheduleBpmTapReset();
if (bpmTapTimes.length < 2) {
setBpmDisplay('TAP...');
return;
}
const intervals = [];
for (let i = 1; i < bpmTapTimes.length; i++) {
intervals.push(bpmTapTimes[i] - bpmTapTimes[i - 1]);
}
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
if (!avgInterval || !isFinite(avgInterval)) {
setBpmDisplay('TAP...');
return;
}
let bpm = 60000 / avgInterval;
while (bpm < 70) bpm *= 2;
while (bpm > 180) bpm /= 2;
currentTappedBpm = Math.round(bpm);
setBpmDisplay(`BPM ${currentTappedBpm}`);
}
function openCurrentTrackPage() {
const url = getCurrentTrackUrl();
if (!url) {
showPopup('Could not determine current track link');
return;
}
window.location.href = url;
}
function saveVol(v, muted) {
try {
localStorage.setItem(VOL_KEY, v);
localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
sessionStorage.setItem(VOL_KEY, v);
sessionStorage.setItem(MUTE_KEY, muted ? '1' : '0');
} catch (e) {}
}
function loadVol() {
try {
const v = parseFloat(sessionStorage.getItem(VOL_KEY) ?? localStorage.getItem(VOL_KEY));
return isNaN(v) ? 1 : Math.max(0, Math.min(1, v));
} catch (e) {
return 1;
}
}
function loadMuted() {
try {
const s = sessionStorage.getItem(MUTE_KEY) ?? localStorage.getItem(MUTE_KEY);
return s === '1';
} catch (e) {
return false;
}
}
function saveSpeed(v) {
try {
localStorage.setItem(SPEED_KEY, String(v));
sessionStorage.setItem(SPEED_KEY, String(v));
} catch (e) {}
}
function loadSpeed() {
try {
const v = parseFloat(sessionStorage.getItem(SPEED_KEY) ?? localStorage.getItem(SPEED_KEY));
if (isNaN(v)) return SPEED_DEFAULT;
return Math.max(SPEED_MIN, Math.min(SPEED_MAX, v));
} catch (e) {
return SPEED_DEFAULT;
}
}
function savePreservePitch(v) {
try {
localStorage.setItem(PRESERVE_PITCH_KEY, v ? '1' : '0');
sessionStorage.setItem(PRESERVE_PITCH_KEY, v ? '1' : '0');
} catch (e) {}
}
function loadPreservePitch() {
try {
const s = sessionStorage.getItem(PRESERVE_PITCH_KEY) ?? localStorage.getItem(PRESERVE_PITCH_KEY);
return s == null ? true : s === '1';
} catch (e) {
return true;
}
}
function setAudioPreservePitch(audio, enabled) {
if (!audio) return false;
let supported = false;
if ('preservesPitch' in audio) {
audio.preservesPitch = enabled;
supported = true;
}
if ('mozPreservesPitch' in audio) {
audio.mozPreservesPitch = enabled;
supported = true;
}
if ('webkitPreservesPitch' in audio) {
audio.webkitPreservesPitch = enabled;
supported = true;
}
return supported;
}
function updateSpeedUi(speed) {
const slider = document.getElementById('bcp-speed');
const label = document.getElementById('bcp-speed-label');
if (slider) slider.value = String(speed);
if (label) {
label.textContent = fmtSpeed(speed);
label.classList.toggle('non-default', Math.abs(speed - 1.0) > 0.001);
}
}
function updatePreservePitchUi() {
const btn = document.getElementById('bcp-preserve-pitch');
if (!btn) return;
const enabled = loadPreservePitch();
const audio = getAudio();
const supported = !!audio && (
'preservesPitch' in audio ||
'mozPreservesPitch' in audio ||
'webkitPreservesPitch' in audio
);
btn.textContent = supported ? (enabled ? 'PP ON' : 'PP OFF') : 'PP N/A';
btn.disabled = !supported;
btn.style.opacity = supported ? '1' : '0.45';
}
function applySpeed(v) {
const speed = Math.max(SPEED_MIN, Math.min(SPEED_MAX, Number(v)));
const audio = getAudio();
if (audio) {
audio.playbackRate = speed;
setAudioPreservePitch(audio, loadPreservePitch());
}
updateSpeedUi(speed);
saveSpeed(speed);
}
function togglePreservePitch() {
const audio = getAudio();
const supported = !!audio && (
'preservesPitch' in audio ||
'mozPreservesPitch' in audio ||
'webkitPreservesPitch' in audio
);
if (!supported) {
showPopup('Preserve pitch is not supported in this browser');
updatePreservePitchUi();
return;
}
const next = !loadPreservePitch();
savePreservePitch(next);
setAudioPreservePitch(audio, next);
updatePreservePitchUi();
showPopup(next ? 'Preserve pitch enabled' : 'Preserve pitch disabled');
}
function jumpToTrack(index) {
const tracks = getAllTracks().filter(Boolean);
if (tracks.length <= 1) return;
if (isCollectionPage()) {
const items = Array.from(document.querySelectorAll('.collection-item-container, .collection-item'));
const target = items[index];
if (target) {
const playEl =
target.querySelector('.track_play_auxiliary') ||
target.querySelector('.item_link_play') ||
target.querySelector('.collection-item-art') ||
target.querySelector('a[data-trackid]');
if (playEl) {
playEl.click();
return;
}
}
}
const rows = document.querySelectorAll('.track_row_view');
if (rows[index]) {
const btn = rows[index].querySelector('.play_status, .play_col, .track_play_hilite, a.play_row_for');
if (btn) {
btn.click();
return;
}
const link = rows[index].querySelector('.title-col a, .track-title a');
if (link) {
link.click();
return;
}
rows[index].click();
return;
}
const cur = getCurrentIndex();
if (cur === -1) return;
const diff = index - cur;
if (diff === 0) return;
const btn = diff > 0 ? getNativeBtn('.nextbutton') : getNativeBtn('.prevbutton');
let steps = Math.abs(diff);
(function step() {
if (steps-- <= 0) return;
if (btn) btn.click();
if (steps > 0) setTimeout(step, 80);
})();
}
function preloadFirstTrack() {
if (preloadDone) return;
if (!window.TralbumData?.trackinfo?.length && !isTrackPage() && !isCollectionPage()) return;
const savedVol = loadVol();
const firstRow = document.querySelector('.track_row_view');
let trigger =
firstRow?.querySelector('.play_status') ||
firstRow?.querySelector('.play_col') ||
firstRow?.querySelector('.track_play_hilite') ||
firstRow?.querySelector('a.play_row_for') ||
firstRow?.querySelector('.title-col .linked-title a') ||
firstRow?.querySelector('.title-col a');
if (!trigger && isCollectionPage()) {
const firstCollectionItem = document.querySelector('.track_play_auxiliary, .item_link_play, a[data-trackid]');
if (firstCollectionItem) trigger = firstCollectionItem;
}
if (!trigger && !isTrackPage()) return;
preloadDone = true;
function muteAndClick() {
const a = getAudio();
if (a) {
a.volume = 0;
a.muted = true;
}
if (trigger) {
trigger.click();
} else if (isTrackPage()) {
const playBtn = getNativeBtn('.playbutton');
if (playBtn) playBtn.click();
}
waitForBuffer(80);
}
function waitForBuffer(attempts) {
if (attempts <= 0) {
const a = getAudio();
if (a) {
a.pause();
a.muted = false;
a.volume = loadMuted() ? 0 : savedVol;
a.currentTime = 0;
a.playbackRate = loadSpeed();
setAudioPreservePitch(a, loadPreservePitch());
}
preloadReady = true;
syncVolSlider(savedVol);
return;
}
const a = getAudio();
if (!a) {
setTimeout(() => waitForBuffer(attempts - 1), 50);
return;
}
a.volume = 0;
a.muted = true;
if (a.readyState >= 2 || (isFinite(a.duration) && a.duration > 0)) {
a.pause();
a.currentTime = 0;
a.muted = false;
a.volume = loadMuted() ? 0 : savedVol;
a.playbackRate = loadSpeed();
setAudioPreservePitch(a, loadPreservePitch());
a._bcpInited = true;
preloadReady = true;
syncVolSlider(savedVol);
return;
}
setTimeout(() => waitForBuffer(attempts - 1), 50);
}
function syncVolSlider(v) {
const volEl = document.getElementById('bcp-vol');
if (volEl) volEl.value = String(v);
}
setTimeout(muteAndClick, 400);
}
function getTags() {
return Array.from(document.querySelectorAll('a.tag, .tags a, [class*="tag"] a'))
.map(a => a.textContent.trim())
.filter(t => t.length > 0 && t.length < 40);
}
function hidePageElements() {
if (isCollectionPage()) return;
const existing = document.getElementById('bcp-hide-native');
if (existing) return;
const s = document.createElement('style');
s.id = 'bcp-hide-native';
s.textContent = `
.inline_player, #player, .html5-player,
div[id="player"], div.player-section { display: none !important; }
.tralbumData.tralbum-tags, .tags,
.tag-list, div.tralbum-tags,
p.tags-inner { display: none !important; }
`;
document.head.appendChild(s);
}
const SVG_VOL = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><polygon points="1,4 5,4 9,1 9,13 5,10 1,10" fill="currentColor"/><path d="M10.5 4.5 Q12.5 7 10.5 9.5" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/><path d="M11.8 2.8 Q14.5 7 11.8 11.2" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/></svg>`;
const SVG_MUTE = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><polygon points="1,4 5,4 9,1 9,13 5,10 1,10" fill="currentColor"/><path d="M10.5 4.5 Q12.5 7 10.5 9.5" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/><path d="M11.8 2.8 Q14.5 7 11.8 11.2" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/><line x1="1" y1="13" x2="13" y2="1" stroke="#e05" stroke-width="1.6" stroke-linecap="round"/></svg>`;
const SVG_PLAY = `<svg width="12" height="13" viewBox="0 0 12 13" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><polygon points="0,0 12,6.5 0,13"/></svg>`;
const SVG_PAUSE = `<svg width="11" height="13" viewBox="0 0 11 13" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="4" height="13"/><rect x="7" y="0" width="4" height="13"/></svg>`;
function buildPlayer() {
if (document.getElementById('bc-sticky-player')) return null;
hidePageElements();
const savedVol = loadVol();
const savedMuted = loadMuted();
const savedSpeed = loadSpeed();
const bar = document.createElement('div');
bar.id = 'bc-sticky-player';
bar.innerHTML = `
<style>
#bc-sticky-player {
position: fixed; bottom: 0; left: 0; right: 0;
z-index: 999999;
font-family: Helvetica, Arial, sans-serif;
background: #f3f3f3;
border-top: 1px solid #cfd6dc;
color: #222;
display: flex; flex-direction: column;
box-shadow: 0 -2px 14px rgba(0,0,0,0.08);
user-select: none;
}
#bc-sticky-player * { box-sizing: border-box; }
#bcp-row1 {
display: flex; align-items: center; gap: 10px;
padding: 0 12px; min-height: 58px;
background: #f7f7f7;
}
#bcp-row2 {
height: 22px;
display: flex; align-items: center;
padding: 0 10px 2px;
border-top: 1px solid #dde3e8;
overflow: hidden;
gap: 0;
background: #efefef;
}
#bcp-album-info {
font-size: 10px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
max-width: 36%;
letter-spacing: 0.02em;
}
#bcp-album-info .bcp-artist { color: #444; }
#bcp-album-info .bcp-sep { color: #9aa4ad; margin: 0 4px; }
#bcp-album-info .bcp-album { color: #6d7780; }
#bcp-row2-spacer { flex: 1; }
#bcp-tags-label, #bcp-help-link {
font-size: 8px;
color: #9aa4ad;
text-transform: uppercase;
letter-spacing: 0.1em;
flex-shrink: 0;
}
#bcp-tags-label { margin-right: 5px; }
#bcp-help-link {
margin-left: 10px;
cursor: pointer;
}
#bcp-help-link:hover {
color: #1da0c3;
}
#bcp-tags-list {
display: flex; gap: 5px;
overflow: hidden; flex-wrap: nowrap;
flex-direction: row-reverse;
}
.bcp-tag {
font-size: 11px;
color: #4b5660;
background: transparent;
border: none;
padding: 0 2px;
white-space: nowrap;
letter-spacing: 0.03em;
font-family: inherit;
}
.bcp-tag::before { content: '#'; color: #8d98a2; }
.bcp-btn {
background: #fafafa;
border: 1px solid #c8d0d7;
color: #5b6670;
border-radius: 3px;
width: 34px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
font-family: inherit; font-size: 13px; cursor: pointer;
transition: border-color .12s, color .12s, background .12s, box-shadow .12s, opacity .12s;
flex-shrink: 0; padding: 0;
}
.bcp-btn:hover:not(:disabled) {
border-color: #1da0c3;
color: #1da0c3;
background: #ffffff;
box-shadow: 0 0 0 1px rgba(29,160,195,0.08);
}
.bcp-btn:disabled {
pointer-events: none;
}
#bcp-play {
border-color: #1da0c3;
color: #1da0c3;
width: 36px;
background: #ffffff;
}
#bcp-play:hover:not(:disabled) {
background: #eef9fc;
}
#bcp-meta-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex: 1;
overflow: hidden;
}
#bcp-meta {
font-size: 11px;
color: #1da0c3;
white-space: nowrap;
flex-shrink: 0;
letter-spacing: 0.03em;
}
#bcp-title {
font-size: 13px;
font-weight: bold;
color: #222;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
#bcp-time {
font-size: 12px;
color: #5f6973;
flex-shrink: 0;
min-width: 90px;
text-align: center;
letter-spacing: 0.04em;
}
#bcp-bpm {
font-size: 11px;
color: #5f6973;
flex-shrink: 0;
min-width: 68px;
text-align: center;
letter-spacing: 0.03em;
cursor: pointer;
border-radius: 3px;
padding: 3px 4px;
}
#bcp-bpm:hover {
background: rgba(29,160,195,0.08);
color: #1da0c3;
}
.bcp-seek {
flex: 0 1 170px;
min-width: 90px;
display: flex;
align-items: center;
}
.bcp-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: #cfd6dc;
outline: none;
cursor: pointer;
accent-color: #1da0c3;
}
.bcp-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 11px;
height: 11px;
border-radius: 50%;
background: #1da0c3;
cursor: pointer;
border: none;
}
#bcp-speed-inline {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
flex: 0 0 120px;
min-width: 120px;
}
#bcp-speed-topline {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 9px;
color: #6f7a84;
letter-spacing: 0.04em;
}
#bcp-speed-label {
color: #1d2b36;
font-size: 10px;
cursor: pointer;
border-radius: 3px;
padding: 1px 3px;
}
#bcp-speed-label:hover {
background: rgba(29,160,195,0.08);
color: #1da0c3;
}
#bcp-speed-label.non-default {
color: #1da0c3;
font-weight: 700;
}
#bcp-preserve-pitch {
border: 1px solid #c8d0d7;
background: #fff;
color: #4f5b66;
border-radius: 3px;
font-size: 9px;
padding: 1px 4px;
cursor: pointer;
line-height: 1.4;
}
#bcp-preserve-pitch:hover:not(:disabled) {
border-color: #1da0c3;
color: #1da0c3;
background: #eef9fc;
}
#bcp-speed-slider-wrap {
position: relative;
width: 100%;
height: 14px;
display: flex;
align-items: center;
}
#bcp-speed-marker {
position: absolute;
left: 50%;
transform: translateX(-1px);
top: 1px;
width: 2px;
height: 12px;
background: #9aa4ad;
border-radius: 1px;
pointer-events: none;
opacity: 0.85;
}
#bcp-speed {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: #cfd6dc;
outline: none;
cursor: pointer;
accent-color: #1da0c3;
position: relative;
z-index: 1;
}
#bcp-speed::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: #1da0c3;
cursor: pointer;
border: none;
}
.bcp-vol-wrap {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
}
#bcp-vol-icon {
color: #6f7a84;
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 2px;
border-radius: 2px;
transition: color .12s;
line-height: 0;
}
#bcp-vol-icon:hover { color: #1da0c3; }
#bcp-vol-icon.muted { color: #a7b0b8; }
.bcp-vol {
-webkit-appearance: none;
appearance: none;
width: 70px;
height: 4px;
border-radius: 2px;
background: #cfd6dc;
outline: none;
cursor: pointer;
accent-color: #1da0c3;
}
.bcp-vol::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: #1da0c3;
cursor: pointer;
border: none;
}
#bcp-dropdown {
position: fixed;
bottom: calc(var(--bcp-bar-height, 88px) + 2px);
left: 0;
right: 0;
z-index: 999998;
background: #f5f5f5;
border-top: 1px solid #cfd6dc;
box-shadow: 0 -6px 24px rgba(0,0,0,0.10);
max-height: 320px;
overflow-y: auto;
display: none;
font-family: Helvetica, Arial, sans-serif;
}
#bcp-dropdown.open { display: block; }
#bcp-dropdown::-webkit-scrollbar { width: 6px; }
#bcp-dropdown::-webkit-scrollbar-track { background: #e8edf1; }
#bcp-dropdown::-webkit-scrollbar-thumb { background: #bcc6ce; border-radius: 3px; }
.bcp-track-item {
padding: 8px 16px;
font-size: 12px;
color: #4b5660;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid #e1e6ea;
transition: background .1s, color .1s;
}
.bcp-track-item:hover {
background: #eef9fc;
color: #222;
}
.bcp-track-item.active {
color: #1da0c3;
font-weight: bold;
background: #e9f7fb;
}
.bcp-track-num {
min-width: 28px;
color: #8b96a0;
font-size: 11px;
text-align: right;
flex-shrink: 0;
}
.bcp-track-item.active .bcp-track-num { color: #1da0c3; }
</style>
<div id="bcp-row1">
<button class="bcp-btn" id="bcp-prev" title="Prev (↑)">◀◀</button>
<button class="bcp-btn" id="bcp-play" title="Play/Pause (Space)">${SVG_PLAY}</button>
<button class="bcp-btn" id="bcp-next" title="Next (↓)">▶▶</button>
<div id="bcp-meta-title">
<span id="bcp-meta">— / —</span>
<span id="bcp-title">—</span>
</div>
<span id="bcp-time">0:00 / 0:00</span>
<span id="bcp-bpm" title="Click repeatedly to tap tempo">BPM TAP</span>
<div class="bcp-seek">
<input type="range" class="bcp-range" id="bcp-seek" min="0" max="100" value="0" step="0.1">
</div>
<div id="bcp-speed-inline">
<div id="bcp-speed-topline">
<span>SPEED</span>
<span id="bcp-speed-label" title="Double-click to reset to 1.00x">${fmtSpeed(savedSpeed)}</span>
<button id="bcp-preserve-pitch" type="button" title="Toggle preserve pitch">PP</button>
</div>
<div id="bcp-speed-slider-wrap">
<span id="bcp-speed-marker" title="1.00x"></span>
<input type="range" id="bcp-speed" min="${SPEED_MIN}" max="${SPEED_MAX}" step="${SPEED_STEP}" value="${savedSpeed}">
</div>
</div>
<div class="bcp-vol-wrap">
<span id="bcp-vol-icon" title="Mute/unmute (click)">${SVG_VOL}</span>
<input type="range" class="bcp-vol" id="bcp-vol" min="0" max="1" step="0.02" value="${savedVol}">
</div>
</div>
<div id="bcp-row2">
<span id="bcp-album-info">
<span class="bcp-artist"></span><span class="bcp-sep"></span><span class="bcp-album"></span>
</span>
<span id="bcp-row2-spacer"></span>
<span id="bcp-tags-label">tags</span>
<div id="bcp-tags-list"></div>
<span id="bcp-help-link" title="Show shortcuts (? / ß)">help</span>
</div>
<div id="bcp-dropdown"></div>`;
document.body.insertBefore(bar, document.body.firstChild);
const barHeight = Math.ceil(bar.getBoundingClientRect().height) || 88;
document.documentElement.style.setProperty('--bcp-bar-height', `${barHeight}px`);
document.body.style.paddingBottom = `${barHeight}px`;
document.getElementById('bcp-prev').addEventListener('click', (e) => {
e.stopPropagation();
if (!hasMultipleTracks()) return;
jumpToTrack(Math.max(0, getCurrentIndex() - 1));
});
document.getElementById('bcp-play').addEventListener('click', (e) => {
e.stopPropagation();
const b = getNativeBtn('.playbutton');
if (b) {
b.click();
return;
}
const audio = getAudio();
if (audio) {
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
});
document.getElementById('bcp-next').addEventListener('click', (e) => {
e.stopPropagation();
if (!hasMultipleTracks()) return;
jumpToTrack(Math.min(getAllTracks().length - 1, getCurrentIndex() + 1));
});
document.getElementById('bcp-help-link').addEventListener('click', (e) => {
e.stopPropagation();
showShortcutsOverlay();
});
document.getElementById('bcp-bpm').addEventListener('click', (e) => {
e.stopPropagation();
tapBpm();
});
const seekEl = document.getElementById('bcp-seek');
let seekDragging = false;
seekEl.addEventListener('mousedown', () => { seekDragging = true; });
window.addEventListener('mouseup', () => { seekDragging = false; });
seekEl.addEventListener('input', () => {
const a = getAudio();
if (a && isFinite(a.duration)) {
a.currentTime = (seekEl.value / 100) * a.duration;
}
});
const volEl = document.getElementById('bcp-vol');
const volIcon = document.getElementById('bcp-vol-icon');
const speedEl = document.getElementById('bcp-speed');
const speedLabel = document.getElementById('bcp-speed-label');
const preservePitchBtn = document.getElementById('bcp-preserve-pitch');
let isMuted = savedMuted;
function updateVolIcon() {
volIcon.innerHTML = isMuted ? SVG_MUTE : SVG_VOL;
volIcon.classList.toggle('muted', isMuted);
}
function applyVol(newVal, newMuted) {
if (newVal !== undefined) {
volEl.value = String(Math.max(0, Math.min(1, newVal)));
}
if (newMuted !== undefined) {
isMuted = newMuted;
}
const a = getAudio();
if (a) a.volume = isMuted ? 0 : parseFloat(volEl.value);
updateVolIcon();
saveVol(parseFloat(volEl.value), isMuted);
}
volEl.value = String(savedVol);
updateVolIcon();
updateSpeedUi(savedSpeed);
const initAudio = getAudio();
if (initAudio) {
initAudio.volume = savedMuted ? 0 : savedVol;
initAudio.playbackRate = savedSpeed;
setAudioPreservePitch(initAudio, loadPreservePitch());
}
updatePreservePitchUi();
volEl.addEventListener('input', () => applyVol(parseFloat(volEl.value), false));
volIcon.addEventListener('click', (e) => {
e.stopPropagation();
applyVol(undefined, !isMuted);
});
speedEl.addEventListener('input', () => {
applySpeed(parseFloat(speedEl.value));
});
speedLabel.addEventListener('dblclick', (e) => {
e.stopPropagation();
applySpeed(1.0);
showPopup('Speed reset to 1.00x');
});
preservePitchBtn.addEventListener('click', (e) => {
e.stopPropagation();
togglePreservePitch();
});
const dropdown = document.getElementById('bcp-dropdown');
const metaTitleEl = document.getElementById('bcp-meta-title');
function openDropdown() {
const tracks = getAllTracks();
if (!tracks.length) return;
const cur = getCurrentIndex();
dropdown.innerHTML = tracks.map((name, i) => `
<div class="bcp-track-item${i === cur ? ' active' : ''}" data-index="${i}">
<span class="bcp-track-num">${i + 1}.</span>
<span>${name || '(untitled)'}</span>
</div>`).join('');
dropdown.classList.add('open');
const active = dropdown.querySelector('.active');
if (active) active.scrollIntoView({ block: 'nearest' });
dropdown.querySelectorAll('.bcp-track-item').forEach(item =>
item.addEventListener('click', () => {
jumpToTrack(parseInt(item.dataset.index, 10));
dropdown.classList.remove('open');
})
);
}
metaTitleEl.addEventListener('click', (e) => {
e.stopPropagation();
if (dropdown.classList.contains('open')) dropdown.classList.remove('open');
else openDropdown();
});
document.addEventListener('click', (e) => {
if (!bar.contains(e.target)) dropdown.classList.remove('open');
});
return {
seekEl,
volEl,
speedEl,
isMuted: () => isMuted,
seekDragging: () => seekDragging,
applyVol
};
}
function updateRow2() {
const tags = getTags();
const tStr = tags.join('|');
if (tStr !== lastTagStr) {
lastTagStr = tStr;
const el = document.getElementById('bcp-tags-list');
if (el) el.innerHTML = tags.map(t => `<span class="bcp-tag">${t}</span>`).join('');
}
const { artist, album } = getAlbumInfo();
const aStr = artist + '|' + album;
if (aStr !== lastAlbumStr) {
lastAlbumStr = aStr;
const artistEl = document.querySelector('#bcp-album-info .bcp-artist');
const sepEl = document.querySelector('#bcp-album-info .bcp-sep');
const albumEl = document.querySelector('#bcp-album-info .bcp-album');
if (artistEl) artistEl.textContent = artist;
if (albumEl) albumEl.textContent = album;
if (sepEl) sepEl.textContent = (artist && album) ? '·' : '';
}
}
function tick() {
if (!controls) {
controls = buildPlayer();
if (!controls) return;
}
preloadFirstTrack();
const audio = getAudio();
const playBtn = document.getElementById('bcp-play');
const timeEl = document.getElementById('bcp-time');
const titleEl = document.getElementById('bcp-title');
const metaEl = document.getElementById('bcp-meta');
const { seekEl, volEl, speedEl, isMuted, seekDragging, applyVol } = controls;
const prevBtn = document.getElementById('bcp-prev');
const nextBtn = document.getElementById('bcp-next');
const enabled = hasMultipleTracks();
[prevBtn, nextBtn].forEach(btn => {
if (!btn) return;
btn.disabled = !enabled;
btn.style.opacity = enabled ? '1' : '0.45';
btn.style.cursor = enabled ? 'pointer' : 'default';
});
if (!audio) {
if (playBtn) playBtn.innerHTML = SVG_PLAY;
return;
}
if (!audio._bcpInited && preloadReady) {
audio._bcpInited = true;
applyVol(loadVol(), loadMuted());
}
audio.playbackRate = loadSpeed();
setAudioPreservePitch(audio, loadPreservePitch());
updatePreservePitchUi();
if (playBtn) playBtn.innerHTML = audio.paused ? SVG_PLAY : SVG_PAUSE;
if (timeEl) timeEl.textContent = `${fmt(audio.currentTime)} / ${fmt(audio.duration)}`;
if (seekEl && !seekDragging() && isFinite(audio.duration) && audio.duration > 0) {
seekEl.value = (audio.currentTime / audio.duration) * 100;
}
if (volEl && !volEl.matches(':active') && !isMuted()) {
volEl.value = String(audio.volume);
}
if (speedEl && !speedEl.matches(':active')) {
updateSpeedUi(loadSpeed());
}
const tracks = getAllTracks();
const cur = getCurrentIndex();
const metaText = `${cur >= 0 ? cur + 1 : '?'} / ${tracks.length || '?'}`;
const titleText = (cur >= 0 && tracks[cur]) ? tracks[cur] : (getCurrentTrackRawTitle() || '—');
if (metaEl && metaText !== lastMetaText) {
lastMetaText = metaText;
metaEl.textContent = metaText;
}
if (titleEl && titleText !== lastTitleText) {
lastTitleText = titleText;
titleEl.textContent = titleText;
}
updateRow2();
}
document.addEventListener('keydown', (e) => {
const isTyping = isTypingEvent(e);
// Skip all shortcuts if typing, except for Escape
if (isTyping && e.key !== 'Escape' && e.keyCode !== 27) {
return;
}
if (isShortcutsOverlayOpen()) {
if (isCloseOverlayKey(e)) {
e.preventDefault();
e.stopPropagation();
hideShortcutsOverlay();
}
return;
}
if (e.key === '?' || e.key === 'ß') {
e.preventDefault();
e.stopPropagation();
showShortcutsOverlay();
return;
}
if (e.key === ' ' && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
window.scrollBy({ top: window.innerHeight * 0.85, behavior: 'smooth' });
return;
}
if (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
e.stopPropagation();
if (!controls) return;
const { volEl, isMuted, applyVol } = controls;
const cur = parseFloat(volEl.value);
const next = e.key === 'ArrowUp' ? Math.min(1, cur + VOL_STEP) : Math.max(0, cur - VOL_STEP);
applyVol(next, isMuted() && next > 0 ? false : isMuted());
return;
}
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
seekRelative(e.shiftKey ? -SEEK_LARGE : -SEEK_SMALL);
break;
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
seekRelative(e.shiftKey ? SEEK_LARGE : SEEK_SMALL);
break;
case 'ArrowUp':
e.preventDefault();
e.stopPropagation();
if (!hasMultipleTracks()) break;
jumpToTrack(Math.max(0, getCurrentIndex() - 1));
break;
case 'ArrowDown':
e.preventDefault();
e.stopPropagation();
if (!hasMultipleTracks()) break;
jumpToTrack(Math.min(getAllTracks().length - 1, getCurrentIndex() + 1));
break;
case ' ':
case 'Spacebar':
e.preventDefault();
e.stopPropagation();
{
const b = getNativeBtn('.playbutton');
if (b) b.click();
else {
const audio = getAudio();
if (audio) {
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
}
}
break;
case 'c':
case 'C':
e.preventDefault();
e.stopPropagation();
copyCurrentTrackInfo();
break;
case 'f':
case 'F':
e.preventDefault();
e.stopPropagation();
openCurrentTrackPage();
break;
case 'w':
case 'W':
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
removeCurrentTrackFromWishlist();
} else {
addCurrentTrackToWishlist();
}
break;
}
}, true);
function init() {
setInterval(tick, TICK_MS);
tick();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();