// ==UserScript==
// @name Spotify Ad Skipper (Background Pro X+)
// @name:zh-CN Spotify 广告跳过器(后台专业增强版+)
// @namespace https://github.com/xai-enhanced
// @version 1.2.3
// @description Skips ads precisely without affecting normal track playback
// @description:zh-cn 精准跳过广告,不影响正常歌曲播放
// @author Grok Enhanced
// @match https://open.spotify.com/*
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=open.spotify.com
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Configuration
const DEFAULT_CONFIG = {
CHECK_INTERVAL: 800,
DYNAMIC_INTERVAL: true,
MAX_ATTEMPTS: 10,
DEBOUNCE_MS: 1500,
ENABLE_MUTE_FALLBACK: true,
ENABLE_NEXT_FALLBACK: true,
RESTORE_VOLUME: true,
LOG_LEVEL: 'info',
LANGUAGE_PREFERENCE: 'auto',
ERROR_REPORTING: true,
SHOW_NOTIFICATIONS: true,
MIN_AD_DURATION: 5000,
UI_THEME: 'auto',
BACKGROUND_CHECK_INTERVAL: 2500,
USE_WEB_WORKER: true,
WAKE_LOCK_FALLBACK: false,
BACKGROUND_SKIP_PRIORITY: 'mute',
RANDOMIZE_INTERVAL: true,
BROWSER_OPTIMIZATION: 'auto',
FALLBACK_DETECTION: true,
MAX_NOTIFICATION_QUEUE: 3,
ENABLE_NETWORK_MONITOR: true,
MEMORY_CLEANUP_INTERVAL: 3600000,
AD_CONFIDENCE_THRESHOLD: 0.7
};
let CONFIG = { ...DEFAULT_CONFIG };
function loadConfig() {
const saved = localStorage.getItem('spotifyAdSkipperConfig');
if (saved) {
const parsed = JSON.parse(saved);
Object.assign(CONFIG, parsed, {
MIN_AD_DURATION: parsed.MIN_AD_DURATION || 5000,
AD_CONFIDENCE_THRESHOLD: parsed.AD_CONFIDENCE_THRESHOLD || 0.7
});
}
}
loadConfig();
// State Management
let state = {
isAdPlaying: false,
lastAdTime: 0,
checkAttempts: 0,
intervalId: null,
muteStateBeforeAd: null,
volumeLevelBeforeAd: null,
lastTrackInfo: null,
detectionHistory: [],
adStartTime: 0,
currentTrackStartTime: 0,
stats: JSON.parse(localStorage.getItem('adSkipperStats') || '{"adsSkipped":0,"adsMuted":0,"totalAdTime":0,"backgroundAdsHandled":0}'),
isBackground: false,
worker: null,
notificationQueue: new WeakMap(),
pendingNotifications: [],
lastBrowserCheck: 0,
browserFeatures: null,
showingNotification: false
};
// Language Support (Chinese and English only)
const LANG = {
adTitles: {
en: ['advertisement', 'sponsored', 'ad break'],
zh: ['广告', '赞助', '广告时段']
},
skipLabels: {
en: ['skip', 'next'],
zh: ['跳过', '下一首']
},
notifications: {
en: {
adDetected: 'Ad detected, handling...',
adSkipped: 'Ad skipped successfully',
adMuted: 'Ad muted',
volumeRestored: 'Volume restored',
backgroundMode: 'Running in background mode',
browserLimit: 'Browser limits background operations, may affect ad handling',
workerFallback: 'Web Worker unavailable, switched to fallback mode',
adBlocked: 'Ad blocked (total handled: %d)',
configSaved: 'Settings saved',
configReset: 'Settings reset',
trackProtected: 'Normal track detected, protection active'
},
zh: {
adDetected: '检测到广告,正在处理...',
adSkipped: '广告已成功跳过',
adMuted: '广告已静音',
volumeRestored: '音量已恢复',
backgroundMode: '后台模式运行中',
browserLimit: '浏览器限制后台操作,可能影响广告处理',
workerFallback: 'Web Worker不可用,已切换到备用模式',
adBlocked: '广告已拦截(共处理: %d 个)',
configSaved: '设置已保存',
configReset: '设置已重置',
trackProtected: '检测到正常曲目,已保护播放'
}
}
};
// Logger
const logger = {
debug: (...args) => CONFIG.LOG_LEVEL === 'debug' && console.log(`[${new Date().toISOString().slice(11,19)}] [Spotify Ad Skipper] DEBUG:`, ...args),
info: (...args) => ['info', 'debug'].includes(CONFIG.LOG_LEVEL) && console.log(`[${new Date().toISOString().slice(11,19)}] [Spotify Ad Skipper] INFO:`, ...args),
error: (...args) => console.error(`[${new Date().toISOString().slice(11,19)}] [Spotify Ad Skipper] ERROR:`, ...args),
reportError: (error) => {
if (!CONFIG.ERROR_REPORTING) return;
const errorData = {
browser: { name: navigator.userAgent, features: browser.getFeatures() },
time: new Date().toISOString(),
error: error.message,
stack: error.stack,
url: window.location.href,
version: '1.2.3',
isBackground: state.isBackground
};
const errors = JSON.parse(localStorage.getItem('adSkipperErrors') || '[]');
errors.push(errorData);
if (errors.length > 10) errors.shift();
localStorage.setItem('adSkipperErrors', JSON.stringify(errors));
logger.error('错误已记录(共', errors.length, '条)');
},
updateStats: (type) => {
if (type === 'skip') state.stats.adsSkipped++;
if (type === 'mute') state.stats.adsMuted++;
if (state.isBackground && (type === 'skip' || type === 'mute')) {
state.stats.backgroundAdsHandled++;
}
localStorage.setItem('adSkipperStats', JSON.stringify(state.stats));
const total = state.stats.adsSkipped + state.stats.adsMuted;
if (total > 0 && total % 5 === 0) {
dom.showNotification('adBlocked', 'info', total);
}
}
};
// Browser Features
const browser = {
getFeatures() {
if (state.browserFeatures && Date.now() - state.lastBrowserCheck < 3600000) {
return state.browserFeatures;
}
const features = {
wakeLock: 'wakeLock' in navigator,
webWorker: 'Worker' in window,
visibilityApi: 'visibilityState' in document,
backgroundThrottling: false,
isChrome: /Chrome/.test(navigator.userAgent) && !/Edge/.test(navigator.userAgent),
isFirefox: /Firefox/.test(navigator.userAgent),
isSafari: /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent),
lowBattery: false
};
if ('getBattery' in navigator) {
navigator.getBattery().then(battery => {
features.lowBattery = battery.level < 0.2 || !battery.charging;
if (features.lowBattery) {
CONFIG.BACKGROUND_CHECK_INTERVAL = Math.max(3000, CONFIG.BACKGROUND_CHECK_INTERVAL);
logger.debug('检测到低电量模式,延长检测间隔');
}
});
}
state.browserFeatures = features;
state.lastBrowserCheck = Date.now();
return features;
},
autoOptimizeConfig() {
if (CONFIG.BROWSER_OPTIMIZATION !== 'auto') return;
const features = this.getFeatures();
if (features.isSafari) {
CONFIG.USE_WEB_WORKER = false;
CONFIG.BACKGROUND_CHECK_INTERVAL = Math.max(3000, CONFIG.BACKGROUND_CHECK_INTERVAL);
logger.debug('Safari优化:禁用Web Worker,延长检测间隔');
}
if (features.isFirefox || features.lowBattery) {
CONFIG.WAKE_LOCK_FALLBACK = false;
logger.debug('Firefox或低电量优化:禁用唤醒锁');
}
try {
new Blob([''], { type: 'application/javascript' });
} catch (e) {
CONFIG.USE_WEB_WORKER = false;
logger.debug('CSP阻止Web Worker,禁用');
dom.showNotification('workerFallback');
}
}
};
// DOM Utilities
const dom = {
safeQuery: (selector) => {
try {
return document.querySelector(selector) || null;
} catch (e) {
logger.error('DOM查询失败:', selector, e);
return null;
}
},
safeClick: async (element, options = {}) => {
if (!element) return false;
try {
const clickDelay = options.delay || Math.random() * 100 + 50;
const click = new MouseEvent('click', { bubbles: true, cancelable: true, view: window });
setTimeout(() => element.dispatchEvent(click), clickDelay);
logger.debug(`点击元素 [${element.tagName}] (延迟: ${Math.round(clickDelay)}ms)`);
return true;
} catch (e) {
logger.error('点击元素失败:', e);
logger.reportError(e);
return false;
}
},
getVolumeLevel: () => {
const volumeSlider = dom.safeQuery('[data-testid="volume-bar"] input');
return volumeSlider ? parseFloat(volumeSlider.value) : null;
},
setVolumeLevel: async (level) => {
const volumeSlider = dom.safeQuery('[data-testid="volume-bar"] input');
if (!volumeSlider) return false;
volumeSlider.value = level;
const changeEvent = new Event('input', { bubbles: true });
volumeSlider.dispatchEvent(changeEvent);
await new Promise(resolve => setTimeout(resolve, 100));
logger.debug('音量设置为:', level);
return true;
},
simulateKeyPress: (key = 'ArrowRight') => {
const keyEvent = new KeyboardEvent('keydown', { bubbles: true, key });
document.dispatchEvent(keyEvent);
logger.debug(`模拟键盘按键: ${key}`);
},
updateTheme: () => {
const spotifyRoot = document.querySelector('[data-testid="root"]');
const isDark = CONFIG.UI_THEME === 'dark' ||
(CONFIG.UI_THEME === 'auto' && spotifyRoot?.classList.contains('dark'));
document.documentElement.style.setProperty('--ad-skipper-bg', isDark ? 'rgba(40, 40, 40, 0.95)' : 'rgba(255, 255, 255, 0.95)');
document.documentElement.style.setProperty('--ad-skipper-text', isDark ? '#fff' : '#000');
document.documentElement.style.setProperty('--ad-skipper-shadow', isDark ? '0 4px 12px rgba(0,0,0,0.4)' : '0 4px 12px rgba(0,0,0,0.3)');
},
showNotification: (messageKey, type = 'info', ...args) => {
if (!CONFIG.SHOW_NOTIFICATIONS) return;
let message = LANG.notifications[dom.getLang()][messageKey] || messageKey;
if (args.length && message.includes('%')) {
message = message.replace(/%d/g, args[0] || '');
}
if (state.isBackground) {
const pending = JSON.parse(localStorage.getItem('pendingNotifications') || '[]');
if (pending.some(item => item.message === message)) return;
pending.push({ message, type, time: Date.now() });
localStorage.setItem('pendingNotifications', JSON.stringify(pending.slice(-CONFIG.MAX_NOTIFICATION_QUEUE)));
return;
}
const container = document.createElement('div');
container.style.position = 'fixed';
container.style.bottom = '20px';
container.style.left = '20px';
container.style.zIndex = '1000000';
const notification = document.createElement('div');
notification.className = `ad-skipper-notification ${type}`;
notification.textContent = message;
notification.style.padding = '10px 20px';
notification.style.borderRadius = '6px';
notification.style.background = 'var(--ad-skipper-bg)';
notification.style.color = 'var(--ad-skipper-text)';
notification.style.boxShadow = 'var(--ad-skipper-shadow)';
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
notification.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
container.appendChild(notification);
document.body.appendChild(container);
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => container.remove(), 300);
}, 2500);
},
isPlaying: () => {
const playPauseBtn = dom.safeQuery('[data-testid="control-button-playpause"]');
const media = document.querySelector('audio, video');
return (playPauseBtn?.ariaLabel?.includes('暂停') ||
playPauseBtn?.ariaLabel?.includes('Pause') ||
(media && !media.paused));
},
getLang: () => {
return CONFIG.LANGUAGE_PREFERENCE === 'auto'
? (navigator.language.includes('zh') ? 'zh' : 'en')
: CONFIG.LANGUAGE_PREFERENCE;
},
getStableTrackInfo() {
const trackName = dom.safeQuery('[data-testid="now-playing-widget"] [data-testid="track-title"]')?.textContent?.trim() || '';
const artistName = dom.safeQuery('[data-testid="now-playing-widget"] [data-testid="artist-name"]')?.textContent?.trim() || '';
return trackName && artistName ? `${trackName} - ${artistName}` : null;
}
};
// Ad Detector
class AdDetector {
constructor() {
this.detectionRules = [
() => LANG.adTitles[dom.getLang()]?.some(keyword =>
document.title.toLowerCase().includes(keyword.toLowerCase())),
() => !!dom.safeQuery('[data-testid="ad-badge"], [aria-label*="ad"], [class*="ad-"][class*="badge"]'),
() => {
const trackInfo = dom.getStableTrackInfo();
if (!trackInfo || !state.lastTrackInfo) return false;
const isRecentTrackChange = Date.now() - state.currentTrackStartTime < 3000;
return trackInfo !== state.lastTrackInfo && !isRecentTrackChange;
},
() => {
const skipButton = dom.safeQuery('[data-testid="control-button-skip"], [aria-label*="skip"], [aria-label*="Skip"]');
return !!skipButton && !skipButton.disabled;
},
() => {
const media = document.querySelector('audio, video');
return media && (media.src.includes('ad') || media.src.includes('sponsor') || media.src.includes('advertisement'));
}
];
this.fallbackRules = [
() => !!document.querySelector('.ad-container, [data-ad-type], [role="banner"]'),
() => {
const progressBar = dom.safeQuery('[data-testid="progress-bar"]');
if (!progressBar) return false;
const currentTime = parseFloat(progressBar.getAttribute('aria-valuenow') || 0);
const duration = parseFloat(progressBar.getAttribute('aria-valuemax') || 0);
return duration > 0 && duration < 30 && currentTime / duration > 0.8;
}
];
this.adThreshold = CONFIG.AD_CONFIDENCE_THRESHOLD;
this.normalThreshold = 0.3;
}
detect() {
const currentTrack = dom.getStableTrackInfo();
if (currentTrack && currentTrack !== state.lastTrackInfo) {
state.lastTrackInfo = currentTrack;
state.currentTrackStartTime = Date.now();
logger.debug(`检测到新曲目: ${currentTrack}`);
}
let results = this.detectionRules.map(rule => rule() ? 1 : 0);
let finalScore = this.calculateScore(results);
if (CONFIG.FALLBACK_DETECTION && finalScore > 0.3 && finalScore < this.adThreshold) {
const fallbackResults = this.fallbackRules.map(rule => rule() ? 1 : 0);
const fallbackScore = fallbackResults.reduce((sum, val) => sum + val, 0) / fallbackResults.length;
finalScore = finalScore * 0.8 + fallbackScore * 0.2;
logger.debug(`备用检测得分: ${fallbackScore.toFixed(2)}, 最终得分: ${finalScore.toFixed(2)}`);
}
if (Date.now() - state.currentTrackStartTime < 3000) {
logger.debug(`新曲目保护期(${Math.round((3000 - (Date.now() - state.currentTrackStartTime))/1000)}s),强制降低广告判定概率`);
finalScore = finalScore * 0.3;
}
if (state.isBackground && !dom.isPlaying()) {
logger.debug('后台未播放,不判定为广告');
return false;
}
logger.debug(`广告检测得分: ${finalScore.toFixed(2)} (阈值: ${this.adThreshold})`);
return state.isAdPlaying
? finalScore > this.normalThreshold
: finalScore > this.adThreshold;
}
calculateScore(results) {
state.detectionHistory.push(results);
if (state.detectionHistory.length > 5) state.detectionHistory.shift();
const ruleWeights = [1.5, 2.0, 1.0, 2.0, 1.8];
const weightedScores = state.detectionHistory.map((hist, idx) => {
const timeWeight = (idx + 1) / state.detectionHistory.length;
return hist.reduce((sum, val, i) => sum + val * timeWeight * ruleWeights[i], 0);
});
const totalWeight = ruleWeights.reduce((a, b) => a + b, 0) * state.detectionHistory.length;
return totalWeight > 0 ? weightedScores.reduce((sum, score) => sum + score, 0) / totalWeight : 0;
}
}
// Ad Handler
class AdHandler {
trySkipButton() {
if (Date.now() - state.adStartTime < CONFIG.MIN_AD_DURATION) {
logger.debug(`广告时长不足${CONFIG.MIN_AD_DURATION/1000}秒,暂不跳过`);
return false;
}
const skipButton = dom.safeQuery('[data-testid="control-button-skip"], [aria-label*="skip"], [aria-label*="Skip"]');
if (skipButton && dom.safeClick(skipButton, { delay: 300 })) {
state.stats.totalAdTime += Date.now() - state.adStartTime;
dom.showNotification('adSkipped');
logger.updateStats('skip');
this.resetState();
return true;
}
return false;
}
tryNextButton() {
if (!CONFIG.ENABLE_NEXT_FALLBACK) return false;
if (Date.now() - state.adStartTime < CONFIG.MIN_AD_DURATION) {
logger.debug(`广告时长不足${CONFIG.MIN_AD_DURATION/1000}秒,暂不使用下一首备用`);
return false;
}
const nextButton = dom.safeQuery('[data-testid="control-button-next"], [aria-label*="下一首"], [aria-label*="Next"]');
if (nextButton && dom.safeClick(nextButton, { delay: 300 })) {
state.stats.totalAdTime += Date.now() - state.adStartTime;
dom.showNotification('adSkipped');
logger.updateStats('skip');
this.resetState();
return true;
}
dom.simulateKeyPress('ArrowRight');
return false;
}
async tryMute() {
if (!CONFIG.ENABLE_MUTE_FALLBACK) return false;
const volumeSlider = dom.safeQuery('[data-testid="volume-bar"] input');
if (!volumeSlider || volumeSlider.value === '0') return false;
state.muteStateBeforeAd = volumeSlider.value !== '0';
state.volumeLevelBeforeAd = dom.getVolumeLevel();
if (await dom.setVolumeLevel(0)) {
dom.showNotification('adMuted');
logger.updateStats('mute');
return true;
}
return false;
}
async restoreVolume() {
if (!CONFIG.RESTORE_VOLUME || state.volumeLevelBeforeAd === null) return;
const currentTrack = dom.getStableTrackInfo();
if (currentTrack && state.lastTrackInfo === currentTrack) {
if (await dom.setVolumeLevel(state.volumeLevelBeforeAd)) {
dom.showNotification('volumeRestored');
state.volumeLevelBeforeAd = null;
}
}
}
resetState() {
state.isAdPlaying = false;
state.checkAttempts = 0;
state.adStartTime = 0;
state.lastAdTime = Date.now();
}
async handleAd() {
state.checkAttempts++;
if (state.checkAttempts > CONFIG.MAX_ATTEMPTS) {
logger.error('达到最大尝试次数,放弃处理');
this.resetState();
return false;
}
if (CONFIG.BACKGROUND_SKIP_PRIORITY === 'mute' && state.isBackground) {
if (await this.tryMute()) return true;
if (this.trySkipButton()) return true;
if (this.tryNextButton()) return true;
} else {
if (this.trySkipButton()) return true;
if (await this.tryMute()) return true;
if (this.tryNextButton()) return true;
}
return false;
}
}
// Configuration Panel
class ConfigPanel {
constructor() {
this.panel = null;
this.container = null;
this.initStyle();
this.createTriggerButton();
this.createPanel();
}
initStyle() {
if (document.getElementById('ad-skipper-global-style')) return;
const style = document.createElement('style');
style.id = 'ad-skipper-global-style';
style.textContent = `
.ad-skipper-config-container {
position: fixed !important;
z-index: 1000000 !important;
top: 20px !important;
right: 20px !important;
transform: translateZ(0);
}
.ad-skipper-trigger {
position: fixed !important;
z-index: 1000001 !important;
bottom: 20px !important;
right: 20px !important;
width: 48px !important;
height: 48px !important;
border-radius: 50% !important;
background: #1db954 !important;
color: white !important;
border: none !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
font-size: 24px !important;
transition: transform 0.3s ease, background 0.3s ease !important;
}
.ad-skipper-trigger:hover {
transform: scale(1.1) !important;
background: #1ed760 !important;
}
.ad-skipper-config {
width: 320px;
padding: 20px;
border-radius: 8px;
background: var(--ad-skipper-bg);
color: var(--ad-skipper-text);
box-shadow: var(--ad-skipper-shadow);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
font-size: 14px;
}
.ad-skipper-config h3 {
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid rgba(128,128,128,0.3);
}
.ad-skipper-config label {
display: flex;
align-items: center;
margin: 10px 0;
}
.ad-skipper-config input[type="checkbox"] {
margin-right: 10px;
}
.ad-skipper-config input[type="range"] {
width: 100%;
margin: 10px 0;
}
.ad-skipper-config .range-label {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-top: 5px;
}
.ad-skipper-config button {
width: 100%;
padding: 10px;
margin-top: 12px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
}
.ad-skipper-config button:hover {
filter: brightness(1.1);
transform: scale(1.02);
}
`;
document.head.appendChild(style);
}
createTriggerButton() {
let trigger = document.querySelector('.ad-skipper-trigger');
if (trigger) return trigger;
trigger = document.createElement('button');
trigger.className = 'ad-skipper-trigger';
trigger.textContent = '⚙️';
trigger.title = dom.getLang() === 'zh' ? '广告跳过器设置' : 'Ad Skipper Settings';
trigger.onclick = () => this.togglePanel();
document.body.appendChild(trigger);
return trigger;
}
createPanel() {
if (this.panel) {
this.updatePanelTheme();
return;
}
this.container = document.createElement('div');
this.container.className = 'ad-skipper-config-container';
this.container.style.display = 'none';
this.panel = document.createElement('div');
this.panel.className = 'ad-skipper-config';
const title = document.createElement('h3');
title.textContent = dom.getLang() === 'zh' ? '广告跳过器设置' : 'Ad Skipper Settings';
this.panel.appendChild(title);
const stats = document.createElement('div');
stats.style.margin = '10px 0';
stats.style.padding = '10px';
stats.style.borderRadius = '4px';
stats.style.background = 'rgba(0,0,0,0.1)';
stats.innerHTML = `
<p><strong>${dom.getLang() === 'zh' ? '统计信息' : 'Statistics'}</strong></p>
<p>${dom.getLang() === 'zh' ? '跳过广告' : 'Ads Skipped'}: ${state.stats.adsSkipped}</p>
<p>${dom.getLang() === 'zh' ? '静音广告' : 'Ads Muted'}: ${state.stats.adsMuted}</p>
<p>${dom.getLang() === 'zh' ? '后台处理' : 'Background Ads Handled'}: ${state.stats.backgroundAdsHandled}</p>
<p>${dom.getLang() === 'zh' ? '节省时间' : 'Time Saved'}: ${Math.round(state.stats.totalAdTime / 1000)}s</p>
`;
this.panel.appendChild(stats);
const browserInfo = document.createElement('div');
browserInfo.style.margin = '10px 0';
browserInfo.style.padding = '10px';
browserInfo.style.borderRadius = '4px';
browserInfo.style.background = 'rgba(0,0,0,0.1)';
const features = browser.getFeatures();
browserInfo.innerHTML = `
<p style="margin: 0 0 5px 0;"><strong>${dom.getLang() === 'zh' ? '浏览器信息' : 'Browser Info'}:</strong></p>
<p style="margin: 2px 0;">Web Worker: ${features.webWorker ? (dom.getLang() === 'zh' ? '支持' : 'Supported') : (dom.getLang() === 'zh' ? '不支持' : 'Not Supported')}</p>
<p style="margin: 2px 0;">${dom.getLang() === 'zh' ? '唤醒锁' : 'Wake Lock'}: ${features.wakeLock ? (dom.getLang() === 'zh' ? '支持' : 'Supported') : (dom.getLang() === 'zh' ? '不支持' : 'Not Supported')}</p>
<p style="margin: 2px 0;">${dom.getLang() === 'zh' ? '后台限制' : 'Background Throttling'}: ${features.backgroundThrottling ? (dom.getLang() === 'zh' ? '存在' : 'Present') : (dom.getLang() === 'zh' ? '无' : 'None')}</p>
`;
this.panel.appendChild(browserInfo);
['ENABLE_MUTE_FALLBACK', 'ENABLE_NEXT_FALLBACK', 'RESTORE_VOLUME', 'SHOW_NOTIFICATIONS'].forEach(key => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = CONFIG[key];
checkbox.id = key;
checkbox.onchange = () => {
CONFIG[key] = checkbox.checked;
this.saveConfig();
};
const label = document.createElement('label');
label.htmlFor = key;
label.textContent = dom.getLang() === 'zh' ? {
ENABLE_MUTE_FALLBACK: '启用静音备用',
ENABLE_NEXT_FALLBACK: '启用下一首备用',
RESTORE_VOLUME: '恢复音量',
SHOW_NOTIFICATIONS: '显示通知'
}[key] : {
ENABLE_MUTE_FALLBACK: 'Enable Mute Fallback',
ENABLE_NEXT_FALLBACK: 'Enable Next Track Fallback',
RESTORE_VOLUME: 'Restore Volume',
SHOW_NOTIFICATIONS: 'Show Notifications'
}[key];
label.prepend(checkbox);
this.panel.appendChild(label);
});
const thresholdLabel = document.createElement('label');
thresholdLabel.textContent = dom.getLang() === 'zh' ? '广告检测灵敏度(数值越高越严格)' : 'Ad Detection Sensitivity (Higher = stricter)';
this.panel.appendChild(thresholdLabel);
const thresholdInput = document.createElement('input');
thresholdInput.type = 'range';
thresholdInput.min = 0.5;
thresholdInput.max = 0.9;
thresholdInput.step = 0.05;
thresholdInput.value = CONFIG.AD_CONFIDENCE_THRESHOLD;
thresholdInput.onchange = () => {
CONFIG.AD_CONFIDENCE_THRESHOLD = parseFloat(thresholdInput.value);
this.saveConfig();
adDetector.adThreshold = CONFIG.AD_CONFIDENCE_THRESHOLD;
};
this.panel.appendChild(thresholdInput);
const thresholdValues = document.createElement('div');
thresholdValues.className = 'range-label';
thresholdValues.innerHTML = `
<span>${dom.getLang() === 'zh' ? '灵敏' : 'Sensitive'}</span>
<span>${CONFIG.AD_CONFIDENCE_THRESHOLD.toFixed(2)}</span>
<span>${dom.getLang() === 'zh' ? '严格' : 'Strict'}</span>
`;
this.panel.appendChild(thresholdValues);
const minAdLabel = document.createElement('label');
minAdLabel.textContent = dom.getLang() === 'zh' ? '广告最短时长(毫秒)' : 'Minimum Ad Duration (ms)';
this.panel.appendChild(minAdLabel);
const minAdInput = document.createElement('input');
minAdInput.type = 'range';
minAdInput.min = 2000;
minAdInput.max = 10000;
minAdInput.step = 1000;
minAdInput.value = CONFIG.MIN_AD_DURATION;
minAdInput.onchange = () => {
CONFIG.MIN_AD_DURATION = parseInt(minAdInput.value);
this.saveConfig();
};
this.panel.appendChild(minAdInput);
const minAdValues = document.createElement('div');
minAdValues.className = 'range-label';
minAdValues.innerHTML = `
<span>2s</span>
<span>${CONFIG.MIN_AD_DURATION/1000}s</span>
<span>10s</span>
`;
this.panel.appendChild(minAdValues);
const saveBtn = document.createElement('button');
saveBtn.textContent = dom.getLang() === 'zh' ? '保存设置' : 'Save Settings';
saveBtn.style.background = '#1db954';
saveBtn.style.color = 'white';
saveBtn.onclick = () => {
this.saveConfig();
dom.showNotification('configSaved');
this.togglePanel();
};
this.panel.appendChild(saveBtn);
const resetBtn = document.createElement('button');
resetBtn.textContent = dom.getLang() === 'zh' ? '重置设置' : 'Reset Settings';
resetBtn.style.background = '#ff5555';
resetBtn.style.color = 'white';
resetBtn.onclick = () => {
if (confirm(dom.getLang() === 'zh' ? '确定要重置所有设置吗?' : 'Are you sure to reset all settings?')) {
localStorage.removeItem('spotifyAdSkipperConfig');
localStorage.removeItem('adSkipperStats');
localStorage.removeItem('pendingNotifications');
Object.assign(CONFIG, DEFAULT_CONFIG);
Object.assign(state.stats, {
adsSkipped: 0,
adsMuted: 0,
totalAdTime: 0,
backgroundAdsHandled: 0
});
dom.showNotification('configReset');
this.togglePanel();
window.location.reload();
}
};
this.panel.appendChild(resetBtn);
const errorExportBtn = document.createElement('button');
errorExportBtn.textContent = dom.getLang() === 'zh' ? '导出错误日志' : 'Export Error Logs';
errorExportBtn.style.background = '#ff9900';
errorExportBtn.style.color = 'white';
errorExportBtn.onclick = () => {
const errors = JSON.parse(localStorage.getItem('adSkipperErrors') || '[]');
const json = JSON.stringify(errors, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'adSkipperErrors.json';
a.click();
URL.revokeObjectURL(url);
dom.showNotification('错误日志已导出', 'info');
};
this.panel.appendChild(errorExportBtn);
this.container.appendChild(this.panel);
document.body.appendChild(this.container);
}
saveConfig() {
localStorage.setItem('spotifyAdSkipperConfig', JSON.stringify(CONFIG));
}
togglePanel() {
if (!this.panel) this.createPanel();
this.container.style.display = this.container.style.display === 'none' ? 'block' : 'none';
}
updatePanelTheme() {
dom.updateTheme();
if (!this.panel) return;
this.panel.style.background = 'var(--ad-skipper-bg)';
this.panel.style.color = 'var(--ad-skipper-text)';
this.panel.style.boxShadow = 'var(--ad-skipper-shadow)';
}
}
// Web Worker
function initWebWorker() {
const features = browser.getFeatures();
if (!CONFIG.USE_WEB_WORKER || !features.webWorker) {
logger.info('Web Worker不可用,回退到主线程');
dom.showNotification('workerFallback');
return;
}
try {
const workerCode = `
let checkInterval, detectionHistory = [];
self.onmessage = (e) => {
try {
if (e.data.type === 'start') {
if (checkInterval) clearInterval(checkInterval);
checkInterval = setInterval(() => self.postMessage({ type: 'check' }), e.data.interval);
} else if (e.data.type === 'updateData') {
const { title, adTitles, progress, duration, trackInfo, trackStartTime, minAdDuration } = e.data;
const results = [
adTitles.some(keyword => title.toLowerCase().includes(keyword.toLowerCase())),
progress > 0 && duration < 30 && (progress / duration) > 0.8,
title.includes(' - ') && trackInfo !== e.data.lastTrackInfo && Date.now() - trackStartTime > 3000
].map(r => r ? 1 : 0);
detectionHistory.push(results);
if (detectionHistory.length > 5) detectionHistory.shift();
const ruleWeights = [2.0, 1.5, 1.0];
const weightedScore = detectionHistory.reduce((sum, hist, idx) => {
const timeWeight = (idx + 1) / detectionHistory.length;
return sum + hist.reduce((s, v, i) => s + v * timeWeight * ruleWeights[i], 0);
}, 0) / (ruleWeights.reduce((a, b) => a + b, 0) * detectionHistory.length);
const isNewTrack = Date.now() - trackStartTime < 3000;
const finalScore = isNewTrack ? weightedScore * 0.3 : weightedScore;
self.postMessage({
type: 'result',
isAd: finalScore > e.data.threshold && Date.now() - trackStartTime > minAdDuration
});
} else if (e.data.type === 'stop') {
clearInterval(checkInterval);
checkInterval = null;
} else if (e.data.type === 'ping') {
self.postMessage({ type: 'pong' });
}
} catch (e) {
self.postMessage({ type: 'error', error: e.message });
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
state.worker = new Worker(workerUrl);
state.worker.onerror = (e) => {
logger.error('Web Worker错误:', e);
logger.reportError(e);
state.worker.terminate();
state.worker = null;
dom.showNotification('workerFallback');
};
state.worker.onmessage = (e) => {
if (e.data.type === 'error') {
logger.error('Worker内部错误:', e.data.error);
return;
}
if (e.data.type === 'check' && state.isBackground) {
const progressBar = dom.safeQuery('[data-testid="progress-bar"]');
const progress = progressBar ? parseFloat(progressBar.getAttribute('aria-valuenow') || 0) : 0;
const duration = progressBar ? parseFloat(progressBar.getAttribute('aria-valuemax') || 0) : 0;
state.worker.postMessage({
type: 'updateData',
title: document.title,
adTitles: LANG.adTitles[dom.getLang()] || LANG.adTitles.en,
progress,
duration,
trackInfo: dom.getStableTrackInfo(),
lastTrackInfo: state.lastTrackInfo,
trackStartTime: state.currentTrackStartTime,
threshold: CONFIG.AD_CONFIDENCE_THRESHOLD,
minAdDuration: CONFIG.MIN_AD_DURATION
});
} else if (e.data.type === 'result' && state.isBackground) {
if (e.data.isAd && !state.isAdPlaying) {
logger.debug('Worker检测到广告');
state.isAdPlaying = true;
state.adStartTime = Date.now();
dom.showNotification('adDetected');
new AdHandler().handleAd();
} else if (!e.data.isAd && state.isAdPlaying) {
new AdHandler().restoreVolume();
state.isAdPlaying = false;
}
}
};
state.worker.postMessage({
type: 'start',
interval: CONFIG.BACKGROUND_CHECK_INTERVAL
});
setInterval(() => {
if (state.worker) {
state.worker.postMessage({ type: 'ping' });
}
}, 5000);
logger.debug('Web Worker初始化成功');
} catch (e) {
logger.error('Web Worker初始化失败:', e);
logger.reportError(e);
state.worker = null;
dom.showNotification('workerFallback');
}
}
// Main Loop
async function checkAds() {
try {
if (state.isBackground && state.worker) return;
const detector = new AdDetector();
const isAd = detector.detect();
if (isAd && !state.isAdPlaying) {
const secondCheck = await new Promise(resolve => {
setTimeout(() => resolve(detector.detect()), 300);
});
if (secondCheck) {
state.isAdPlaying = true;
state.adStartTime = Date.now();
dom.showNotification('adDetected');
await new AdHandler().handleAd();
} else {
logger.debug('二次检测否定广告判定,避免误判');
}
} else if (!isAd && state.isAdPlaying) {
await new AdHandler().restoreVolume();
state.isAdPlaying = false;
dom.showNotification('trackProtected');
} else if (isAd && state.isAdPlaying) {
await new AdHandler().handleAd();
}
} catch (e) {
logger.error('广告检测循环错误:', e);
logger.reportError(e);
}
}
// Start Checking
function startChecking() {
clearInterval(state.intervalId);
const getInterval = () => {
const base = state.isBackground ? CONFIG.BACKGROUND_CHECK_INTERVAL : CONFIG.CHECK_INTERVAL;
return CONFIG.RANDOMIZE_INTERVAL
? base * (0.8 + Math.random() * 0.4)
: base;
};
state.intervalId = setInterval(checkAds, getInterval());
logger.debug(`开始广告检测循环,初始间隔: ${getInterval()}ms`);
}
// Handle Visibility Change
function handleVisibilityChange() {
const wasBackground = state.isBackground;
state.isBackground = document.visibilityState !== 'visible';
if (state.isBackground && !wasBackground) {
logger.debug('切换到后台模式');
dom.showNotification('backgroundMode');
if (browser.getFeatures().backgroundThrottling) {
dom.showNotification('browserLimit');
}
startChecking();
initWebWorker();
} else if (!state.isBackground && wasBackground) {
logger.debug('切换到前台模式');
if (state.worker) {
state.worker.postMessage({ type: 'stop' });
state.worker.terminate();
state.worker = null;
}
const pending = JSON.parse(localStorage.getItem('pendingNotifications') || '[]');
pending.forEach(item => dom.showNotification(item.message, item.type));
localStorage.removeItem('pendingNotifications');
startChecking();
}
}
// Initialization
function init() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
}
function start() {
dom.updateTheme();
browser.autoOptimizeConfig();
const configPanel = new ConfigPanel();
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'S') {
e.preventDefault();
configPanel.togglePanel();
}
});
document.addEventListener('visibilitychange', handleVisibilityChange);
startChecking();
setInterval(() => {
logger.debug('执行内存清理');
state.detectionHistory = state.detectionHistory.slice(-2);
state.lastBrowserCheck = 0;
}, CONFIG.MEMORY_CLEANUP_INTERVAL);
logger.info('Spotify广告跳过器初始化完成 (v1.2.3)');
}
const adDetector = new AdDetector();
init();
})();