New:Socket for Asana Is Now Available.Learn more
Get Started

YT Focus — Hide Shorts, Feeds & Distractions

Package Overview
Versions
2
Alerts
File Explorer
Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

yt-focus@local - firefox Package Compare versions

Comparing version
3.3
to
4.1
+144
spotify.js
// YT Focus — Spotify content script (open.spotify.com).
//
// What this can and cannot do: Spotify audio ads are server-stitched into
// the stream — there is nothing to skip TO, and the player disables
// seeking while one plays (unlike YouTube, where the ad is a separate
// video that skipVideoAd() jumps to the end of). So ads are handled by
// MUTING: detect the ad, mute the media element for its duration, then
// restore the exact mute state the user had. Visual ad units are hidden
// cosmetically on top.
//
// DEFAULTS/STORE/loadSettings come from defaults.js (loaded first).
let currentSettings = { ...DEFAULTS };
// ── Ad detection ──
// Three signals, cheapest first:
// A. tab title becomes "Advertisement" (English UI)
// B. now-playing widget text/aria mentions an advertisement
// C. now-playing widget is populated but links to no /track|episode|
// album|show/ — catches localized UIs where A/B never match.
// C alone can false-positive for one poll while a track is still
// loading (widget rendered, links not yet), so it must hold for two
// consecutive polls before it counts.
const CONTENT_LINK = 'a[href*="/track/"],a[href*="/episode/"],a[href*="/album/"],a[href*="/show/"]';
let linklessPolls = 0;
function adPlaying() {
const widget = document.querySelector('[data-testid="now-playing-widget"]');
const hasContentLink = !!(widget && widget.querySelector(CONTENT_LINK));
// Title formats seen for ads: "Advertisement", "Advertisement · Spotify",
// "Spotify – Advertisement". A real track NAMED "Advertisement" produces
// the same title shape — but a real track always has a content link in
// the widget and an ad never does, so the link exonerates it.
if (/^(spotify\s*[–—-]\s*)?advertisement\b/i.test(document.title) && !hasContentLink) {
return true;
}
if (!widget) { linklessPolls = 0; return false; }
// aria-label only — widget textContent is the ad's brand name for real
// ads, and song/artist names containing "advertisement" would false-mute.
// Same exoneration as the title: a track named "Advertisement" has aria
// "Now playing: Advertisement by X" AND a content link; a real ad never
// has the link.
if (!hasContentLink
&& /advertisement|advertiser/i.test(widget.getAttribute('aria-label') || '')) {
return true;
}
if (widget.textContent.trim() && !hasContentLink) {
linklessPolls++;
return linklessPolls >= 2;
}
linklessPolls = 0;
return false;
}
// ── Mute state machine ──
// adMuted: we are currently muting. userMutedBefore: what to restore.
let adMuted = false;
let userMutedBefore = false;
function mediaEls() {
return document.querySelectorAll('audio, video');
}
function setMuted(m) {
mediaEls().forEach(el => { el.muted = m; });
}
function tick() {
const active = currentSettings.enabled && currentSettings.spotifyMuteAds;
if (active && adPlaying()) {
if (!adMuted) {
adMuted = true;
userMutedBefore = [...mediaEls()].some(el => el.muted);
}
// Re-assert every poll: Spotify can recreate the element mid-ad.
setMuted(true);
} else if (adMuted) {
adMuted = false;
if (!userMutedBefore) setMuted(false);
}
hideAdUI();
}
// ── Visual ad units ──
const AD_UI_SELECTOR = [
'[data-testid="ad-slot"]',
'[data-testid*="advert"]',
'iframe[src*="doubleclick.net"]',
'iframe[src*="adform"]',
].join(',');
function hideAdUI() {
const active = currentSettings.enabled && currentSettings.spotifyHideAdUI;
document.querySelectorAll(AD_UI_SELECTOR).forEach(el => {
if (active) {
if (el.style.display !== 'none') el.style.setProperty('display', 'none', 'important');
} else {
el.style.removeProperty('display');
}
});
}
// ── Drive: observer for title/widget changes + interval fallback ──
// The interval also catches media elements swapped without a DOM
// mutation in the observed subtree.
let tickTimer = null;
function scheduleTick() {
clearTimeout(tickTimer);
tickTimer = setTimeout(tick, 80);
}
const observer = new MutationObserver(scheduleTick);
function start() {
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
setInterval(tick, 1000);
tick();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
// ── Settings load + live updates (same pattern as content.js) ──
loadSettings().then(settings => {
currentSettings = settings;
tick();
});
browser.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync') return;
for (const [key, { newValue }] of Object.entries(changes)) {
if (key in DEFAULTS) currentSettings[key] = newValue;
}
tick();
});
+112
-3
// YT Focus — background script.
// Single job: the pause/resume keyboard shortcut (see manifest
// "commands"). The flipped value reaches every tab via storage.onChanged.
// Three jobs: the pause/resume keyboard shortcut, the timed-pause
// auto-resume, and the right-click "mute this channel" menu item.
// All state flows through storage.sync → storage.onChanged, so every
// tab and the popup stay in step with no messaging.
// ── Keyboard shortcut (manifest "commands") ──
// A manual toggle always cancels a pending timed pause. During focus
// hours turning off is refused; with the slow off-switch on, the
// shortcut downgrades "off" to a 10-minute timed pause (it self-heals).
browser.commands.onCommand.addListener(async (command) => {
if (command !== 'toggle-focus') return;
const settings = await loadSettings();
await STORE.set({ enabled: !settings.enabled });
if (!settings.enabled) {
await STORE.set({ enabled: true, pausedUntil: 0 });
return;
}
if (inFocusWindow(settings)) return;
if (settings.strictOff) {
await STORE.set({ enabled: false, pausedUntil: Date.now() + 10 * 60 * 1000 });
} else {
await STORE.set({ enabled: false, pausedUntil: 0 });
}
});
// ── Focus hours: force filters on inside the scheduled window ──
// Checked every 30s (and at startup). Re-enabling also clears any
// timed pause that would otherwise re-fire.
async function enforceSchedule() {
const settings = await loadSettings();
if (inFocusWindow(settings) && !settings.enabled) {
await STORE.set({ enabled: true, pausedUntil: 0 });
}
}
setInterval(enforceSchedule, 30000);
enforceSchedule();
// ── Selector-canary badge ──
// content.js counts consecutive watch-page loads where a structural
// element is missing (storage.local.ytfCanary). Three strikes ⇒ badge,
// so "YouTube changed, some rules are dead" is announced, not silent.
function updateCanaryBadge(canary) {
const broken = Object.values((canary && canary.fails) || {}).some(n => n >= 3);
browser.browserAction.setBadgeText({ text: broken ? '!' : '' });
if (broken) browser.browserAction.setBadgeBackgroundColor({ color: '#ff3b30' });
}
browser.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.ytfCanary) {
updateCanaryBadge(changes.ytfCanary.newValue);
}
});
browser.storage.local.get('ytfCanary').then(res => updateCanaryBadge(res.ytfCanary));
// ── Timed pause: auto-resume when pausedUntil passes ──
// The popup's "Pause for 10 min" sets { enabled:false, pausedUntil:ts }.
// MV2 background pages are persistent, so a plain timeout is reliable;
// the startup check below covers browser restarts.
let pauseTimer = null;
function schedulePauseCheck(until) {
clearTimeout(pauseTimer);
pauseTimer = null;
const delay = until - Date.now();
if (until <= 0) return;
if (delay <= 0) {
STORE.set({ enabled: true, pausedUntil: 0 });
return;
}
pauseTimer = setTimeout(() => {
STORE.set({ enabled: true, pausedUntil: 0 });
}, delay);
}
browser.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync' || !('pausedUntil' in changes)) return;
schedulePauseCheck(changes.pausedUntil.newValue || 0);
});
loadSettings().then(settings => {
if (settings.pausedUntil) schedulePauseCheck(settings.pausedUntil);
});
// ── Right-click a channel link → add it to the mute list ──
browser.contextMenus.create({
id: 'ytf-mute-channel',
title: 'YT Focus: mute this channel',
contexts: ['link'],
targetUrlPatterns: [
'*://www.youtube.com/@*',
'*://www.youtube.com/channel/*',
'*://www.youtube.com/c/*',
'*://www.youtube.com/user/*',
],
});
browser.contextMenus.onClicked.addListener(async (info) => {
if (info.menuItemId !== 'ytf-mute-channel') return;
const m = (info.linkUrl || '').match(
/youtube\.com\/(?:@([^/?#]+)|channel\/([^/?#]+)|c\/([^/?#]+)|user\/([^/?#]+))/
);
if (!m) return;
// Prefer the human-readable handle/name; raw channel id last (it never
// appears in card text, but muting it still catches links elsewhere).
const name = decodeURIComponent(m[1] || m[3] || m[4] || m[2] || '')
.replace(/^@/, '')
.trim()
.toLowerCase();
if (!name) return;
const settings = await loadSettings();
const muteList = Array.isArray(settings.muteList) ? settings.muteList.map(String) : [];
if (!muteList.includes(name)) {
muteList.push(name);
await STORE.set({ muteList });
}
});
+63
-52
/* ══════════════════════════════════════════════════
YT Focus v3 — CSS rules
Toggle-controlled rules gated by data-ytf-* attrs.
Always-on rules are unscoped (no attr gate).
YT Focus v4 — CSS rules
Every rule is gated by a data-ytf-* attr on <html>;
attrs are only set while the master switch is on,
so removing an attr instantly restores the element.
══════════════════════════════════════════════════ */

@@ -80,3 +81,8 @@

html[data-ytf-ads] ytd-promoted-sparkles-web-renderer,
html[data-ytf-ads] ytd-banner-promo-renderer {
html[data-ytf-ads] ytd-banner-promo-renderer,
html[data-ytf-ads] ytd-companion-slot-renderer,
html[data-ytf-ads] ytd-action-companion-ad-renderer,
html[data-ytf-ads] ytd-player-legacy-desktop-watch-ads-renderer,
html[data-ytf-ads] ytd-search-pyv-renderer,
html[data-ytf-ads] ytd-promoted-video-renderer {
display: none !important;

@@ -90,3 +96,5 @@ }

html[data-ytf-endscreen] .ytp-cards-button,
html[data-ytf-endscreen] .ytp-suggested-action {
html[data-ytf-endscreen] .ytp-suggested-action,
html[data-ytf-endscreen] .ytp-pause-overlay,
html[data-ytf-endscreen] .ytp-watermark {
display: none !important;

@@ -139,73 +147,76 @@ }

/* ═══════════════════════════════════════════════
ALWAYS ON (no per-feature toggle)
Gated by data-ytf-on so the master switch can
deactivate everything at once.
═══════════════════════════════════════════════ */
/* ── Mixes & radio cards (feeds + watch sidebar) ── */
html[data-ytf-mixes] ytd-compact-radio-renderer,
html[data-ytf-mixes] ytd-radio-renderer,
html[data-ytf-mixes] ytd-rich-item-renderer:has(a[href*="start_radio=1"]),
html[data-ytf-mixes] yt-lockup-view-model:has(a[href*="start_radio=1"]),
html[data-ytf-mixes] ytd-compact-video-renderer:has(a[href*="start_radio=1"]) {
display: none !important;
}
/* ── Top bar: voice search, create, notifications ── */
html[data-ytf-on] #voice-search-button,
html[data-ytf-on] ytd-topbar-menu-button-renderer,
html[data-ytf-on] #masthead-container #buttons ytd-button-renderer,
html[data-ytf-on] #masthead-container #buttons yt-icon-button {
/* ── Top bar: voice search, create, notifications (opt-in) ── */
html[data-ytf-topbar] #voice-search-button,
html[data-ytf-topbar] ytd-topbar-menu-button-renderer,
html[data-ytf-topbar] #masthead-container #buttons ytd-button-renderer,
html[data-ytf-topbar] #masthead-container #buttons yt-icon-button {
display: none !important;
}
/* ── Hamburger menu button ── */
html[data-ytf-on] #guide-button {
/* ── Hamburger menu button (part of Left Sidebar Clutter) ── */
html[data-ytf-leftnav] #guide-button {
display: none !important;
}
/* ── Watch page: channel info below video ── */
/* ── Watch page: channel info below video (opt-in) ── */
/* Scoped to watch page — bare #owner could match elsewhere */
html[data-ytf-on] ytd-watch-flexy ytd-video-owner-renderer,
html[data-ytf-on] ytd-watch-metadata #owner,
html[data-ytf-on] ytd-watch-flexy #upload-info {
html[data-ytf-owner] ytd-watch-flexy ytd-video-owner-renderer,
html[data-ytf-owner] ytd-watch-metadata #owner,
html[data-ytf-owner] ytd-watch-flexy #upload-info {
display: none !important;
}
/* ── Channel page: hide Shorts, Posts, Store tabs ── */
html[data-ytf-on] yt-tab-shape[tab-title="Shorts"],
html[data-ytf-on] yt-tab-shape[tab-title="Posts"],
html[data-ytf-on] yt-tab-shape[tab-title="Store"],
html[data-ytf-on] tp-yt-paper-tab:has(> div[tab-title="Shorts"]),
html[data-ytf-on] tp-yt-paper-tab:has(> div[tab-title="Posts"]),
html[data-ytf-on] tp-yt-paper-tab:has(> div[tab-title="Store"]),
html[data-ytf-on] [tab-title="Shorts"],
html[data-ytf-on] [tab-title="Posts"],
html[data-ytf-on] [tab-title="Store"] {
/* ═══════════════════════════════════════════════
MINIMAL CHANNEL PAGES (opt-in, data-ytf-channel)
Strips banner, avatar/metadata, Shorts/Posts/Store
tabs, promo shelves. Tab bar itself is preserved.
═══════════════════════════════════════════════ */
html[data-ytf-channel] yt-tab-shape[tab-title="Shorts"],
html[data-ytf-channel] yt-tab-shape[tab-title="Posts"],
html[data-ytf-channel] yt-tab-shape[tab-title="Store"],
html[data-ytf-channel] tp-yt-paper-tab:has(> div[tab-title="Shorts"]),
html[data-ytf-channel] tp-yt-paper-tab:has(> div[tab-title="Posts"]),
html[data-ytf-channel] tp-yt-paper-tab:has(> div[tab-title="Store"]),
html[data-ytf-channel] [tab-title="Shorts"],
html[data-ytf-channel] [tab-title="Posts"],
html[data-ytf-channel] [tab-title="Store"] {
display: none !important;
}
/* ── Channel page: recognition/featured shelves ── */
html[data-ytf-on] ytd-recognition-shelf-renderer,
html[data-ytf-on] ytd-channel-video-player-renderer,
html[data-ytf-on] ytd-branded-page-v2-secondary-column-renderer {
html[data-ytf-channel] ytd-recognition-shelf-renderer,
html[data-ytf-channel] ytd-channel-video-player-renderer,
html[data-ytf-channel] ytd-branded-page-v2-secondary-column-renderer {
display: none !important;
}
/* ── Channel page: about dialog/panel ── */
html[data-ytf-on] ytd-engagement-panel-section-list-renderer[target-id="channel-about-panel"],
html[data-ytf-on] ytd-about-channel-renderer,
html[data-ytf-on] tp-yt-paper-dialog:has(ytd-about-channel-renderer) {
html[data-ytf-channel] ytd-engagement-panel-section-list-renderer[target-id="channel-about-panel"],
html[data-ytf-channel] ytd-about-channel-renderer,
html[data-ytf-channel] tp-yt-paper-dialog:has(ytd-about-channel-renderer) {
display: none !important;
}
/* ── Channel page: banner ── */
html[data-ytf-on] ytd-c4-tabbed-header-renderer #banner-container,
html[data-ytf-on] ytd-c4-tabbed-header-renderer .banner-visible-area,
html[data-ytf-on] yt-image-banner-view-model,
html[data-ytf-on] #page-header-banner,
html[data-ytf-on] ytd-page-header-renderer .page-header-banner {
html[data-ytf-channel] ytd-c4-tabbed-header-renderer #banner-container,
html[data-ytf-channel] ytd-c4-tabbed-header-renderer .banner-visible-area,
html[data-ytf-channel] yt-image-banner-view-model,
html[data-ytf-channel] #page-header-banner,
html[data-ytf-channel] ytd-page-header-renderer .page-header-banner {
display: none !important;
}
/* ── Channel page: metadata (avatar, handle, sub count, description, subscribe) ── */
/* Preserved: tab bar for navigation */
html[data-ytf-on] yt-page-header-view-model,
html[data-ytf-on] yt-channel-tagline-view-model,
html[data-ytf-on] #channel-tagline,
html[data-ytf-on] ytd-c4-tabbed-header-renderer #channel-header,
html[data-ytf-on] ytd-c4-tabbed-header-renderer #channel-header-container {
html[data-ytf-channel] yt-page-header-view-model,
html[data-ytf-channel] yt-channel-tagline-view-model,
html[data-ytf-channel] #channel-tagline,
html[data-ytf-channel] ytd-c4-tabbed-header-renderer #channel-header,
html[data-ytf-channel] ytd-c4-tabbed-header-renderer #channel-header-container {
display: none !important;
}
+217
-47

@@ -18,2 +18,6 @@ // YT Focus — content script (DEFAULTS comes from defaults.js, loaded first)

blockHomeFeed: 'data-ytf-homefeed',
hideMixes: 'data-ytf-mixes',
hideOwner: 'data-ytf-owner',
minimalChannel: 'data-ytf-channel',
hideTopbar: 'data-ytf-topbar',
};

@@ -75,2 +79,3 @@

'#guide-links-secondary',
'#guide-button',
].join(','),

@@ -88,2 +93,11 @@ hideGuideSections: true,

'ytd-banner-promo-renderer',
// Watch-page companion/side ads (banner beside the player). Without
// these, Ads only appeared to work because blockSidebar removed
// #secondary entirely — Ads must stand alone.
'ytd-companion-slot-renderer',
'ytd-action-companion-ad-renderer',
'ytd-player-legacy-desktop-watch-ads-renderer',
// Promoted results in search
'ytd-search-pyv-renderer',
'ytd-promoted-video-renderer',
].join(','),

@@ -98,2 +112,6 @@ },

'.ytp-suggested-action',
// Recommendations overlay when the video is paused
'.ytp-pause-overlay',
// Channel watermark in the player corner
'.ytp-watermark',
].join(','),

@@ -115,2 +133,41 @@ },

},
hideMixes: {
selector: [
'ytd-compact-radio-renderer',
'ytd-radio-renderer',
'ytd-rich-item-renderer:has(a[href*="start_radio=1"])',
'yt-lockup-view-model:has(a[href*="start_radio=1"])',
'ytd-compact-video-renderer:has(a[href*="start_radio=1"])',
].join(','),
},
hideOwner: {
selector: [
'ytd-watch-flexy ytd-video-owner-renderer',
'ytd-watch-metadata #owner',
'ytd-watch-flexy #upload-info',
].join(','),
},
minimalChannel: {
selector: [
'ytd-recognition-shelf-renderer',
'ytd-channel-video-player-renderer',
'ytd-branded-page-v2-secondary-column-renderer',
'ytd-about-channel-renderer',
'ytd-engagement-panel-section-list-renderer[target-id="channel-about-panel"]',
'yt-image-banner-view-model',
'#page-header-banner',
'#banner-container',
'yt-page-header-view-model',
'yt-channel-tagline-view-model',
'#channel-tagline',
].join(','),
},
hideTopbar: {
selector: [
'#voice-search-button',
'ytd-topbar-menu-button-renderer',
'#masthead-container #buttons ytd-button-renderer',
'#masthead-container #buttons yt-icon-button',
].join(','),
},
};

@@ -135,31 +192,6 @@

// ── Always-on hides (no toggle, no user control) ──
const ALWAYS_HIDE_SELECTOR = [
// Watch page: channel info below video (scoped to avoid matching elsewhere)
'ytd-watch-flexy ytd-video-owner-renderer',
'ytd-watch-metadata #owner',
'ytd-watch-flexy #upload-info',
// Hamburger menu button
'#guide-button',
// Channel page: recognition/featured shelves
'ytd-recognition-shelf-renderer',
'ytd-channel-video-player-renderer',
'ytd-branded-page-v2-secondary-column-renderer',
// Channel about dialog
'ytd-about-channel-renderer',
'ytd-engagement-panel-section-list-renderer[target-id="channel-about-panel"]',
// Channel banner
'yt-image-banner-view-model',
'#page-header-banner',
'#banner-container',
// Channel metadata (avatar, handle, subscribers, description, subscribe)
'yt-page-header-view-model',
'yt-channel-tagline-view-model',
'#channel-tagline',
].join(',');
// Channel page: shelf titles to always hide (hoisted from scrub)
// Channel page (minimalChannel): shelf titles to hide (text-matched)
const BLOCKED_SHELF_TITLES = ['for you', 'official channels', 'channels', 'collaborations', 'posts'];
// Channel page: tabs to always hide
// Channel page (minimalChannel): tabs to hide
const BLOCKED_TAB_LABELS = ['shorts', 'posts', 'store'];

@@ -169,2 +201,31 @@

// ── Per-page profiles ──
// Filters can be switched off for whole page types ("strict on home,
// lenient on watch"). Unknown pages (history, playlists…) always filter.
const PAGE_KEY = {
home: 'pageHome',
watch: 'pageWatch',
search: 'pageSearch',
subs: 'pageSubs',
channel: 'pageChannel',
};
function pageType() {
const p = location.pathname;
if (p === '/') return 'home';
if (p.startsWith('/watch')) return 'watch';
if (p.startsWith('/results')) return 'search';
if (p.startsWith('/feed/subscriptions')) return 'subs';
if (p.startsWith('/@') || p.startsWith('/channel/')
|| p.startsWith('/c/') || p.startsWith('/user/')) return 'channel';
return 'other';
}
// Master switch AND this page type's profile both on
function activeHere() {
if (!currentSettings.enabled) return false;
const key = PAGE_KEY[pageType()];
return key ? currentSettings[key] !== false : true;
}
// ── Apply data attributes to <html> so CSS rules activate/deactivate ──

@@ -175,5 +236,6 @@ // data-ytf-on gates the always-on CSS; per-feature attrs gate their rules.

const root = document.documentElement;
root.toggleAttribute('data-ytf-on', !!currentSettings.enabled);
const on = activeHere();
root.toggleAttribute('data-ytf-on', on);
for (const [key, attr] of Object.entries(ATTR_MAP)) {
root.toggleAttribute(attr, !!(currentSettings.enabled && currentSettings[key]));
root.toggleAttribute(attr, !!(on && currentSettings[key]));
}

@@ -199,3 +261,3 @@ }

function skipVideoAd() {
if (!currentSettings.enabled || !currentSettings.blockAds) return;
if (!activeHere() || !currentSettings.blockAds) return;
const player = document.querySelector('#movie_player.ad-showing');

@@ -216,6 +278,49 @@ if (!player) return;

function forceAutoplayOff() {
if (!currentSettings.enabled || !currentSettings.disableAutoplay) return;
if (!activeHere() || !currentSettings.disableAutoplay) return;
document.querySelector('.ytp-autonav-toggle-button[aria-checked="true"]')?.click();
}
// ── "Video paused. Continue watching?" auto-dismiss ──
// The idle interrupt that pauses long sessions. Grouped under
// disableAutoplay: both are "let the video just play" controls.
function dismissContinueWatching() {
if (!activeHere() || !currentSettings.disableAutoplay) return;
const dialog = document.querySelector('yt-confirm-dialog-renderer');
if (dialog && /continue watching/i.test(dialog.textContent || '')) {
const btn = dialog.querySelector('#confirm-button button')
|| dialog.querySelector('#confirm-button');
btn?.click();
}
}
// ── Playback defaults: speed + theater mode ──
// Applied once per video id so the user can still override afterwards;
// never touches an ad (ad-showing) so the skipper's seek math stays sane.
let speedAppliedFor = null;
let theaterAppliedFor = null;
function applyPlaybackDefaults() {
if (!activeHere()) return;
const id = new URLSearchParams(location.search).get('v');
if (!id) return;
if (currentSettings.playbackSpeed > 0 && speedAppliedFor !== id) {
const player = document.querySelector('#movie_player:not(.ad-showing)');
const video = player && player.querySelector('video');
if (video) {
video.playbackRate = currentSettings.playbackSpeed;
speedAppliedFor = id;
}
}
if (currentSettings.defaultTheater && theaterAppliedFor !== id) {
const flexy = document.querySelector('ytd-watch-flexy');
if (flexy) {
if (!flexy.hasAttribute('theater') && !flexy.hasAttribute('fullscreen')) {
document.querySelector('#movie_player .ytp-size-button')?.click();
}
theaterAppliedFor = id;
}
}
}
// ── Mute list: hide videos by title/channel keyword ──

@@ -226,3 +331,3 @@ function applyMuteList() {

.filter(Boolean);
const active = currentSettings.enabled && terms.length > 0;
const active = activeHere() && terms.length > 0;

@@ -239,3 +344,4 @@ document.querySelectorAll(MUTE_ITEM_SELECTOR).forEach(item => {

// ── Clickbait remover ──
// Titles: rewrite SHOUTING titles (>60% caps) to sentence case.
// Titles: rewrite SHOUTING titles (>60% caps) to sentence case, keeping
// known acronyms and digit-bearing tokens (PS5, GTA6) uppercase.
// Thumbnails: swap the curated thumbnail for a real mid-video frame.

@@ -245,4 +351,20 @@ // Originals are stashed in data attributes so toggling off restores them.

// only, and rewritten titles no longer trip the caps threshold.
const KEEP_CAPS = new Set([
'ai', 'tv', 'usa', 'uk', 'us', 'eu', 'un', 'fbi', 'cia', 'nasa', 'nba',
'nfl', 'mlb', 'nhl', 'ufc', 'wwe', 'f1', 'gta', 'pc', 'diy', 'ceo',
'vs', 'rpg', 'fps', 'mmo', 'ufo', 'usb', 'gpu', 'cpu', 'ios', 'vr',
'ar', 'hd', 'llm', 'gpt', 'nyc', 'la', 'dc', 'ww2', 'wwii', 'diy',
]);
function smartSentenceCase(t) {
const out = t.toLowerCase().replace(/[a-z0-9]+/gi, (w) => {
if (KEEP_CAPS.has(w) || /\d/.test(w)) return w.toUpperCase();
if (w === 'i') return 'I';
return w;
});
return out.charAt(0).toUpperCase() + out.slice(1);
}
function applyClickbait() {
const active = currentSettings.enabled && currentSettings.deClickbait;
const active = activeHere() && currentSettings.deClickbait;

@@ -257,4 +379,3 @@ document.querySelectorAll('#video-title').forEach(el => {

if (el.dataset.ytfOrigTitle == null) el.dataset.ytfOrigTitle = t;
const lower = t.toLowerCase();
el.textContent = lower.charAt(0).toUpperCase() + lower.slice(1);
el.textContent = smartSentenceCase(t);
} else if (el.dataset.ytfOrigTitle != null) {

@@ -287,10 +408,24 @@ el.textContent = el.dataset.ytfOrigTitle;

// ── Hide (mostly) watched videos ──
// CSS can't read the progress bar's width, so this is JS-only: hide the
// containing item when the resume-progress bar shows ≥90% watched.
// Runs AFTER applyMuteList in scrub — mute's showEl pass would otherwise
// undo these hides.
function applyWatched() {
const active = activeHere() && currentSettings.hideWatched;
document.querySelectorAll('ytd-thumbnail-overlay-resume-playback-renderer #progress')
.forEach(bar => {
const item = bar.closest(MUTE_ITEM_SELECTOR);
if (!item) return;
const pct = parseFloat(bar.style.width);
if (active && pct >= 90) hideEl(item);
});
}
// ── Main scrub ──
function scrub() {
const on = currentSettings.enabled;
const on = activeHere();
const channelStrip = on && currentSettings.minimalChannel;
// ─ Always-on: selector-based ─
document.querySelectorAll(ALWAYS_HIDE_SELECTOR).forEach(on ? hideEl : showEl);
// ─ Always-on: channel shelf titles (text-matched) ─
// ─ minimalChannel: channel shelf titles (text-matched) ─
// Scoped to channel pages: in search results the wrapping

@@ -303,9 +438,9 @@ // ytd-item-section-renderer holds ALL results, and a "For you" shelf

const title = (shelf.querySelector('#title, #title-text, h2')?.textContent || '').trim().toLowerCase();
if (BLOCKED_SHELF_TITLES.some(b => title.includes(b))) (on ? hideEl : showEl)(shelf);
if (BLOCKED_SHELF_TITLES.some(b => title.includes(b))) (channelStrip ? hideEl : showEl)(shelf);
});
// ─ Always-on: channel tabs (text-matched) ─
// ─ minimalChannel: channel tabs (text-matched) ─
document.querySelectorAll('yt-tab-shape, tp-yt-paper-tab').forEach(tab => {
const label = (tab.getAttribute('tab-title') || tab.textContent || '').trim().toLowerCase();
if (BLOCKED_TAB_LABELS.includes(label)) (on ? hideEl : showEl)(tab);
if (BLOCKED_TAB_LABELS.includes(label)) (channelStrip ? hideEl : showEl)(tab);
});

@@ -315,3 +450,6 @@

forceAutoplayOff();
dismissContinueWatching();
applyPlaybackDefaults();
applyMuteList();
applyWatched();
applyClickbait();

@@ -385,3 +523,3 @@

function redirectShorts() {
if (!settingsLoaded || !currentSettings.enabled || !currentSettings.blockShorts) return;
if (!settingsLoaded || !activeHere() || !currentSettings.blockShorts) return;
const m = location.pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);

@@ -393,3 +531,3 @@ if (m) location.replace('/watch?v=' + m[1]);

function redirectHome() {
if (!settingsLoaded || !currentSettings.enabled || !currentSettings.redirectHome) return;
if (!settingsLoaded || !activeHere() || !currentSettings.redirectHome) return;
if (location.pathname === '/' && !location.search) {

@@ -400,6 +538,36 @@ location.replace('/feed/subscriptions');

// ── Selector self-test (canary) ──
// Structural elements that MUST exist on every watch page. If one goes
// missing on consecutive watch loads, YouTube probably renamed it — and
// some hide rules are silently dead. Counts live in storage.local; the
// popup shows a warning and the toolbar icon gets a badge at 3 strikes,
// so breakage is announced instead of discovered by the user.
const CANARIES = [
['player', '#movie_player'],
['sidebar', '#secondary'],
['comments', 'ytd-comments'],
['video metadata', 'ytd-watch-metadata'],
];
function runCanaryCheck() {
if (!location.pathname.startsWith('/watch')) return;
return browser.storage.local.get('ytfCanary').then(res => {
const fails = (res.ytfCanary && res.ytfCanary.fails) || {};
let changed = false;
for (const [name, sel] of CANARIES) {
const next = document.querySelector(sel) ? 0 : (fails[name] || 0) + 1;
if (next !== (fails[name] || 0)) { fails[name] = next; changed = true; }
}
if (changed) {
return browser.storage.local.set({ ytfCanary: { fails, ts: Date.now() } });
}
});
}
// ── YouTube SPA navigation hook ──
// Uses scheduleScrub for the early pass (deduplicates with observer),
// direct scrub at 800ms to catch late-rendering elements.
// direct scrub at 800ms to catch late-rendering elements. applyAttrs
// re-runs because the page type (per-page profiles) may have changed.
window.addEventListener('yt-navigate-finish', () => {
applyAttrs();
redirectShorts();

@@ -409,3 +577,5 @@ redirectHome();

setTimeout(scrub, 800);
setTimeout(runCanaryCheck, 3000);
});
setTimeout(runCanaryCheck, 3000);

@@ -412,0 +582,0 @@ // ── Init ──

@@ -23,2 +23,22 @@ // YT Focus — shared settings schema + storage helpers.

redirectHome: false, // home page → subscriptions feed (opt-in)
hideMixes: true, // algorithmic Mix/radio cards in feeds & sidebar
hideWatched: false, // hide videos you've already (mostly) watched
hideOwner: false, // channel avatar/name/subscribe under the player
minimalChannel: false, // strip channel pages: banner, avatar, tabs, shelves
hideTopbar: false, // voice search, create, notification bell
spotifyMuteAds: true, // Spotify web player: mute audio ads (can't skip — server-stitched)
spotifyHideAdUI: true, // Spotify web player: hide visual ad units
defaultTheater: false, // enter theater mode on each new video
playbackSpeed: 0, // default speed per new video; 0 = leave alone
pausedUntil: 0, // epoch ms; timed pause auto-resumes then (0 = none)
pageHome: true, // per-page profiles: where filters apply
pageWatch: true,
pageSearch: true,
pageSubs: true,
pageChannel: true,
scheduleEnabled: false, // focus hours: force filters on during the window
scheduleStart: '09:00',
scheduleEnd: '17:00',
scheduleDays: [1, 2, 3, 4, 5], // getDay() values; Mon–Fri
strictOff: false, // slow off-switch: 10s countdown to turn off
muteList: [], // hide videos matching these words/channels

@@ -37,8 +57,31 @@ };

const legacy = await browser.storage.local.get(null);
if (Object.keys(legacy).length > 0) {
await STORE.set(legacy);
stored = legacy;
// Migrate only known settings keys — storage.local also holds
// transient state like the selector-canary results.
const known = {};
for (const key of Object.keys(DEFAULTS)) {
if (key in legacy) known[key] = legacy[key];
}
if (Object.keys(known).length > 0) {
await STORE.set(known);
stored = known;
}
}
return { ...DEFAULTS, ...stored };
}
// ── Focus-hours helper (shared by background.js + popup.js) ──
// True while the schedule forces filters on. Windows may cross midnight
// (start > end). An empty day list means every day.
function inFocusWindow(settings, now = new Date()) {
if (!settings.scheduleEnabled) return false;
const days = Array.isArray(settings.scheduleDays) ? settings.scheduleDays : [];
if (days.length > 0 && !days.includes(now.getDay())) return false;
const hm = s => {
const [h, m] = String(s || '').split(':').map(Number);
return (h || 0) * 60 + (m || 0);
};
const t = now.getHours() * 60 + now.getMinutes();
const a = hm(settings.scheduleStart);
const b = hm(settings.scheduleEnd);
return a <= b ? (t >= a && t < b) : (t >= a || t < b);
}
{
"manifest_version": 2,
"name": "YT Focus",
"version": "3.3",
"description": "Blocks YouTube Shorts, comments, recommendations and sidebar clutter.",
"version": "4.1",
"description": "Declutter YouTube your way: Shorts, comments, recommendations, ads, clickbait \u2014 every filter is a toggle. Timed pause, playback defaults, right-click channel muting. Also mutes Spotify web-player ads.",
"icons": {

@@ -45,2 +45,12 @@ "48": "icons/icon48.png",

"run_at": "document_start"
},
{
"matches": [
"*://open.spotify.com/*"
],
"js": [
"defaults.js",
"spotify.js"
],
"run_at": "document_start"
}

@@ -50,3 +60,5 @@ ],

"storage",
"*://www.youtube.com/*"
"contextMenus",
"*://www.youtube.com/*",
"*://open.spotify.com/*"
],

@@ -53,0 +65,0 @@ "browser_specific_settings": {

+399
-259

@@ -8,51 +8,51 @@ <!DOCTYPE html>

:root {
--bg: #0c0c0e;
--raised: #151517;
--line: #232327;
--text: #ececee;
--muted: #9a9aa2;
--faint: #55555c;
--accent: #ff3b30;
}
body {
width: 280px;
background: #0f0f0f;
color: #f1f1f1;
font-family: 'Segoe UI', system-ui, sans-serif;
padding-bottom: 12px;
width: 320px;
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI Variable Text', 'Segoe UI', system-ui, sans-serif;
font-size: 12px;
padding-bottom: 10px;
}
/* ── Header ── */
header {
background: #ff0000;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--line);
}
header h1 { font-size: 15px; font-weight: 700; color: #fff; letter-spacing: 0.5px; }
header p { font-size: 10px; color: rgba(255,255,255,0.75); margin-top: 1px; }
.section-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
color: #555;
padding: 10px 16px 4px;
.mark {
width: 28px; height: 28px;
border-radius: 7px;
background: var(--accent);
display: grid;
place-items: center;
flex-shrink: 0;
transition: background 0.25s;
}
body.paused .mark { background: #3a3a3e; }
header h1 { font-size: 14px; font-weight: 700; letter-spacing: 0.2px; }
#statusLine { font-size: 10px; color: var(--muted); margin-top: 1px; }
body.paused #statusLine { color: var(--faint); }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 16px;
border-bottom: 1px solid #1a1a1a;
cursor: pointer;
transition: background 0.12s;
gap: 12px;
}
.toggle-row:hover { background: #181818; }
.toggle-info strong { display: block; font-size: 12px; font-weight: 600; }
.toggle-info span { font-size: 10px; color: #666; margin-top: 1px; display: block; }
.switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
/* ── Switches ── */
.switch { position: relative; width: 34px; height: 19px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0;
background: #2e2e2e;
border-radius: 20px;
transition: background 0.18s;
background: #2c2c30;
border-radius: 19px;
transition: background 0.15s;
cursor: pointer;

@@ -63,26 +63,161 @@ }

position: absolute;
width: 14px; height: 14px;
width: 13px; height: 13px;
left: 3px; top: 3px;
background: #888;
background: #808088;
border-radius: 50%;
transition: transform 0.18s, background 0.18s;
transition: transform 0.15s, background 0.15s;
}
input:checked + .slider { background: #ff0000; }
input:checked + .slider::before { transform: translateX(16px); background: #fff; }
input:checked + .slider { background: var(--accent); }
input:checked + .slider::before { transform: translateX(15px); background: #fff; }
input:focus-visible + .slider { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Master switch sits on the red header, so red-on-red won't read */
header .slider { background: rgba(0,0,0,0.35); }
header input:checked + .slider { background: rgba(0,0,0,0.55); }
header input:checked + .slider::before { background: #fff; }
/* ── Canary warning ── */
#canaryWarn {
display: none;
margin: 8px 16px 0;
padding: 7px 10px;
background: rgba(255, 170, 0, 0.08);
border: 1px solid rgba(255, 170, 0, 0.4);
border-radius: 6px;
font-size: 10px;
line-height: 1.45;
color: #e6b45a;
}
#canaryWarn.show { display: block; }
body.paused .section-label,
body.paused .toggle-row,
body.paused .mute-row,
body.paused .chips,
body.paused footer { opacity: 0.4; }
/* ── Page pills ── */
.pill-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 16px 12px; }
.pill { position: relative; }
.pill input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.pill span {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--raised);
color: var(--muted);
font-size: 10px;
cursor: pointer;
user-select: none;
}
.pill input:checked + span {
border-color: var(--accent);
color: var(--text);
background: rgba(255, 59, 48, 0.12);
}
.pill input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }
.pill-hint { padding: 0 16px 4px; font-size: 10px; color: var(--faint); }
/* ── Focus hours ── */
.sched-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 16px 2px;
font-size: 11px;
color: var(--muted);
}
.sched-row input[type="time"] {
background: var(--raised);
border: 1px solid var(--line);
color: var(--text);
border-radius: 5px;
font-size: 11px;
padding: 3px 5px;
font-family: Consolas, monospace;
}
.day-row { display: flex; gap: 4px; padding: 6px 16px 12px; }
.day {
width: 26px; height: 22px;
border-radius: 5px;
border: 1px solid var(--line);
background: var(--raised);
color: var(--muted);
font-size: 10px;
cursor: pointer;
}
.day.on {
border-color: var(--accent);
color: var(--text);
background: rgba(255, 59, 48, 0.12);
}
/* ── Timed pause ── */
.pause-row { padding: 8px 16px; border-bottom: 1px solid var(--line); }
#pauseBtn {
width: 100%;
background: #1c1c20;
border: 1px solid var(--line);
color: var(--muted);
border-radius: 6px;
padding: 6px 0;
font-size: 11px;
cursor: pointer;
}
#pauseBtn:hover { border-color: var(--accent); color: var(--text); }
body.paused #pauseBtn { border-color: var(--accent); color: var(--text); opacity: 1; filter: none; }
body.paused .pause-row { opacity: 1; }
/* ── Inline select (default speed) ── */
.toggle-row select {
background: #26262b;
border: 1px solid var(--line);
color: var(--text);
border-radius: 5px;
font-size: 11px;
padding: 3px 4px;
flex-shrink: 0;
}
/* ── Collapsible sections ── */
details { border-bottom: 1px solid var(--line); }
summary {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 16px;
cursor: pointer;
user-select: none;
}
summary::-webkit-details-marker { display: none; }
summary:hover { background: var(--raised); }
.sec-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 1.1px;
text-transform: uppercase;
color: var(--muted);
}
.sec-count {
font-family: Consolas, monospace;
font-size: 10px;
color: var(--faint);
}
.sec-count.live { color: var(--accent); }
details[open] summary .sec-title { color: var(--text); }
/* ── Toggle rows ── */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 16px;
cursor: pointer;
}
.toggle-row:hover { background: var(--raised); }
.toggle-info strong { display: block; font-size: 12px; font-weight: 600; }
.toggle-info span { display: block; font-size: 10px; color: var(--muted); margin-top: 1px; }
.toggle-row:last-child { padding-bottom: 10px; }
body.paused details,
body.paused .util { opacity: 0.45; filter: saturate(0.2); }
/* ── Mute list + backup ── */
.util { padding: 0 0 2px; border-bottom: 1px solid var(--line); }
.mute-row, .backup-row {
display: flex;
gap: 6px;
padding: 6px 16px 0;
padding: 8px 16px 2px;
}

@@ -92,15 +227,15 @@ .mute-row input {

min-width: 0;
background: #1a1a1a;
border: 1px solid #2e2e2e;
color: #f1f1f1;
border-radius: 4px;
background: var(--raised);
border: 1px solid var(--line);
color: var(--text);
border-radius: 5px;
padding: 5px 8px;
font-size: 11px;
}
.mute-row input:focus { outline: 1px solid #ff0000; }
.mute-row input:focus { outline: 1px solid var(--accent); }
.mute-row button, .backup-row button {
background: #2e2e2e;
background: #26262b;
border: none;
color: #f1f1f1;
border-radius: 4px;
color: var(--text);
border-radius: 5px;
padding: 5px 12px;

@@ -110,13 +245,9 @@ font-size: 11px;

}
.mute-row button:hover, .backup-row button:hover { background: #3a3a3a; }
.mute-row button:hover, .backup-row button:hover { background: #333338; }
.chips {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 6px 16px 2px;
}
.chips { display: flex; flex-wrap: wrap; gap: 5px; padding: 7px 16px 9px; }
.chips:empty { padding: 0 16px 6px; }
.chip {
background: #1f1f1f;
border: 1px solid #2e2e2e;
background: var(--raised);
border: 1px solid var(--line);
border-radius: 10px;

@@ -130,11 +261,7 @@ padding: 2px 6px 2px 9px;

.chip-x {
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0;
background: none; border: none;
color: var(--muted); cursor: pointer;
font-size: 12px; line-height: 1; padding: 0;
}
.chip-x:hover { color: #ff4444; }
.chip-x:hover { color: var(--accent); }

@@ -144,10 +271,10 @@ #backupBox {

width: calc(100% - 32px);
margin: 6px 16px 0;
height: 56px;
background: #1a1a1a;
border: 1px solid #2e2e2e;
color: #aaa;
margin: 6px 16px 10px;
height: 52px;
background: var(--raised);
border: 1px solid var(--line);
color: var(--muted);
font-size: 9px;
font-family: Consolas, monospace;
border-radius: 4px;
border-radius: 5px;
padding: 6px;

@@ -158,7 +285,19 @@ resize: vertical;

footer {
padding: 10px 16px 0;
padding: 9px 16px 0;
font-size: 10px;
color: #333;
text-align: center;
color: var(--faint);
display: flex;
justify-content: space-between;
align-items: center;
font-family: Consolas, monospace;
}
#kofi {
color: var(--muted);
text-decoration: none;
border: 1px solid var(--line);
border-radius: 10px;
padding: 2px 9px;
font-family: 'Segoe UI', system-ui, sans-serif;
}
#kofi:hover { color: var(--text); border-color: var(--accent); }
</style>

@@ -169,11 +308,12 @@ </head>

<header>
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
<rect width="26" height="26" rx="5" fill="rgba(255,255,255,0.15)"/>
<polygon points="9,6 21,13 9,20" fill="white"/>
</svg>
<div style="flex: 1">
<div class="mark">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<polygon points="3,1 12,7 3,13" fill="white"/>
</svg>
</div>
<div style="flex:1">
<h1>YT Focus</h1>
<p>Watch without the noise</p>
<div id="statusLine">…</div>
</div>
<label class="switch" title="Master switch — pause/resume everything">
<label class="switch" title="Master switch — pause/resume everything (Alt+Shift+Y)">
<input type="checkbox" id="enabled"/>

@@ -184,201 +324,201 @@ <span class="slider"></span>

<div class="section-label">Video Page</div>
<div class="toggle-row" data-key="blockSidebar">
<div class="toggle-info">
<strong>Recommended Sidebar</strong>
<span>Hides the "Up next" feed</span>
</div>
<label class="switch">
<input type="checkbox" id="blockSidebar"/>
<span class="slider"></span>
</label>
<div id="canaryWarn">
<strong>YouTube changed its layout.</strong> Some filters may have
stopped matching on watch pages. Everything else keeps working —
an extension update is needed.
</div>
<div class="toggle-row" data-key="blockComments">
<div class="toggle-info">
<strong>Comments</strong>
<span>Removes the entire comments section</span>
</div>
<label class="switch">
<input type="checkbox" id="blockComments"/>
<span class="slider"></span>
</label>
<div class="pause-row">
<button id="pauseBtn">Pause for 10 min</button>
</div>
<div class="toggle-row" data-key="blockActions">
<div class="toggle-info">
<strong>Action Bar</strong>
<span>Like, dislike, share, ask, save</span>
</div>
<label class="switch">
<input type="checkbox" id="blockActions"/>
<span class="slider"></span>
</label>
</div>
<details data-section="video" open>
<summary><span class="sec-title">Video Page</span><span class="sec-count"></span></summary>
<div class="toggle-row" data-key="blockDescription">
<div class="toggle-info">
<strong>Description & Ask</strong>
<span>Description box, Ask panel, chapters</span>
<div class="toggle-row" data-key="blockSidebar">
<div class="toggle-info"><strong>Recommended sidebar</strong><span>Hides the "Up next" feed</span></div>
<label class="switch"><input type="checkbox" id="blockSidebar"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockDescription"/>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row" data-key="blockEndscreen">
<div class="toggle-info">
<strong>End Screens & Cards</strong>
<span>Video-end wall, in-video teasers</span>
<div class="toggle-row" data-key="blockComments">
<div class="toggle-info"><strong>Comments</strong><span>Removes the comments section</span></div>
<label class="switch"><input type="checkbox" id="blockComments"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockEndscreen"/>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row" data-key="blockAds">
<div class="toggle-info">
<strong>Ads</strong>
<span>Hides display ads, skips video ads</span>
<div class="toggle-row" data-key="blockActions">
<div class="toggle-info"><strong>Action bar</strong><span>Like, dislike, share, ask, save</span></div>
<label class="switch"><input type="checkbox" id="blockActions"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockAds"/>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row" data-key="blockLiveChat">
<div class="toggle-info">
<strong>Live Chat</strong>
<span>Chat panel on streams &amp; premieres</span>
<div class="toggle-row" data-key="blockDescription">
<div class="toggle-info"><strong>Description &amp; Ask</strong><span>Description box, Ask panel, chapters</span></div>
<label class="switch"><input type="checkbox" id="blockDescription"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockLiveChat"/>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row" data-key="disableAutoplay">
<div class="toggle-info">
<strong>Stop Autoplay</strong>
<span>Forces "autoplay next" off</span>
<div class="toggle-row" data-key="hideOwner">
<div class="toggle-info"><strong>Channel info under video</strong><span>Avatar, name, Subscribe button</span></div>
<label class="switch"><input type="checkbox" id="hideOwner"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="disableAutoplay"/>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row" data-key="blockEndscreen">
<div class="toggle-info"><strong>End screens &amp; cards</strong><span>Video-end wall, in-video teasers</span></div>
<label class="switch"><input type="checkbox" id="blockEndscreen"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="blockAds">
<div class="toggle-info"><strong>Ads</strong><span>Hides display ads, skips video ads</span></div>
<label class="switch"><input type="checkbox" id="blockAds"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="blockLiveChat">
<div class="toggle-info"><strong>Live chat</strong><span>Chat panel on streams &amp; premieres</span></div>
<label class="switch"><input type="checkbox" id="blockLiveChat"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="disableAutoplay">
<div class="toggle-info"><strong>Stop autoplay</strong><span>Autoplay-next off, no "Continue watching?"</span></div>
<label class="switch"><input type="checkbox" id="disableAutoplay"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="defaultTheater">
<div class="toggle-info"><strong>Theater mode</strong><span>Wide player on every video</span></div>
<label class="switch"><input type="checkbox" id="defaultTheater"/><span class="slider"></span></label>
</div>
<div class="toggle-row">
<div class="toggle-info"><strong>Default speed</strong><span>Applied once per video — still adjustable</span></div>
<select id="playbackSpeed">
<option value="0">Off</option>
<option value="1">1×</option>
<option value="1.25">1.25×</option>
<option value="1.5">1.5×</option>
<option value="1.75">1.75×</option>
<option value="2">2×</option>
</select>
</div>
</details>
<div class="section-label">Home Feed</div>
<details data-section="feeds" open>
<summary><span class="sec-title">Feeds &amp; Search</span><span class="sec-count"></span></summary>
<div class="toggle-row" data-key="blockShorts">
<div class="toggle-info">
<strong>Shorts</strong>
<span>Shelves, nav link, search results</span>
<div class="toggle-row" data-key="blockShorts">
<div class="toggle-info"><strong>Shorts</strong><span>Shelves, nav link, search results</span></div>
<label class="switch"><input type="checkbox" id="blockShorts"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockShorts"/>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row" data-key="blockShelves">
<div class="toggle-info">
<strong>Topic Shelves</strong>
<span>"Explore more topics", AI Ask panel</span>
<div class="toggle-row" data-key="blockShelves">
<div class="toggle-info"><strong>Topic shelves</strong><span>"Explore more topics", AI Ask panel</span></div>
<label class="switch"><input type="checkbox" id="blockShelves"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockShelves"/>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row" data-key="hideMixes">
<div class="toggle-info"><strong>Mixes &amp; radio</strong><span>Endless algorithmic "Mix" playlists</span></div>
<label class="switch"><input type="checkbox" id="hideMixes"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="hideWatched">
<div class="toggle-info"><strong>Already watched</strong><span>Hides videos you finished (90%+)</span></div>
<label class="switch"><input type="checkbox" id="hideWatched"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="blockChips">
<div class="toggle-info"><strong>Filter bar</strong><span>Podcasts, Gaming, News… chips</span></div>
<label class="switch"><input type="checkbox" id="blockChips"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="blockMerch">
<div class="toggle-info"><strong>Merch &amp; posts</strong><span>Merch/ticket shelves, community posts</span></div>
<label class="switch"><input type="checkbox" id="blockMerch"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="deClickbait">
<div class="toggle-info"><strong>Clickbait remover</strong><span>Real video frames, de-CAPS titles</span></div>
<label class="switch"><input type="checkbox" id="deClickbait"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="blockHomeFeed">
<div class="toggle-info"><strong>Entire home feed</strong><span>Blank home page — search only</span></div>
<label class="switch"><input type="checkbox" id="blockHomeFeed"/><span class="slider"></span></label>
</div>
</details>
<div class="toggle-row" data-key="blockChips">
<div class="toggle-info">
<strong>Filter Bar</strong>
<span>Podcasts, Gaming, News… chips</span>
<details data-section="nav">
<summary><span class="sec-title">Navigation &amp; Channels</span><span class="sec-count"></span></summary>
<div class="toggle-row" data-key="blockLeftNav">
<div class="toggle-info"><strong>Left sidebar clutter</strong><span>You, History, Subscriptions list…</span></div>
<label class="switch"><input type="checkbox" id="blockLeftNav"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockChips"/>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row" data-key="hideTopbar">
<div class="toggle-info"><strong>Top bar buttons</strong><span>Notifications bell, Create, voice search</span></div>
<label class="switch"><input type="checkbox" id="hideTopbar"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="minimalChannel">
<div class="toggle-info"><strong>Minimal channel pages</strong><span>No banner, avatar, promo shelves, extra tabs</span></div>
<label class="switch"><input type="checkbox" id="minimalChannel"/><span class="slider"></span></label>
</div>
<div class="toggle-row" data-key="redirectHome">
<div class="toggle-info"><strong>Home → Subscriptions</strong><span>Home page opens your subs feed</span></div>
<label class="switch"><input type="checkbox" id="redirectHome"/><span class="slider"></span></label>
</div>
</details>
<div class="toggle-row" data-key="blockMerch">
<div class="toggle-info">
<strong>Merch & Posts</strong>
<span>Merch/ticket shelves, community posts</span>
<details data-section="pages" data-nocount>
<summary><span class="sec-title">Where Filters Run</span><span class="sec-count"></span></summary>
<div class="pill-hint">Filters only apply on the selected pages — e.g. strict Home, untouched Watch.</div>
<div class="pill-row">
<label class="pill"><input type="checkbox" id="pageHome"/><span>Home</span></label>
<label class="pill"><input type="checkbox" id="pageWatch"/><span>Watch</span></label>
<label class="pill"><input type="checkbox" id="pageSearch"/><span>Search</span></label>
<label class="pill"><input type="checkbox" id="pageSubs"/><span>Subscriptions</span></label>
<label class="pill"><input type="checkbox" id="pageChannel"/><span>Channels</span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockMerch"/>
<span class="slider"></span>
</label>
</div>
</details>
<div class="toggle-row" data-key="deClickbait">
<div class="toggle-info">
<strong>Clickbait Remover</strong>
<span>Real video frames, de-CAPS titles</span>
<details data-section="discipline" data-nocount>
<summary><span class="sec-title">Focus Discipline</span><span class="sec-count"></span></summary>
<div class="toggle-row" data-key="scheduleEnabled">
<div class="toggle-info"><strong>Focus hours</strong><span>Filters lock on during this window</span></div>
<label class="switch"><input type="checkbox" id="scheduleEnabled"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="deClickbait"/>
<span class="slider"></span>
</label>
</div>
<div class="sched-row">
<input type="time" id="scheduleStart" value="09:00"/>
<span>to</span>
<input type="time" id="scheduleEnd" value="17:00"/>
</div>
<div class="day-row" id="dayRow">
<button class="day" data-day="0">S</button>
<button class="day" data-day="1">M</button>
<button class="day" data-day="2">T</button>
<button class="day" data-day="3">W</button>
<button class="day" data-day="4">T</button>
<button class="day" data-day="5">F</button>
<button class="day" data-day="6">S</button>
</div>
<div class="toggle-row" data-key="blockHomeFeed">
<div class="toggle-info">
<strong>Entire Home Feed</strong>
<span>Blank home page — search only</span>
<div class="toggle-row" data-key="strictOff">
<div class="toggle-info"><strong>Slow off-switch</strong><span>Turning off waits 10 s — timed pause stays instant</span></div>
<label class="switch"><input type="checkbox" id="strictOff"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockHomeFeed"/>
<span class="slider"></span>
</label>
</div>
</details>
<div class="section-label">Navigation</div>
<details data-section="spotify">
<summary><span class="sec-title">Spotify Web Player</span><span class="sec-count"></span></summary>
<div class="toggle-row" data-key="blockLeftNav">
<div class="toggle-info">
<strong>Left Sidebar Clutter</strong>
<span>Hides You, History, Subscriptions list…</span>
<div class="toggle-row" data-key="spotifyMuteAds">
<div class="toggle-info"><strong>Mute ads</strong><span>Silences audio ads (can't be skipped)</span></div>
<label class="switch"><input type="checkbox" id="spotifyMuteAds"/><span class="slider"></span></label>
</div>
<label class="switch">
<input type="checkbox" id="blockLeftNav"/>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row" data-key="spotifyHideAdUI">
<div class="toggle-info"><strong>Hide ad banners</strong><span>Visual ad units in the web player</span></div>
<label class="switch"><input type="checkbox" id="spotifyHideAdUI"/><span class="slider"></span></label>
</div>
</details>
<div class="toggle-row" data-key="redirectHome">
<div class="toggle-info">
<strong>Home → Subscriptions</strong>
<span>Home page opens your subs feed</span>
<div class="util">
<div class="mute-row">
<input type="text" id="muteInput" placeholder="Mute a word or channel…" maxlength="60"/>
<button id="muteAdd">Add</button>
</div>
<label class="switch">
<input type="checkbox" id="redirectHome"/>
<span class="slider"></span>
</label>
<div class="chips" id="muteChips"></div>
</div>
<div class="section-label">Muted Words &amp; Channels</div>
<div class="mute-row">
<input type="text" id="muteInput" placeholder="word or channel name…" maxlength="60"/>
<button id="muteAdd">Add</button>
<div class="util">
<div class="backup-row">
<button id="exportBtn">Export settings</button>
<button id="importBtn">Import</button>
</div>
<textarea id="backupBox" spellcheck="false" placeholder="Export fills this box (and copies to clipboard). Paste a backup here and hit Import to restore."></textarea>
</div>
<div class="chips" id="muteChips"></div>
<div class="section-label">Backup</div>
<footer>
<span>v4.1</span>
<a id="kofi" href="https://ko-fi.com/midwestmysterymeat" title="Support YT Focus on Ko-fi">☕ Ko-fi</a>
<span>Alt+Shift+Y pauses</span>
</footer>
<div class="backup-row">
<button id="exportBtn">Export</button>
<button id="importBtn">Import</button>
</div>
<textarea id="backupBox" spellcheck="false" placeholder="Export fills this box (and copies to clipboard). Paste here and hit Import to restore."></textarea>
<footer>Changes apply instantly · Alt+Shift+Y to pause</footer>
<script src="defaults.js"></script>

@@ -385,0 +525,0 @@ <script src="popup.js"></script>

+208
-18

@@ -1,13 +0,55 @@

// DEFAULTS / STORE / loadSettings come from defaults.js (loaded first
// in popup.html). Checkbox rows cover the boolean settings; muteList
// and backup have their own handlers below.
// DEFAULTS / STORE / loadSettings / inFocusWindow come from defaults.js
// (loaded first in popup.html). Checkbox rows cover the boolean
// settings; the speed select, timed pause, focus hours, mute list and
// backup have their own handlers.
const boolKeys = Object.keys(DEFAULTS).filter(k => typeof DEFAULTS[k] === 'boolean');
let current = { ...DEFAULTS };
let muteList = [];
// Dim the feature rows while the master switch is off
function updatePausedState() {
const master = document.getElementById('enabled');
document.body.classList.toggle('paused', !(master && master.checked));
// ── Status line + per-section counts ──
function fmtTime(ts) {
return new Date(ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function locked() {
return current.enabled && inFocusWindow(current);
}
function updateStatus(flash) {
document.body.classList.toggle('paused', !current.enabled);
let on = 0, total = 0;
document.querySelectorAll('details').forEach(sec => {
const count = sec.querySelector('.sec-count');
if (sec.hasAttribute('data-nocount')) { count.textContent = ''; return; }
const boxes = sec.querySelectorAll('input[type="checkbox"]');
const secOn = [...boxes].filter(b => b.checked).length;
on += secOn; total += boxes.length;
count.textContent = secOn + '/' + boxes.length;
count.classList.toggle('live', current.enabled && secOn > 0);
});
const status = document.getElementById('statusLine');
if (flash) {
status.textContent = flash;
} else if (locked()) {
status.textContent = on + ' of ' + total + ' filters on · focus hours until '
+ (current.scheduleEnd || '');
} else if (current.enabled) {
status.textContent = on + ' of ' + total + ' filters on';
} else if (current.pausedUntil > Date.now()) {
status.textContent = 'Paused — resumes ' + fmtTime(current.pausedUntil);
} else {
status.textContent = 'Paused';
}
const pauseBtn = document.getElementById('pauseBtn');
pauseBtn.textContent = current.enabled
? (locked() ? 'Focus hours — pause locked' : 'Pause for 10 min')
: (current.pausedUntil > Date.now()
? 'Resume now (auto ' + fmtTime(current.pausedUntil) + ')'
: 'Resume');
}
// ── Save/load ──
function save() {

@@ -17,18 +59,32 @@ const settings = {};

const el = document.getElementById(key);
settings[key] = el ? el.checked : DEFAULTS[key];
if (el) settings[key] = el.checked;
});
settings.playbackSpeed = parseFloat(document.getElementById('playbackSpeed').value) || 0;
Object.assign(current, settings);
// Content scripts pick this up via storage.onChanged — no messaging needed
STORE.set(settings);
updatePausedState();
updateStatus();
}
function renderDays() {
const days = Array.isArray(current.scheduleDays) ? current.scheduleDays : [];
document.querySelectorAll('#dayRow .day').forEach(btn => {
btn.classList.toggle('on', days.includes(Number(btn.dataset.day)));
});
}
function loadIntoUI(settings) {
current = { ...DEFAULTS, ...settings };
boolKeys.forEach(key => {
const el = document.getElementById(key);
if (el) el.checked = !!settings[key];
if (el) el.checked = !!current[key];
});
muteList = Array.isArray(settings.muteList) ? settings.muteList.map(String) : [];
document.getElementById('playbackSpeed').value = String(current.playbackSpeed || 0);
document.getElementById('scheduleStart').value = current.scheduleStart || '09:00';
document.getElementById('scheduleEnd').value = current.scheduleEnd || '17:00';
muteList = Array.isArray(current.muteList) ? current.muteList.map(String) : [];
renderDays();
renderMuteList();
updatePausedState();
updateStatus();
}

@@ -38,2 +94,90 @@

// Selector canary: show the "YouTube changed" banner after 3 strikes
browser.storage.local.get('ytfCanary').then(res => {
const fails = (res.ytfCanary && res.ytfCanary.fails) || {};
const broken = Object.values(fails).some(n => n >= 3);
document.getElementById('canaryWarn').classList.toggle('show', broken);
});
// Refresh live if the background auto-resumes (or another window changes
// settings) while this popup is open.
browser.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'sync') return;
loadIntoUI(await loadSettings());
});
// ── Master switch ──
// A manual flip cancels a pending timed pause. During focus hours,
// turning off is refused. With the slow off-switch on, turning off
// starts a 10 s countdown; a second click cancels it.
let offTimer = null;
let offLeft = 0;
function cancelOffCountdown() {
clearInterval(offTimer);
offTimer = null;
updateStatus();
}
document.getElementById('enabled').addEventListener('change', (e) => {
const box = e.target;
if (offTimer) { // countdown running — this click cancels it
box.checked = true;
cancelOffCountdown();
updateStatus('Kept on.');
return;
}
if (!box.checked && locked()) {
box.checked = true;
updateStatus('Focus hours — locked until ' + (current.scheduleEnd || ''));
return;
}
if (!box.checked && current.strictOff) {
box.checked = true; // stays on until the countdown finishes
offLeft = 10;
updateStatus('Turning off in ' + offLeft + ' s — click again to cancel');
offTimer = setInterval(() => {
offLeft--;
if (offLeft > 0) {
updateStatus('Turning off in ' + offLeft + ' s — click again to cancel');
return;
}
cancelOffCountdown();
box.checked = false;
current.enabled = false;
current.pausedUntil = 0;
STORE.set({ enabled: false, pausedUntil: 0 });
updateStatus();
}, 1000);
return;
}
current.enabled = box.checked;
current.pausedUntil = 0;
STORE.set({ enabled: current.enabled, pausedUntil: 0 });
updateStatus();
});
// ── Timed pause (always instant — it self-heals) ──
document.getElementById('pauseBtn').addEventListener('click', () => {
if (current.enabled && locked()) {
updateStatus('Focus hours — locked until ' + (current.scheduleEnd || ''));
return;
}
if (current.enabled) {
current.enabled = false;
current.pausedUntil = Date.now() + 10 * 60 * 1000;
STORE.set({ enabled: false, pausedUntil: current.pausedUntil });
} else {
current.enabled = true;
current.pausedUntil = 0;
STORE.set({ enabled: true, pausedUntil: 0 });
}
document.getElementById('enabled').checked = current.enabled;
updateStatus();
});
// Click anywhere on a row to toggle — except on the switch itself,

@@ -44,5 +188,5 @@ // where the checkbox toggles natively and fires the change listener.

row.addEventListener('click', (e) => {
if (e.target.closest('.switch')) return;
if (e.target.closest('.switch') || e.target.closest('select')) return;
const key = row.dataset.key;
const checkbox = document.getElementById(key);
const checkbox = key && document.getElementById(key);
if (checkbox) {

@@ -55,4 +199,4 @@ checkbox.checked = !checkbox.checked;

// Direct checkbox change
boolKeys.forEach(key => {
// Direct checkbox change (master handled above)
boolKeys.filter(k => k !== 'enabled').forEach(key => {
const el = document.getElementById(key);

@@ -62,2 +206,36 @@ if (el) el.addEventListener('change', save);

document.getElementById('playbackSpeed').addEventListener('change', save);
// ── Focus hours: time range + day picker ──
['scheduleStart', 'scheduleEnd'].forEach(key => {
document.getElementById(key).addEventListener('change', (e) => {
const value = e.target.value || DEFAULTS[key];
current[key] = value;
STORE.set({ [key]: value });
updateStatus();
});
});
document.querySelectorAll('#dayRow .day').forEach(btn => {
btn.addEventListener('click', () => {
const day = Number(btn.dataset.day);
const days = new Set(Array.isArray(current.scheduleDays) ? current.scheduleDays : []);
days.has(day) ? days.delete(day) : days.add(day);
current.scheduleDays = [...days].sort();
STORE.set({ scheduleDays: current.scheduleDays });
renderDays();
updateStatus();
});
});
// Remember which sections the user keeps open
document.querySelectorAll('details').forEach(sec => {
const memoKey = 'ytf-open-' + sec.dataset.section;
const memo = localStorage.getItem(memoKey);
if (memo !== null) sec.open = memo === '1';
sec.addEventListener('toggle', () => {
localStorage.setItem(memoKey, sec.open ? '1' : '0');
});
});
// ── Mute list ──

@@ -102,2 +280,9 @@ function renderMuteList() {

// ── Ko-fi link: open in a tab (popup pages can't navigate themselves) ──
document.getElementById('kofi').addEventListener('click', (e) => {
e.preventDefault();
browser.tabs.create({ url: 'https://ko-fi.com/midwestmysterymeat' });
window.close();
});
// ── Backup: export/import all settings as JSON ──

@@ -108,2 +293,3 @@ const backupBox = document.getElementById('backupBox');

const settings = await loadSettings();
delete settings.pausedUntil; // transient state, not a preference
backupBox.value = JSON.stringify(settings, null, 2);

@@ -125,7 +311,11 @@ backupBox.select();

for (const key of Object.keys(DEFAULTS)) {
if (!(key in parsed)) continue;
if (typeof DEFAULTS[key] === 'boolean' && typeof parsed[key] === 'boolean') {
if (!(key in parsed) || key === 'pausedUntil') continue;
const kind = typeof DEFAULTS[key];
if ((kind === 'boolean' || kind === 'number' || kind === 'string')
&& typeof parsed[key] === kind) {
clean[key] = parsed[key];
} else if (key === 'muteList' && Array.isArray(parsed[key])) {
clean[key] = parsed[key].map(String);
} else if (key === 'scheduleDays' && Array.isArray(parsed[key])) {
clean[key] = parsed[key].map(Number).filter(n => n >= 0 && n <= 6);
}

@@ -132,0 +322,0 @@ }

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet