|
// Ad Skipper - Injected Script (runs in MAIN world) |
|
// Mutes, hides, speeds up and auto-skips ads on Disney+ and YouTube |
|
// This script has access to the page's video elements |
|
(function() { |
|
'use strict'; |
|
|
|
let adBadgeVisible = false; |
|
let observer = null; |
|
let adEndTimeout = null; |
|
let adEndDebounceTimeout = null; |
|
let lastAdEndTime = 0; |
|
let lastAdStartTime = 0; |
|
let isPageVisible = !document.hidden; |
|
let safetyUnmuteTimeout = null; |
|
let lastPromoSkipTime = 0; |
|
let promoObserver = null; |
|
|
|
// Map to track original settings for each video element |
|
const videoSettings = new WeakMap(); |
|
|
|
// Get all video elements |
|
function getAllVideos() { |
|
return Array.from(document.querySelectorAll('video')); |
|
} |
|
|
|
// Find the primary video element (for backward compatibility) |
|
function getVideo() { |
|
return document.querySelector('video'); |
|
} |
|
|
|
// Save original settings for a video element |
|
function saveVideoSettings(video) { |
|
if (!videoSettings.has(video)) { |
|
videoSettings.set(video, { |
|
volume: video.volume, |
|
playbackRate: video.playbackRate, |
|
opacity: video.style.opacity |
|
}); |
|
} |
|
} |
|
|
|
// Centralized TTS function |
|
function speak(text) { |
|
if ('speechSynthesis' in window) { |
|
// Cancel any ongoing speech to prevent queueing |
|
speechSynthesis.cancel(); |
|
|
|
const utterance = new SpeechSynthesisUtterance(text); |
|
speechSynthesis.speak(utterance); |
|
console.log('[Ad Skipper] TTS:', text); |
|
} |
|
} |
|
|
|
// Announce commercials muted |
|
function announceMuted() { |
|
const now = Date.now(); |
|
// Only announce if it's been more than 15 seconds since last mute announcement |
|
if (now - lastAdStartTime > 15000) { |
|
lastAdStartTime = now; |
|
speak('Muting commercials'); |
|
} else { |
|
console.log('[Ad Skipper] Debounced mute announcement - likely pod transition'); |
|
} |
|
} |
|
|
|
// Mute ALL videos and apply ad transformations |
|
function muteVideo() { |
|
const videos = getAllVideos(); |
|
let anyMuted = false; |
|
|
|
videos.forEach(video => { |
|
// Always save settings before modifying (even if already muted) |
|
saveVideoSettings(video); |
|
|
|
// Apply ad transformations to ALL videos |
|
video.muted = true; |
|
video.playbackRate = 16.0; // Maximum playback speed (browsers cap around 16x) |
|
video.style.opacity = '0'; // Make video invisible without affecting layout |
|
anyMuted = true; |
|
}); |
|
|
|
if (anyMuted) { |
|
console.log('[Ad Skipper]', videos.length, 'video(s) muted, hidden, and sped up - ad detected'); |
|
announceMuted(); |
|
} |
|
} |
|
|
|
// Restore ONLY videos we modified (ones in the WeakMap) |
|
function unmuteVideo() { |
|
const videos = getAllVideos(); |
|
let restoredCount = 0; |
|
|
|
videos.forEach(video => { |
|
// Only restore videos that are in our WeakMap (ones we modified) |
|
const settings = videoSettings.get(video) |
|
if (settings) { |
|
videoSettings.delete(video); // Remove from map after restoring |
|
|
|
// Restore original settings |
|
video.muted = false; |
|
video.volume = settings.volume; |
|
video.playbackRate = settings.playbackRate; |
|
video.style.opacity = settings.opacity; |
|
restoredCount++; |
|
} |
|
}); |
|
|
|
if (restoredCount > 0) { |
|
console.log('[Ad Skipper]', restoredCount, 'video(s) unmuted and restored'); |
|
} |
|
} |
|
|
|
// Pause the video |
|
function pauseVideo() { |
|
const video = getVideo(); |
|
if (video && !video.paused) { |
|
video.pause(); |
|
console.log('[Ad Skipper] Video paused'); |
|
} |
|
} |
|
|
|
// Play the video |
|
function playVideo() { |
|
const video = getVideo(); |
|
if (video && video.paused) { |
|
video.play(); |
|
console.log('[Ad Skipper] Video playing'); |
|
} |
|
} |
|
|
|
// Announce commercials are over |
|
function announceAdEnd() { |
|
speak('Commercials are over'); |
|
} |
|
|
|
// Handle ad badge visibility change |
|
function handleAdBadgeChange(isVisible) { |
|
if (isVisible && !adBadgeVisible) { |
|
// Ad just became visible - mute the video |
|
adBadgeVisible = true; |
|
muteVideo(); |
|
|
|
// Clear debounce timeout since we're back in ad mode |
|
if (adEndDebounceTimeout) { |
|
clearTimeout(adEndDebounceTimeout); |
|
adEndDebounceTimeout = null; |
|
} |
|
if (adEndTimeout) { |
|
clearTimeout(adEndTimeout); |
|
adEndTimeout = null; |
|
} |
|
} else if (!isVisible && adBadgeVisible) { |
|
// Ad just became invisible - but debounce to avoid multiple ad breaks |
|
adBadgeVisible = false; |
|
const now = Date.now(); |
|
|
|
// Clear existing debounce |
|
if (adEndDebounceTimeout) { |
|
clearTimeout(adEndDebounceTimeout); |
|
} |
|
|
|
// Wait 500ms to see if another ad starts (debounce) |
|
adEndDebounceTimeout = setTimeout(() => { |
|
// Check if we're still in non-ad state (another ad might have started) |
|
if (adBadgeVisible) { |
|
console.log('[Ad Skipper] Debounce cancelled - new ad started'); |
|
return; |
|
} |
|
|
|
// Only trigger ad end behavior if: |
|
// 1. It's been more than 15 seconds since last ad end (prevents multiple announcements) |
|
// 2. Page is still visible (not navigating away) |
|
if (now - lastAdEndTime > 15000 && isPageVisible) { |
|
lastAdEndTime = now; |
|
pauseVideo(); |
|
unmuteVideo(); |
|
announceAdEnd(); |
|
|
|
// Clear any existing timeout |
|
if (adEndTimeout) { |
|
clearTimeout(adEndTimeout); |
|
} |
|
|
|
// Wait 2 seconds then play |
|
adEndTimeout = setTimeout(() => { |
|
playVideo(); |
|
console.log('[Ad Skipper] Resuming playback after 2 seconds'); |
|
}, 2000); |
|
} else { |
|
console.log('[Ad Skipper] Debounced ad end - likely pod transition or page hidden'); |
|
// Still unmute even if we skip announcement |
|
unmuteVideo(); |
|
} |
|
}, 500); |
|
} |
|
} |
|
|
|
// Auto-click skip button if available |
|
function clickSkipButton() { |
|
// Look for skip button - try various selectors in regular DOM |
|
const skipSelectors = [ |
|
'.ytp-skip-ad-button', // YouTube skip ad button (most specific) |
|
'button[aria-label*="skip" i]', |
|
'button[class*="skip" i]', |
|
'.skip-button', |
|
'[data-testid*="skip" i]' |
|
]; |
|
|
|
for (const selector of skipSelectors) { |
|
const skipBtn = document.querySelector(selector); |
|
if (skipBtn && skipBtn.offsetParent !== null) { |
|
skipBtn.click(); |
|
console.log('[Ad Skipper] Clicked skip button:', selector); |
|
announceSkipped(); |
|
return true; |
|
} |
|
} |
|
|
|
// Also check inside shadow DOMs (e.g., promo-overlay) |
|
const shadowHosts = ['promo-overlay', 'ad-badge-overlay']; |
|
for (const hostSelector of shadowHosts) { |
|
const host = document.querySelector(hostSelector); |
|
if (host && host.shadowRoot) { |
|
for (const selector of skipSelectors) { |
|
const skipBtn = host.shadowRoot.querySelector(selector); |
|
if (skipBtn && skipBtn.offsetParent !== null) { |
|
skipBtn.click(); |
|
console.log('[Ad Skipper] Clicked skip button in shadow DOM:', hostSelector); |
|
announceSkipped(); |
|
return true; |
|
} |
|
} |
|
// Also try generic button with "SKIP" text |
|
const buttons = host.shadowRoot.querySelectorAll('button'); |
|
for (const btn of buttons) { |
|
if (btn.textContent && btn.textContent.trim().toUpperCase().includes('SKIP')) { |
|
if (btn.offsetParent !== null) { |
|
btn.click(); |
|
console.log('[Ad Skipper] Clicked SKIP button by text in shadow DOM:', hostSelector); |
|
announceSkipped(); |
|
return true; |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
// Announce ad/promotion skipped |
|
function announceSkipped() { |
|
speak('Ad skipped'); |
|
} |
|
|
|
// Ensure ALL videos stay muted during ads (even if video elements change) |
|
function ensureAdMuted() { |
|
const videos = getAllVideos(); |
|
let anyChanged = false; |
|
|
|
videos.forEach(video => { |
|
// Save settings if not already saved |
|
saveVideoSettings(video); |
|
|
|
// Always enforce ad transformations |
|
if (!video.muted) { |
|
video.muted = true; |
|
anyChanged = true; |
|
} |
|
if (video.playbackRate !== 16.0) { |
|
video.playbackRate = 16.0; |
|
anyChanged = true; |
|
} |
|
if (video.style.opacity !== '0') { |
|
video.style.opacity = '0'; |
|
anyChanged = true; |
|
} |
|
}); |
|
|
|
if (anyChanged) { |
|
console.log('[Ad Skipper] Re-applied ad transformations to', videos.length, 'video(s)'); |
|
} |
|
} |
|
|
|
// Detect if ad is playing (platform-specific) |
|
function detectAd() { |
|
// Disney+ detection |
|
const disneyPlayer = document.querySelector('.btm-media-player'); |
|
if (disneyPlayer && disneyPlayer.classList.contains('interstitial-ad-playing')) { |
|
return true; |
|
} |
|
|
|
// YouTube detection - only on watch pages (e.g., /watch?v=...) |
|
if (window.location.hostname.includes('youtube.com') && |
|
window.location.pathname === '/watch') { |
|
const youtubeSponsored = document.querySelector('[aria-label="Sponsored"]'); |
|
if (youtubeSponsored) { |
|
return true; |
|
} |
|
} |
|
|
|
// Amazon Prime Video detection |
|
if (window.location.hostname.includes('amazon.') || |
|
window.location.hostname.includes('primevideo.com')) { |
|
const primeAdTimer = document.querySelector('.atvwebplayersdk-ad-timer'); |
|
if (primeAdTimer) { |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
// Check if ad is playing |
|
function checkAdBadge() { |
|
const isAdPlaying = detectAd(); |
|
|
|
// Debug logging |
|
if (isAdPlaying !== adBadgeVisible) { |
|
console.log('[Ad Skipper] Ad state changed:', isAdPlaying ? 'AD DETECTED' : 'NO AD'); |
|
} |
|
|
|
// If ad is playing, ensure ALL video elements stay muted (handles video swapping) |
|
if (isAdPlaying) { |
|
ensureAdMuted(); |
|
clickSkipButton(); |
|
} |
|
|
|
// Safety net: restore videos we modified if no ad is playing |
|
if (!isAdPlaying) { |
|
const videos = getAllVideos(); |
|
|
|
// Check if ANY video we modified still needs restoring |
|
const needsRestore = videos.some(v => videoSettings.has(v)); |
|
|
|
if (needsRestore) { |
|
// Clear any existing safety unmute timeout |
|
if (safetyUnmuteTimeout) { |
|
clearTimeout(safetyUnmuteTimeout); |
|
} |
|
|
|
// Wait 500ms before safety unmuting (in case next ad is loading) |
|
safetyUnmuteTimeout = setTimeout(() => { |
|
// Double-check we're still not in ad mode |
|
const stillNoAd = !detectAd(); |
|
|
|
if (stillNoAd) { |
|
const vids = getAllVideos(); |
|
const hasModifiedVideos = vids.some(v => videoSettings.has(v)); |
|
|
|
if (hasModifiedVideos) { |
|
console.log('[Ad Skipper] Safety restore - no ad detected but have modified videos'); |
|
unmuteVideo(); |
|
} |
|
} else { |
|
console.log('[Ad Skipper] Safety restore cancelled - ad started'); |
|
} |
|
}, 500); |
|
} |
|
} else { |
|
// Clear safety unmute timeout if ad is detected |
|
if (safetyUnmuteTimeout) { |
|
clearTimeout(safetyUnmuteTimeout); |
|
safetyUnmuteTimeout = null; |
|
} |
|
} |
|
|
|
handleAdBadgeChange(isAdPlaying); |
|
} |
|
|
|
// Setup mutation observer to watch for ad badge changes |
|
function setupObserver() { |
|
if (observer) { |
|
observer.disconnect(); |
|
} |
|
|
|
observer = new MutationObserver((mutations) => { |
|
checkAdBadge(); |
|
}); |
|
|
|
// Watch the player element directly for class changes |
|
const player = document.querySelector('.btm-media-player'); |
|
if (player) { |
|
observer.observe(player, { |
|
attributes: true, |
|
attributeFilter: ['class'] |
|
}); |
|
console.log('[Ad Skipper] Observer watching player element for class changes'); |
|
} else { |
|
// Fallback: observe the entire document |
|
observer.observe(document.body, { |
|
childList: true, |
|
subtree: true, |
|
attributes: true, |
|
attributeFilter: ['style', 'class', 'hidden'] |
|
}); |
|
console.log('[Ad Skipper] Observer setup on document.body (player not found yet)'); |
|
} |
|
} |
|
|
|
// Watch promo-overlay for skip button appearing |
|
function watchPromoOverlay() { |
|
const checkPromoSkip = () => { |
|
const promo = document.querySelector('promo-overlay'); |
|
if (!promo || !promo.shadowRoot) return; |
|
|
|
const buttons = promo.shadowRoot.querySelectorAll('button'); |
|
for (const btn of buttons) { |
|
if (btn.textContent && btn.textContent.trim().toUpperCase().includes('SKIP')) { |
|
// Check if button is visible |
|
if (btn.offsetParent !== null) { |
|
// Debounce: only click once every 5 seconds |
|
const now = Date.now(); |
|
if (now - lastPromoSkipTime > 5000) { |
|
lastPromoSkipTime = now; |
|
btn.click(); |
|
console.log('[Ad Skipper] Clicked promo skip button'); |
|
announceSkipped(); |
|
} |
|
} |
|
} |
|
} |
|
}; |
|
|
|
// Set up observer on promo-overlay's shadow DOM if available |
|
const setupPromoObserver = () => { |
|
const promo = document.querySelector('promo-overlay'); |
|
if (promo && promo.shadowRoot) { |
|
if (promoObserver) { |
|
promoObserver.disconnect(); |
|
} |
|
|
|
promoObserver = new MutationObserver(() => { |
|
checkPromoSkip(); |
|
}); |
|
|
|
promoObserver.observe(promo.shadowRoot, { |
|
childList: true, |
|
subtree: true, |
|
attributes: true, |
|
attributeFilter: ['style', 'class'] |
|
}); |
|
|
|
console.log('[Ad Skipper] Watching promo-overlay shadow DOM for skip button'); |
|
} |
|
}; |
|
|
|
// Try to set up observer immediately |
|
setupPromoObserver(); |
|
|
|
// Also periodically check (in case observer misses something) |
|
setInterval(checkPromoSkip, 500); |
|
|
|
// Re-check observer setup periodically (in case shadow DOM appears later) |
|
setInterval(setupPromoObserver, 5000); |
|
} |
|
|
|
// Initialize the extension |
|
function initialize() { |
|
console.log('[Ad Skipper] Initializing (injected script)...'); |
|
|
|
// Track page visibility to prevent announcements when navigating away |
|
document.addEventListener('visibilitychange', () => { |
|
isPageVisible = !document.hidden; |
|
console.log('[Ad Skipper] Page visibility changed:', isPageVisible); |
|
}); |
|
|
|
// Wait for video element to be available |
|
const checkForVideo = setInterval(() => { |
|
if (getVideo()) { |
|
clearInterval(checkForVideo); |
|
console.log('[Ad Skipper] Video element found'); |
|
setupObserver(); |
|
checkAdBadge(); // Initial check |
|
} |
|
}, 1000); |
|
|
|
// Also check periodically in case the observer misses something |
|
setInterval(checkAdBadge, 2000); |
|
|
|
// Start watching for promo overlays |
|
watchPromoOverlay(); |
|
} |
|
|
|
// Start when DOM is ready |
|
if (document.readyState === 'loading') { |
|
document.addEventListener('DOMContentLoaded', initialize); |
|
} else { |
|
initialize(); |
|
} |
|
})(); |