YT Focus — Hide Shorts, Feeds & Distractions
| // YT Focus — background script. | ||
| // Single job: the pause/resume keyboard shortcut (see manifest | ||
| // "commands"). The flipped value reaches every tab via storage.onChanged. | ||
| browser.commands.onCommand.addListener(async (command) => { | ||
| if (command !== 'toggle-focus') return; | ||
| const settings = await loadSettings(); | ||
| await STORE.set({ enabled: !settings.enabled }); | ||
| }); |
+42
| // YT Focus — shared settings schema + storage helpers. | ||
| // Loaded before content.js (manifest content_scripts), popup.js | ||
| // (popup.html) and background.js (manifest background), so all three | ||
| // reference this single DEFAULTS/STORE/loadSettings. | ||
| const DEFAULTS = { | ||
| enabled: true, // master switch — gates every rule, CSS and JS | ||
| blockShorts: true, | ||
| blockSidebar: true, | ||
| blockComments: true, | ||
| blockActions: true, | ||
| blockDescription: true, | ||
| blockShelves: true, | ||
| blockChips: true, | ||
| blockLeftNav: true, | ||
| blockAds: true, // display ads + auto-skip video ads | ||
| blockEndscreen: true, // end-of-video wall, in-video card teasers | ||
| blockMerch: true, // merch/ticket/donation shelves, community posts | ||
| blockLiveChat: true, // live chat panel on streams | ||
| disableAutoplay: true, // force the player's autoplay-next toggle off | ||
| deClickbait: true, // real video frames as thumbnails, de-CAPS titles | ||
| blockHomeFeed: false, // blank the home page entirely (opt-in) | ||
| redirectHome: false, // home page → subscriptions feed (opt-in) | ||
| muteList: [], // hide videos matching these words/channels | ||
| }; | ||
| // Settings live in storage.sync so Firefox Sync carries them across | ||
| // devices (works locally too when sync is off). | ||
| const STORE = browser.storage.sync; | ||
| // Returns merged settings; first run migrates from the pre-3.3 | ||
| // storage.local area so existing users keep their configuration. | ||
| async function loadSettings() { | ||
| let stored = await STORE.get(null); | ||
| if (Object.keys(stored).length === 0) { | ||
| const legacy = await browser.storage.local.get(null); | ||
| if (Object.keys(legacy).length > 0) { | ||
| await STORE.set(legacy); | ||
| stored = legacy; | ||
| } | ||
| } | ||
| return { ...DEFAULTS, ...stored }; | ||
| } |
+83
-37
@@ -65,6 +65,50 @@ /* ══════════════════════════════════════════════════ | ||
| html[data-ytf-shelves] ytd-rich-shelf-renderer:not([is-shorts]), | ||
| html[data-ytf-shelves] ytd-rich-section-renderer { | ||
| html[data-ytf-shelves] ytd-rich-section-renderer, | ||
| html[data-ytf-shelves] ytd-search ytd-shelf-renderer, | ||
| html[data-ytf-shelves] ytd-search ytd-horizontal-card-list-renderer, | ||
| html[data-ytf-shelves] ytd-secondary-search-container-renderer { | ||
| display: none !important; | ||
| } | ||
| /* ── Ads (display/in-feed — video ads handled by content.js skipper) ── */ | ||
| html[data-ytf-ads] #masthead-ad, | ||
| html[data-ytf-ads] #player-ads, | ||
| html[data-ytf-ads] ytd-ad-slot-renderer, | ||
| html[data-ytf-ads] ytd-rich-item-renderer:has(ytd-ad-slot-renderer), | ||
| html[data-ytf-ads] ytd-in-feed-ad-layout-renderer, | ||
| html[data-ytf-ads] ytd-display-ad-renderer, | ||
| html[data-ytf-ads] ytd-promoted-sparkles-web-renderer, | ||
| html[data-ytf-ads] ytd-banner-promo-renderer { | ||
| display: none !important; | ||
| } | ||
| /* ── End screens & in-video cards ── */ | ||
| html[data-ytf-endscreen] .ytp-endscreen-content, | ||
| html[data-ytf-endscreen] .ytp-ce-element, | ||
| html[data-ytf-endscreen] .ytp-cards-teaser, | ||
| html[data-ytf-endscreen] .ytp-cards-button, | ||
| html[data-ytf-endscreen] .ytp-suggested-action { | ||
| display: none !important; | ||
| } | ||
| /* ── Merch shelves & community posts ── */ | ||
| html[data-ytf-merch] ytd-merch-shelf-renderer, | ||
| html[data-ytf-merch] ytd-ticket-shelf-renderer, | ||
| html[data-ytf-merch] ytd-donation-shelf-renderer, | ||
| html[data-ytf-merch] ytd-post-renderer, | ||
| html[data-ytf-merch] ytd-rich-item-renderer:has(ytd-post-renderer) { | ||
| display: none !important; | ||
| } | ||
| /* ── Live chat on streams ── */ | ||
| html[data-ytf-livechat] ytd-watch-flexy #chat, | ||
| html[data-ytf-livechat] ytd-live-chat-frame { | ||
| display: none !important; | ||
| } | ||
| /* ── Entire home feed (opt-in blank homepage) ── */ | ||
| html[data-ytf-homefeed] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer { | ||
| display: none !important; | ||
| } | ||
| /* ── Filter chips bar ── */ | ||
@@ -94,11 +138,13 @@ html[data-ytf-chips] #chips-wrapper, | ||
| /* ═══════════════════════ | ||
| ALWAYS ON (no toggle) | ||
| ═══════════════════════ */ | ||
| /* ═══════════════════════════════════════════════ | ||
| ALWAYS ON (no per-feature toggle) | ||
| Gated by data-ytf-on so the master switch can | ||
| deactivate everything at once. | ||
| ═══════════════════════════════════════════════ */ | ||
| /* ── Top bar: voice search, create, notifications ── */ | ||
| #voice-search-button, | ||
| ytd-topbar-menu-button-renderer, | ||
| #masthead-container #buttons ytd-button-renderer, | ||
| #masthead-container #buttons yt-icon-button { | ||
| 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 { | ||
| display: none !important; | ||
@@ -108,3 +154,3 @@ } | ||
| /* ── Hamburger menu button ── */ | ||
| #guide-button { | ||
| html[data-ytf-on] #guide-button { | ||
| display: none !important; | ||
@@ -115,5 +161,5 @@ } | ||
| /* Scoped to watch page — bare #owner could match elsewhere */ | ||
| ytd-watch-flexy ytd-video-owner-renderer, | ||
| ytd-watch-metadata #owner, | ||
| ytd-watch-flexy #upload-info { | ||
| 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 { | ||
| display: none !important; | ||
@@ -123,11 +169,11 @@ } | ||
| /* ── Channel page: hide Shorts, Posts, Store tabs ── */ | ||
| yt-tab-shape[tab-title="Shorts"], | ||
| yt-tab-shape[tab-title="Posts"], | ||
| yt-tab-shape[tab-title="Store"], | ||
| tp-yt-paper-tab:has(> div[tab-title="Shorts"]), | ||
| tp-yt-paper-tab:has(> div[tab-title="Posts"]), | ||
| tp-yt-paper-tab:has(> div[tab-title="Store"]), | ||
| [tab-title="Shorts"], | ||
| [tab-title="Posts"], | ||
| [tab-title="Store"] { | ||
| 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"] { | ||
| display: none !important; | ||
@@ -137,5 +183,5 @@ } | ||
| /* ── Channel page: recognition/featured shelves ── */ | ||
| ytd-recognition-shelf-renderer, | ||
| ytd-channel-video-player-renderer, | ||
| ytd-branded-page-v2-secondary-column-renderer { | ||
| 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 { | ||
| display: none !important; | ||
@@ -145,5 +191,5 @@ } | ||
| /* ── Channel page: about dialog/panel ── */ | ||
| ytd-engagement-panel-section-list-renderer[target-id="channel-about-panel"], | ||
| ytd-about-channel-renderer, | ||
| tp-yt-paper-dialog:has(ytd-about-channel-renderer) { | ||
| 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) { | ||
| display: none !important; | ||
@@ -153,7 +199,7 @@ } | ||
| /* ── Channel page: banner ── */ | ||
| ytd-c4-tabbed-header-renderer #banner-container, | ||
| ytd-c4-tabbed-header-renderer .banner-visible-area, | ||
| yt-image-banner-view-model, | ||
| #page-header-banner, | ||
| ytd-page-header-renderer .page-header-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 { | ||
| display: none !important; | ||
@@ -164,8 +210,8 @@ } | ||
| /* Preserved: tab bar for navigation */ | ||
| yt-page-header-view-model, | ||
| yt-channel-tagline-view-model, | ||
| #channel-tagline, | ||
| ytd-c4-tabbed-header-renderer #channel-header, | ||
| ytd-c4-tabbed-header-renderer #channel-header-container { | ||
| 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 { | ||
| display: none !important; | ||
| } |
+204
-33
@@ -1,14 +0,3 @@ | ||
| // YT Focus v3 — content script | ||
| // YT Focus — content script (DEFAULTS comes from defaults.js, loaded first) | ||
| const DEFAULTS = { | ||
| blockShorts: true, | ||
| blockSidebar: true, | ||
| blockComments: true, | ||
| blockActions: true, | ||
| blockDescription: true, | ||
| blockShelves: true, | ||
| blockChips: true, | ||
| blockLeftNav: true, | ||
| }; | ||
| // Maps setting keys → data attributes on <html> | ||
@@ -24,2 +13,7 @@ const ATTR_MAP = { | ||
| blockLeftNav: 'data-ytf-leftnav', | ||
| blockAds: 'data-ytf-ads', | ||
| blockEndscreen: 'data-ytf-endscreen', | ||
| blockMerch: 'data-ytf-merch', | ||
| blockLiveChat: 'data-ytf-livechat', | ||
| blockHomeFeed: 'data-ytf-homefeed', | ||
| }; | ||
@@ -64,3 +58,10 @@ | ||
| blockShelves: { | ||
| selector: 'ytd-rich-shelf-renderer:not([is-shorts]),ytd-rich-section-renderer', | ||
| selector: [ | ||
| 'ytd-rich-shelf-renderer:not([is-shorts])', | ||
| 'ytd-rich-section-renderer', | ||
| // Search results: "People also watched", "For you", card carousels | ||
| 'ytd-search ytd-shelf-renderer', | ||
| 'ytd-search ytd-horizontal-card-list-renderer', | ||
| 'ytd-secondary-search-container-renderer', | ||
| ].join(','), | ||
| }, | ||
@@ -78,4 +79,48 @@ blockChips: { | ||
| }, | ||
| blockAds: { | ||
| selector: [ | ||
| '#masthead-ad', | ||
| '#player-ads', | ||
| 'ytd-ad-slot-renderer', | ||
| 'ytd-in-feed-ad-layout-renderer', | ||
| 'ytd-display-ad-renderer', | ||
| 'ytd-promoted-sparkles-web-renderer', | ||
| 'ytd-banner-promo-renderer', | ||
| ].join(','), | ||
| }, | ||
| blockEndscreen: { | ||
| selector: [ | ||
| '.ytp-endscreen-content', | ||
| '.ytp-ce-element', | ||
| '.ytp-cards-teaser', | ||
| '.ytp-cards-button', | ||
| '.ytp-suggested-action', | ||
| ].join(','), | ||
| }, | ||
| blockMerch: { | ||
| selector: [ | ||
| 'ytd-merch-shelf-renderer', | ||
| 'ytd-ticket-shelf-renderer', | ||
| 'ytd-donation-shelf-renderer', | ||
| 'ytd-post-renderer', | ||
| ].join(','), | ||
| }, | ||
| blockLiveChat: { | ||
| selector: 'ytd-watch-flexy #chat,ytd-live-chat-frame', | ||
| }, | ||
| blockHomeFeed: { | ||
| selector: 'ytd-browse[page-subtype="home"] ytd-rich-grid-renderer', | ||
| }, | ||
| }; | ||
| // ── Mute list: containers to text-match against muteList terms ── | ||
| const MUTE_ITEM_SELECTOR = [ | ||
| 'ytd-rich-item-renderer', | ||
| 'ytd-video-renderer', | ||
| 'ytd-compact-video-renderer', | ||
| 'ytd-grid-video-renderer', | ||
| 'ytd-playlist-video-renderer', | ||
| 'yt-lockup-view-model', | ||
| ].join(','); | ||
| // Nav entry rules — single pass for both Shorts + LeftNav | ||
@@ -122,10 +167,9 @@ const NAV_RULES = [ | ||
| // ── Apply data attributes to <html> so CSS rules activate/deactivate ── | ||
| // data-ytf-on gates the always-on CSS; per-feature attrs gate their rules. | ||
| // Master switch off ⇒ no attrs at all ⇒ every CSS rule goes inert. | ||
| function applyAttrs() { | ||
| const root = document.documentElement; | ||
| root.toggleAttribute('data-ytf-on', !!currentSettings.enabled); | ||
| for (const [key, attr] of Object.entries(ATTR_MAP)) { | ||
| if (currentSettings[key]) { | ||
| root.setAttribute(attr, ''); | ||
| } else { | ||
| root.removeAttribute(attr); | ||
| } | ||
| root.toggleAttribute(attr, !!(currentSettings.enabled && currentSettings[key])); | ||
| } | ||
@@ -145,11 +189,106 @@ } | ||
| // ── Ad skipper ── | ||
| // Cosmetic hiding can't remove video ads (they're part of the stream), | ||
| // so when the player enters ad-showing state: jump to the ad's end and | ||
| // click Skip as soon as it exists. No mute/rate changes — nothing to | ||
| // restore when the real video resumes. | ||
| function skipVideoAd() { | ||
| if (!currentSettings.enabled || !currentSettings.blockAds) return; | ||
| const player = document.querySelector('#movie_player.ad-showing'); | ||
| if (!player) return; | ||
| const video = player.querySelector('video'); | ||
| if (video && isFinite(video.duration) && video.duration > 0) { | ||
| video.currentTime = video.duration; | ||
| } | ||
| player.querySelector( | ||
| '.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern' | ||
| )?.click(); | ||
| } | ||
| // ── Autoplay-next killer ── | ||
| // Clicking the toggle flips aria-checked to false, so this never loops; | ||
| // if YouTube (or the user) turns it back on, the next scrub re-disables it. | ||
| function forceAutoplayOff() { | ||
| if (!currentSettings.enabled || !currentSettings.disableAutoplay) return; | ||
| document.querySelector('.ytp-autonav-toggle-button[aria-checked="true"]')?.click(); | ||
| } | ||
| // ── Mute list: hide videos by title/channel keyword ── | ||
| function applyMuteList() { | ||
| const terms = (currentSettings.muteList || []) | ||
| .map(t => String(t).toLowerCase()) | ||
| .filter(Boolean); | ||
| const active = currentSettings.enabled && terms.length > 0; | ||
| document.querySelectorAll(MUTE_ITEM_SELECTOR).forEach(item => { | ||
| if (!active) { showEl(item); return; } | ||
| const title = item.querySelector('#video-title, h3')?.textContent || ''; | ||
| const channel = item.querySelector('ytd-channel-name, .yt-lockup-metadata-view-model__metadata')?.textContent || ''; | ||
| const hay = (title + ' ' + channel).toLowerCase(); | ||
| terms.some(t => hay.includes(t)) ? hideEl(item) : showEl(item); | ||
| }); | ||
| } | ||
| // ── Clickbait remover ── | ||
| // Titles: rewrite SHOUTING titles (>60% caps) to sentence case. | ||
| // Thumbnails: swap the curated thumbnail for a real mid-video frame. | ||
| // Originals are stashed in data attributes so toggling off restores them. | ||
| // Safe against observer loops: the MutationObserver watches childList | ||
| // only, and rewritten titles no longer trip the caps threshold. | ||
| function applyClickbait() { | ||
| const active = currentSettings.enabled && currentSettings.deClickbait; | ||
| document.querySelectorAll('#video-title').forEach(el => { | ||
| if (active) { | ||
| const t = el.textContent; | ||
| const letters = t.replace(/[^A-Za-z]/g, ''); | ||
| if (letters.length < 10) return; | ||
| const upper = letters.replace(/[^A-Z]/g, '').length; | ||
| if (upper / letters.length <= 0.6) return; | ||
| if (el.dataset.ytfOrigTitle == null) el.dataset.ytfOrigTitle = t; | ||
| const lower = t.toLowerCase(); | ||
| el.textContent = lower.charAt(0).toUpperCase() + lower.slice(1); | ||
| } else if (el.dataset.ytfOrigTitle != null) { | ||
| el.textContent = el.dataset.ytfOrigTitle; | ||
| delete el.dataset.ytfOrigTitle; | ||
| } | ||
| }); | ||
| document.querySelectorAll('ytd-thumbnail img, yt-thumbnail-view-model img').forEach(img => { | ||
| if (active) { | ||
| const m = (img.src || '').match(/i\.ytimg\.com\/vi(?:_webp)?\/([A-Za-z0-9_-]{6,})\//); | ||
| if (!m) return; | ||
| const frame = `https://i.ytimg.com/vi/${m[1]}/hq2.jpg`; | ||
| if (img.src === frame) return; | ||
| if (img.dataset.ytfOrigSrc == null) { | ||
| img.dataset.ytfOrigSrc = img.src; | ||
| img.dataset.ytfOrigSrcset = img.srcset || ''; | ||
| } | ||
| img.removeAttribute('srcset'); | ||
| img.src = frame; | ||
| } else if (img.dataset.ytfOrigSrc != null) { | ||
| img.src = img.dataset.ytfOrigSrc; | ||
| if (img.dataset.ytfOrigSrcset) img.srcset = img.dataset.ytfOrigSrcset; | ||
| delete img.dataset.ytfOrigSrc; | ||
| delete img.dataset.ytfOrigSrcset; | ||
| } | ||
| }); | ||
| } | ||
| // ── Main scrub ── | ||
| function scrub() { | ||
| const on = currentSettings.enabled; | ||
| // ─ Always-on: selector-based ─ | ||
| document.querySelectorAll(ALWAYS_HIDE_SELECTOR).forEach(hideEl); | ||
| document.querySelectorAll(ALWAYS_HIDE_SELECTOR).forEach(on ? hideEl : showEl); | ||
| // ─ Always-on: channel shelf titles (text-matched) ─ | ||
| document.querySelectorAll('ytd-shelf-renderer, ytd-item-section-renderer').forEach(shelf => { | ||
| // Scoped to channel pages: in search results the wrapping | ||
| // ytd-item-section-renderer holds ALL results, and a "For you" shelf | ||
| // title inside it would otherwise hide the entire results list. | ||
| document.querySelectorAll( | ||
| 'ytd-browse[page-subtype="channels"] ytd-shelf-renderer, ytd-browse[page-subtype="channels"] ytd-item-section-renderer' | ||
| ).forEach(shelf => { | ||
| const title = (shelf.querySelector('#title, #title-text, h2')?.textContent || '').trim().toLowerCase(); | ||
| if (BLOCKED_SHELF_TITLES.some(b => title.includes(b))) hideEl(shelf); | ||
| if (BLOCKED_SHELF_TITLES.some(b => title.includes(b))) (on ? hideEl : showEl)(shelf); | ||
| }); | ||
@@ -160,8 +299,13 @@ | ||
| const label = (tab.getAttribute('tab-title') || tab.textContent || '').trim().toLowerCase(); | ||
| if (BLOCKED_TAB_LABELS.includes(label)) hideEl(tab); | ||
| if (BLOCKED_TAB_LABELS.includes(label)) (on ? hideEl : showEl)(tab); | ||
| }); | ||
| skipVideoAd(); | ||
| forceAutoplayOff(); | ||
| applyMuteList(); | ||
| applyClickbait(); | ||
| // ─ Toggle-controlled: rule-based ─ | ||
| for (const [key, rule] of Object.entries(JS_RULES)) { | ||
| const active = currentSettings[key]; | ||
| const active = on && currentSettings[key]; | ||
@@ -194,3 +338,3 @@ if (rule.selector) { | ||
| for (const rule of NAV_RULES) { | ||
| const active = currentSettings[rule.key]; | ||
| const active = on && currentSettings[rule.key]; | ||
| const match = | ||
@@ -225,2 +369,21 @@ rule.hrefs.some(h => href === h || href.startsWith(h + '/')) || | ||
| // ── Shorts player redirect ── | ||
| // The ytd-shorts player is hidden by CSS when blockShorts is on, which | ||
| // would leave /shorts/<id> pages blank — send them to the normal player. | ||
| // Only runs after settings load, so a disabled toggle is respected. | ||
| let settingsLoaded = false; | ||
| function redirectShorts() { | ||
| if (!settingsLoaded || !currentSettings.enabled || !currentSettings.blockShorts) return; | ||
| const m = location.pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/); | ||
| if (m) location.replace('/watch?v=' + m[1]); | ||
| } | ||
| // ── Home → Subscriptions redirect (opt-in) ── | ||
| function redirectHome() { | ||
| if (!settingsLoaded || !currentSettings.enabled || !currentSettings.redirectHome) return; | ||
| if (location.pathname === '/' && !location.search) { | ||
| location.replace('/feed/subscriptions'); | ||
| } | ||
| } | ||
| // ── YouTube SPA navigation hook ── | ||
@@ -230,2 +393,4 @@ // Uses scheduleScrub for the early pass (deduplicates with observer), | ||
| window.addEventListener('yt-navigate-finish', () => { | ||
| redirectShorts(); | ||
| redirectHome(); | ||
| scheduleScrub(); | ||
@@ -246,16 +411,22 @@ setTimeout(scrub, 800); | ||
| // ── Load settings from storage ── | ||
| browser.storage.local.get(DEFAULTS).then(settings => { | ||
| currentSettings = { ...DEFAULTS, ...settings }; | ||
| // ── Load settings from storage (defaults.js handles sync + migration) ── | ||
| loadSettings().then(settings => { | ||
| currentSettings = settings; | ||
| settingsLoaded = true; | ||
| applyAttrs(); | ||
| scrub(); | ||
| redirectShorts(); | ||
| redirectHome(); | ||
| }); | ||
| // ── Listen for live updates from popup ── | ||
| browser.runtime.onMessage.addListener((msg) => { | ||
| if (msg.type === 'settingsUpdate' && msg.settings) { | ||
| currentSettings = { ...DEFAULTS, ...msg.settings }; | ||
| applyAttrs(); | ||
| scrub(); | ||
| // ── Live updates: fires in every YouTube tab on popup save / shortcut ── | ||
| 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; | ||
| } | ||
| applyAttrs(); | ||
| scrub(); | ||
| redirectShorts(); | ||
| redirectHome(); | ||
| }); | ||
@@ -262,0 +433,0 @@ |
+16
-1
| { | ||
| "manifest_version": 2, | ||
| "name": "YT Focus", | ||
| "version": "3.0", | ||
| "version": "3.3", | ||
| "description": "Blocks YouTube Shorts, comments, recommendations and sidebar clutter.", | ||
@@ -18,2 +18,16 @@ "icons": { | ||
| }, | ||
| "background": { | ||
| "scripts": [ | ||
| "defaults.js", | ||
| "background.js" | ||
| ] | ||
| }, | ||
| "commands": { | ||
| "toggle-focus": { | ||
| "suggested_key": { | ||
| "default": "Alt+Shift+Y" | ||
| }, | ||
| "description": "YT Focus: pause/resume" | ||
| } | ||
| }, | ||
| "content_scripts": [ | ||
@@ -25,2 +39,3 @@ { | ||
| "js": [ | ||
| "defaults.js", | ||
| "content.js" | ||
@@ -27,0 +42,0 @@ ], |
+199
-10
@@ -71,2 +71,82 @@ <!DOCTYPE html> | ||
| /* 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; } | ||
| body.paused .section-label, | ||
| body.paused .toggle-row, | ||
| body.paused .mute-row, | ||
| body.paused .chips, | ||
| body.paused footer { opacity: 0.4; } | ||
| .mute-row, .backup-row { | ||
| display: flex; | ||
| gap: 6px; | ||
| padding: 6px 16px 0; | ||
| } | ||
| .mute-row input { | ||
| flex: 1; | ||
| min-width: 0; | ||
| background: #1a1a1a; | ||
| border: 1px solid #2e2e2e; | ||
| color: #f1f1f1; | ||
| border-radius: 4px; | ||
| padding: 5px 8px; | ||
| font-size: 11px; | ||
| } | ||
| .mute-row input:focus { outline: 1px solid #ff0000; } | ||
| .mute-row button, .backup-row button { | ||
| background: #2e2e2e; | ||
| border: none; | ||
| color: #f1f1f1; | ||
| border-radius: 4px; | ||
| padding: 5px 12px; | ||
| font-size: 11px; | ||
| cursor: pointer; | ||
| } | ||
| .mute-row button:hover, .backup-row button:hover { background: #3a3a3a; } | ||
| .chips { | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| gap: 5px; | ||
| padding: 6px 16px 2px; | ||
| } | ||
| .chip { | ||
| background: #1f1f1f; | ||
| border: 1px solid #2e2e2e; | ||
| border-radius: 10px; | ||
| padding: 2px 6px 2px 9px; | ||
| font-size: 10px; | ||
| display: inline-flex; | ||
| align-items: center; | ||
| gap: 4px; | ||
| } | ||
| .chip-x { | ||
| background: none; | ||
| border: none; | ||
| color: #888; | ||
| cursor: pointer; | ||
| font-size: 12px; | ||
| line-height: 1; | ||
| padding: 0; | ||
| } | ||
| .chip-x:hover { color: #ff4444; } | ||
| #backupBox { | ||
| display: block; | ||
| width: calc(100% - 32px); | ||
| margin: 6px 16px 0; | ||
| height: 56px; | ||
| background: #1a1a1a; | ||
| border: 1px solid #2e2e2e; | ||
| color: #aaa; | ||
| font-size: 9px; | ||
| font-family: Consolas, monospace; | ||
| border-radius: 4px; | ||
| padding: 6px; | ||
| resize: vertical; | ||
| } | ||
| footer { | ||
@@ -87,6 +167,10 @@ padding: 10px 16px 0; | ||
| </svg> | ||
| <div> | ||
| <div style="flex: 1"> | ||
| <h1>YT Focus</h1> | ||
| <p>Watch without the noise</p> | ||
| </div> | ||
| <label class="switch" title="Master switch — pause/resume everything"> | ||
| <input type="checkbox" id="enabled"/> | ||
| <span class="slider"></span> | ||
| </label> | ||
| </header> | ||
@@ -101,3 +185,3 @@ | ||
| </div> | ||
| <label class="switch" onclick="event.stopPropagation()"> | ||
| <label class="switch"> | ||
| <input type="checkbox" id="blockSidebar"/> | ||
@@ -113,3 +197,3 @@ <span class="slider"></span> | ||
| </div> | ||
| <label class="switch" onclick="event.stopPropagation()"> | ||
| <label class="switch"> | ||
| <input type="checkbox" id="blockComments"/> | ||
@@ -125,3 +209,3 @@ <span class="slider"></span> | ||
| </div> | ||
| <label class="switch" onclick="event.stopPropagation()"> | ||
| <label class="switch"> | ||
| <input type="checkbox" id="blockActions"/> | ||
@@ -137,3 +221,3 @@ <span class="slider"></span> | ||
| </div> | ||
| <label class="switch" onclick="event.stopPropagation()"> | ||
| <label class="switch"> | ||
| <input type="checkbox" id="blockDescription"/> | ||
@@ -144,2 +228,46 @@ <span class="slider"></span> | ||
| <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> | ||
| <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 & 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>Forces "autoplay next" off</span> | ||
| </div> | ||
| <label class="switch"> | ||
| <input type="checkbox" id="disableAutoplay"/> | ||
| <span class="slider"></span> | ||
| </label> | ||
| </div> | ||
| <div class="section-label">Home Feed</div> | ||
@@ -152,3 +280,3 @@ | ||
| </div> | ||
| <label class="switch" onclick="event.stopPropagation()"> | ||
| <label class="switch"> | ||
| <input type="checkbox" id="blockShorts"/> | ||
@@ -164,3 +292,3 @@ <span class="slider"></span> | ||
| </div> | ||
| <label class="switch" onclick="event.stopPropagation()"> | ||
| <label class="switch"> | ||
| <input type="checkbox" id="blockShelves"/> | ||
@@ -176,3 +304,3 @@ <span class="slider"></span> | ||
| </div> | ||
| <label class="switch" onclick="event.stopPropagation()"> | ||
| <label class="switch"> | ||
| <input type="checkbox" id="blockChips"/> | ||
@@ -183,2 +311,35 @@ <span class="slider"></span> | ||
| <div class="toggle-row" data-key="blockMerch"> | ||
| <div class="toggle-info"> | ||
| <strong>Merch & 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> | ||
| <div class="section-label">Navigation</div> | ||
@@ -191,3 +352,3 @@ | ||
| </div> | ||
| <label class="switch" onclick="event.stopPropagation()"> | ||
| <label class="switch"> | ||
| <input type="checkbox" id="blockLeftNav"/> | ||
@@ -198,5 +359,33 @@ <span class="slider"></span> | ||
| <footer>Changes apply instantly on YouTube tabs</footer> | ||
| <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> | ||
| <div class="section-label">Muted Words & Channels</div> | ||
| <div class="mute-row"> | ||
| <input type="text" id="muteInput" placeholder="word or channel name…" maxlength="60"/> | ||
| <button id="muteAdd">Add</button> | ||
| </div> | ||
| <div class="chips" id="muteChips"></div> | ||
| <div class="section-label">Backup</div> | ||
| <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> | ||
| <script src="popup.js"></script> | ||
| </body> | ||
| </html> |
+103
-30
@@ -1,25 +0,16 @@ | ||
| const DEFAULTS = { | ||
| blockShorts: true, | ||
| blockSidebar: true, | ||
| blockComments: true, | ||
| blockActions: true, | ||
| blockDescription: true, | ||
| blockShelves: true, | ||
| blockChips: true, | ||
| blockLeftNav: true, | ||
| }; | ||
| // 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. | ||
| const boolKeys = Object.keys(DEFAULTS).filter(k => typeof DEFAULTS[k] === 'boolean'); | ||
| let muteList = []; | ||
| const keys = Object.keys(DEFAULTS); | ||
| // 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)); | ||
| } | ||
| // Load saved settings into checkboxes | ||
| browser.storage.local.get(DEFAULTS).then(settings => { | ||
| keys.forEach(key => { | ||
| const el = document.getElementById(key); | ||
| if (el) el.checked = !!settings[key]; | ||
| }); | ||
| }); | ||
| function save() { | ||
| const settings = {}; | ||
| keys.forEach(key => { | ||
| boolKeys.forEach(key => { | ||
| const el = document.getElementById(key); | ||
@@ -29,15 +20,25 @@ settings[key] = el ? el.checked : DEFAULTS[key]; | ||
| browser.storage.local.set(settings); | ||
| // Content scripts pick this up via storage.onChanged — no messaging needed | ||
| STORE.set(settings); | ||
| updatePausedState(); | ||
| } | ||
| // Notify all open YouTube tabs so changes apply instantly | ||
| browser.tabs.query({ url: '*://www.youtube.com/*' }).then(tabs => { | ||
| tabs.forEach(tab => { | ||
| browser.tabs.sendMessage(tab.id, { type: 'settingsUpdate', settings }).catch(() => {}); | ||
| }); | ||
| function loadIntoUI(settings) { | ||
| boolKeys.forEach(key => { | ||
| const el = document.getElementById(key); | ||
| if (el) el.checked = !!settings[key]; | ||
| }); | ||
| muteList = Array.isArray(settings.muteList) ? settings.muteList.map(String) : []; | ||
| renderMuteList(); | ||
| updatePausedState(); | ||
| } | ||
| // Click anywhere on a row to toggle | ||
| loadSettings().then(loadIntoUI); | ||
| // Click anywhere on a row to toggle — except on the switch itself, | ||
| // where the checkbox toggles natively and fires the change listener. | ||
| // (Inline stopPropagation handlers are blocked by extension-page CSP.) | ||
| document.querySelectorAll('.toggle-row').forEach(row => { | ||
| row.addEventListener('click', () => { | ||
| row.addEventListener('click', (e) => { | ||
| if (e.target.closest('.switch')) return; | ||
| const key = row.dataset.key; | ||
@@ -52,6 +53,78 @@ const checkbox = document.getElementById(key); | ||
| // Direct checkbox change (stopPropagation in HTML prevents double-fire) | ||
| keys.forEach(key => { | ||
| // Direct checkbox change | ||
| boolKeys.forEach(key => { | ||
| const el = document.getElementById(key); | ||
| if (el) el.addEventListener('change', save); | ||
| }); | ||
| // ── Mute list ── | ||
| function renderMuteList() { | ||
| const wrap = document.getElementById('muteChips'); | ||
| wrap.textContent = ''; | ||
| muteList.forEach((term, i) => { | ||
| const chip = document.createElement('span'); | ||
| chip.className = 'chip'; | ||
| chip.textContent = term; | ||
| const x = document.createElement('button'); | ||
| x.className = 'chip-x'; | ||
| x.textContent = '×'; | ||
| x.title = 'Remove'; | ||
| x.addEventListener('click', () => { | ||
| muteList.splice(i, 1); | ||
| STORE.set({ muteList }); | ||
| renderMuteList(); | ||
| }); | ||
| chip.appendChild(x); | ||
| wrap.appendChild(chip); | ||
| }); | ||
| } | ||
| function addMuteTerm() { | ||
| const input = document.getElementById('muteInput'); | ||
| const term = input.value.trim().toLowerCase(); | ||
| if (term && !muteList.includes(term)) { | ||
| muteList.push(term); | ||
| STORE.set({ muteList }); | ||
| renderMuteList(); | ||
| } | ||
| input.value = ''; | ||
| input.focus(); | ||
| } | ||
| document.getElementById('muteAdd').addEventListener('click', addMuteTerm); | ||
| document.getElementById('muteInput').addEventListener('keydown', (e) => { | ||
| if (e.key === 'Enter') addMuteTerm(); | ||
| }); | ||
| // ── Backup: export/import all settings as JSON ── | ||
| const backupBox = document.getElementById('backupBox'); | ||
| document.getElementById('exportBtn').addEventListener('click', async () => { | ||
| const settings = await loadSettings(); | ||
| backupBox.value = JSON.stringify(settings, null, 2); | ||
| backupBox.select(); | ||
| try { await navigator.clipboard.writeText(backupBox.value); } catch {} | ||
| }); | ||
| document.getElementById('importBtn').addEventListener('click', async () => { | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(backupBox.value); | ||
| } catch { | ||
| backupBox.value = '⚠ Paste valid JSON (use Export for the format)'; | ||
| return; | ||
| } | ||
| // Only accept known keys with the right types | ||
| const clean = {}; | ||
| for (const key of Object.keys(DEFAULTS)) { | ||
| if (!(key in parsed)) continue; | ||
| if (typeof DEFAULTS[key] === 'boolean' && typeof parsed[key] === 'boolean') { | ||
| clean[key] = parsed[key]; | ||
| } else if (key === 'muteList' && Array.isArray(parsed[key])) { | ||
| clean[key] = parsed[key].map(String); | ||
| } | ||
| } | ||
| await STORE.set(clean); | ||
| loadIntoUI(await loadSettings()); | ||
| backupBox.value = '✓ Imported'; | ||
| }); |
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