FilterTube v3.3.0 builds on the earlier performance and whitelist-mode work with watch-page SPA recovery hardening, Mix/watch fallback-menu fixes, stronger collaboration roster recovery, cross-browser subscribed-channels import hardening, and smaller UX controls around backups and menu injection. This technical documentation covers the filtering logic, identity recovery behavior, mode switching, and user experience enhancements.
The collaboration pipeline now treats YouTube's explicit Collaborators sheet as authoritative:
shortBylineText.runs[0]
.navigationEndpoint.showSheetCommand
.panelLoadingStrategy.inlineContent.sheetViewModel
.header.panelHeaderViewModel.title.content == "Collaborators"
Why this matters:
- prefetch/global search can see several candidates for the same
videoId - avatar stacks and direct list models are useful warm-up fallbacks, but they can contain weak composite labels
- a longer fallback list is not necessarily richer than the actual
Collaboratorssheet
Runtime rules:
injector.jstags header-backed rosters ascollaborators-sheetand scores them above fallback candidatescontent_bridge.jsandinjector.jssanitize collaborator lists before caching, menu rendering, and expected-count stamping- placeholder rows such as
and 2 moreare removed - weak name-only composite rows are removed when fully covered by two other collaborator labels, for example
Daddy Yankee BizarrapbesideDaddy YankeeandBizarrap - Mix/Radio containers remain excluded from collaborator promotion through renderer, overlay, RD playlist, and
Mix -/–/—title guards
FilterTube now also includes a profile-aware device sync surface in Accounts & Sync.
Core modules:
tab-view.js
-> Accounts & Sync UI
-> live session state
-> trusted-link UI
nanah_sync_adapter.js
-> converts Nanah payloads into FilterTube apply/import requests
io_manager.js
-> canonical merge/replace/import logic for active/main/kids/full scopes
Nanah client
-> pairing
-> SAS verification
-> signaling
-> WebRTC data channel
sequenceDiagram
participant A as "Device A"
participant Relay as "Signaling relay"
participant B as "Device B"
A->>Relay: create / host pairing session
B->>Relay: join pairing session
A->>B: verify same safety phrase
B->>A: verify same safety phrase
A->>B: settings payload over direct data channel
- the relay is only for connection setup
- trusted links live in local extension storage
- PINs stay local and are never transmitted through Nanah
- explicit remote target profile can be chosen during a live session
- managed links can pin one fixed receiver-side local profile for later sessions
- refresh ends the live session but not the saved trust state
FIRST MANAGED PARENT -> CHILD CONNECTION
may require one local parent approval on child device
LATER MATCHING UPDATES
depends on saved managed-link policy:
autoApply
reconnectMode
lockedChildMode
strict child protection preset
FilterTube v3.2.6 introduces a typography update, replacing the previous serif-based system with a modern, highly readable sans-serif design:
Font Family Changes:
/* Before (v3.2.4 and earlier) */
--ft-font-family-base: "Inter", sans-serif;
--ft-font-family-serif: "Crimson Pro", "Playfair Display", "Georgia", serif;
--ft-font-family-display: "Inter", sans-serif;
/* After (v3.2.6) */
--ft-font-family-base: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
--ft-font-family-serif: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; /* Unified */
--ft-font-family-display: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
--ft-font-family-mono: "JetBrains Mono", "SF Mono", "Fira Code", "Source Code Pro", monospace;Rationale:
- Consistency: All headings and body text now use Inter for a cohesive, modern look
- Readability: Sans-serif fonts are more readable on screens, especially at smaller sizes
- Performance: Reduced font loading by eliminating separate serif font families
- Technical Display: Added dedicated monospace font for code/channel IDs
The font scale has been tightened for better hierarchy and modern aesthetics:
┌─────────────────────────────────────────────────────────┐
│ FilterTube Typography Scale (v3.2.6) │
├─────────────────────────────────────────────────────────┤
│ │
│ Token Size (rem) Pixels Use Case │
│ ───────────────── ────────── ────── ──────────── │
│ caption 0.75 12px Hints, labels │
│ body 0.875 14px Body text │
│ base 0.9375 15px Base size │
│ subtitle 1.0625 17px Subheadings │
│ h4 1.125 18px Minor heads │
│ h3 1.25 20px Section heads │
│ h2 1.5 24px Page heads │
│ h1 1.875 30px Main titles │
│ │
│ Previous Scale (v3.2.4): │
│ caption: 13px, body: 15px, base: 16px, h3: 22px, │
│ h2: 28px, h1: 36px │
│ │
│ Change: Tighter, more refined scale with smaller │
│ increments for better visual hierarchy │
└─────────────────────────────────────────────────────────┘
Optimized for improved readability and modern aesthetics:
/* Line Heights */
--ft-line-height-tight: 1.3; /* Headings (was 1.35) */
--ft-line-height-base: 1.6; /* Body text (was 1.65) */
--ft-line-height-relaxed: 1.75; /* Unchanged */
/* Letter Spacing */
--ft-letter-spacing-tight: -0.01em; /* Headings (was -0.02em) */
--ft-letter-spacing-normal: -0.003em; /* Body (was 0em) */
--ft-letter-spacing-wide: 0.01em; /* Emphasis (was 0.025em) */Impact:
- Tighter line heights reduce vertical space, creating a more compact, modern feel
- Subtle negative letter spacing improves readability at larger sizes
- Consistent spacing creates visual harmony across all text elements
┌────────────────────────────────────────────────────────────┐
│ FilterTube │ ← H1 (30px, -0.01em)
│ │
│ Dashboard Overview │ ← H2 (24px, -0.01em)
│ │
│ Quick Actions │ ← H3 (20px, -0.01em)
│ ───────────── │
│ │
│ Block Channel │ ← H4 (18px, -0.01em)
│ Manage your blocked channels and keywords │ ← Subtitle (17px)
│ │
│ FilterTube helps you curate your YouTube experience │ ← Base (15px)
│ by blocking unwanted content. All filtering happens │
│ locally in your browser for maximum privacy. │
│ │
│ Videos Hidden Today: 42 │ ← Body (14px)
│ Time Saved: 2h 15m │
│ │
│ Hint: Click the 3-dot menu to block channels │ ← Caption (12px)
│ │
│ UC1234567890abcdefghijk │ ← Mono (code)
└────────────────────────────────────────────────────────────┘
The filtering engine now supports both blocklist and whitelist modes:
// Core filtering decision function
function shouldHideContent(title, channel, settings) {
const listMode = settings.listMode === 'whitelist' ? 'whitelist' : 'blocklist';
if (listMode === 'whitelist') {
// Whitelist Mode: Hide by default, allow only matches
return !matchesWhitelist(title, channel, settings);
} else {
// Blocklist Mode: Show by default, hide matches
return matchesBlocklist(title, channel, settings);
}
}
function matchesWhitelist(title, channel, settings) {
// Check channel whitelist
if (settings.whitelistChannels &&
settings.whitelistChannels.some(ch => matchesChannel(channel, ch))) {
return true;
}
// Check keyword whitelist
if (settings.whitelistKeywords &&
settings.whitelistKeywords.some(regex => regex.test(title))) {
return true;
}
return false;
}
function matchesBlocklist(title, channel, settings) {
// Check channel blocklist
if (settings.filterChannels &&
settings.filterChannels.some(ch => matchesChannel(channel, ch))) {
return true;
}
// Check keyword blocklist
if (settings.filterKeywords &&
settings.filterKeywords.some(regex => regex.test(title))) {
return true;
}
return false;
}This feature does not reuse the normal manual addChannel() path. It has a dedicated acquisition pipeline because it needs a live signed-in YouTube page context plus bulk merge semantics.
Detailed reference: docs/SUBSCRIBED_CHANNELS_IMPORT.md
tab-view.js
-> resolveSubscriptionsImportTab()
-> waitForYoutubeTabReady()
-> StateManager.importSubscribedChannelsToWhitelist()
state_manager.js
-> send message to selected YouTube tab
-> validate profile/lock state before and after fetch
-> send merged list to background
content/bridge_settings.js
-> inject MAIN-world scripts when needed
-> wait for FilterTube_InjectorBridgeReady
-> forward progress + response
injector.js
-> collect /feed/channels seed
-> keep recent browse-response history
-> prefer page-driven expansion when available
-> fetch FEchannels browse pages
-> normalize entries
background.js
-> mergeImportedWhitelistChannels()
-> persist whitelistChannels + legacy mirrors
-> refresh YouTube tabs
sequenceDiagram
participant UI as "tab-view.js"
participant Tab as "YouTube /feed/channels tab"
participant Bridge as "bridge_settings.js"
participant Main as "injector.js"
participant BG as "background.js"
UI->>Tab: ping + wait for import bridge
UI->>Tab: FilterTube_ImportSubscribedChannels
Tab->>Bridge: runtime listener
Bridge->>Main: FilterTube_RequestSubscriptionImport
Main-->>Bridge: FilterTube_SubscriptionsImportProgress
Main-->>Bridge: FilterTube_SubscriptionsImportResponse
Bridge-->>UI: tab response
UI->>BG: FilterTube_BatchImportWhitelistChannels
BG-->>UI: counts + current mode
The current importer is not purely API-first yet. It does:
/feed/channelspage seed lookup- recent real-page browse-response harvesting
FEchannelsbrowse requests- dedupe/merge of normalized entries
That is why the UI's pages read counter is an importer metric, not a strict continuation-page count.
The importer now leans more heavily on real page-driven /feed/channels growth because browser/session behavior is not identical:
- Edge often materializes more rows through active page expansion.
- Chrome may expose the same continuation chain later or less eagerly.
- Synthetic continuation replay is still useful, but it is no longer treated as the only source of truth.
injector.js builds request profiles from page ytcfg context instead of hardcoding a minimal body:
web_fechannelsmweb_fechannels
The request can retry with the alternate profile when:
- the first profile fails
- the first profile times out
- the first profile looks logged out and produced no rows
The subscriptions-management page itself is treated as a control surface rather than a normal feed:
- blocked channels still remain visible there
- whitelist building still works after import
- users can inspect and audit their subscribed-channel roster even if those channels are hidden elsewhere
FilterTube now has two separate direct-action controls:
showQuickBlockButtonshowBlockMenuItem
The first controls the hover quick-block affordance on cards. The second controls whether FilterTube injects its own entry inside YouTube's native 3-dot menu.
This separation matters because some users want the fast hover action but do not want an extra menu item competing with YouTube's native actions.
Imported rows are normalized into whitelist channel objects with:
idhandlehandleDisplaycanonicalHandlecustomUrlnamelogosource: 'subscriptions_import'
background.js performs the canonical merge via mergeImportedWhitelistChannels(...).
Results are counted as:
importedupdatedduplicatesskipped
The merge writes to:
ftProfilesV4.profiles[activeId].main.whitelistChannelsftProfilesV3.main.whitelistChannelsftProfilesV3.main.whitelistedChannels
and can also update channelMap from imported handle/custom URL identity.
After a successful import:
Import Onlyleaves the current blocklist untouchedImport + Turn On WhitelisttriggersFilterTube_SetListMode
Current implementation note:
- the existing whitelist activation path merges current blocklist channels and keywords into whitelist and clears the blocklist
- the subscriptions import modal documents this explicitly so the behavior is not surprising
The original whitelist-mode architecture landed earlier, but the current 3.3.0 behavior includes several additional correctness fixes:
/feed/channelsis exempt from normal whitelist hiding so the subscriptions-management surface stays usable- creator/channel pages can use page identity to avoid false hiding while still blocking unresolved feed cards elsewhere
- YTM renderer coverage is broader, so more mobile cards now participate in whitelist identity extraction and DOM fallback
- unresolved cards no longer fail open by default on normal feeds just because identity has not arrived yet
Whitelist mode preserves critical watch page functionality to maintain video playback:
// In ensureContentControlStyles() - DOM fallback
const listMode = (settings && settings.listMode === 'whitelist') ? 'whitelist' : 'blocklist';
const hideInfoMaster = (listMode !== 'whitelist') && !!settings.hideVideoInfo;
// Only hide watch page elements if NOT in whitelist mode
if ((listMode !== 'whitelist') && (hideInfoMaster || settings.hideVideoButtonsBar)) {
rules.push(`
#actions.ytd-watch-metadata,
#info > #menu-container {
display: none !important;
}
`);
}Impact: Ensures video actions, channel row, and description remain visible in whitelist mode for proper playback.
Prevents page blanking when search results render before channel identity is available:
// In shouldHideContent() - DOM fallback
try {
const path = document.location?.pathname || '';
const hasNameSignal = Boolean(normalizeChannelNameForComparison(channelMeta?.name || '') ||
normalizeChannelNameForComparison(channel || ''));
// Don't hide indeterminate search results to avoid recursive loading
if (path === '/results' && !hasChannelIdentity && !hasNameSignal && collaboratorMetas.length === 0) {
return false;
}
} catch (e) {
// Fallback to safe behavior
}Impact: Prevents blank search pages and recursive continuation loads during identity resolution.
Automatic cleanup of duplicate content in whitelist mode to improve feed quality:
// In applyDOMFallback() - DOM fallback
try {
const path = document.location?.pathname || '';
if (path === '/' && listMode === 'whitelist') {
const items = document.querySelectorAll('ytd-rich-grid-renderer ytd-rich-item-renderer');
const seen = new Map();
for (const item of items) {
const videoId = item.getAttribute('data-filtertube-video-id') || extractVideoIdFromCard(item) || '';
if (!videoId) continue;
const existing = seen.get(videoId);
if (!existing) {
seen.set(videoId, item);
continue;
}
// Hide duplicates with same video ID
toggleVisibility(item, true, 'Duplicate item', true);
}
}
} catch (e) {
// Graceful fallback
}Impact: Cleans up mixed content feeds by removing duplicate videos in whitelist mode.
Efficient re-evaluation of content awaiting channel identity to reduce processing overhead:
// In applyDOMFallback() - DOM fallback
const { onlyWhitelistPending = false } = options;
// Process only pending items in whitelist mode for efficiency
const videoElements = (onlyWhitelistPending && listMode === 'whitelist')
? document.querySelectorAll(`${VIDEO_CARD_SELECTORS}[data-filtertube-whitelist-pending="true"]`)
: document.querySelectorAll(VIDEO_CARD_SELECTORS);Impact: Reduces DOM processing overhead by targeting only relevant elements during whitelist updates.
Enhanced channel extraction for search pages to prevent whitelist false-negatives:
// In applyDOMFallback() - DOM fallback
let searchThumbAnchor = null;
if (elementTag === 'ytd-video-renderer') {
try {
searchThumbAnchor = element.querySelector(
'#thumbnail a[data-filtertube-channel-handle], ' +
'#thumbnail a[data-filtertube-channel-id], ' +
'a#thumbnail[data-filtertube-channel-handle], ' +
'a#thumbnail[data-filtertube-channel-id], ' +
'#thumbnail[data-filtertube-channel-handle], ' +
'#thumbnail[data-filtertube-channel-id]'
);
} catch (e) {
// Fallback handling
}
}
const relatedElements = [channelAnchor, channelElement, channelSubtitleElement, searchThumbAnchor];Impact: Prevents first-batch whitelist false-negatives by including thumbnail anchor data in channel extraction.
Prevents stuck hidden elements during YouTube's single-page application navigation:
// In applyDOMFallback() - DOM fallback
const watchMeta = document.querySelector('ytd-watch-metadata');
if (watchMeta) {
watchMeta.querySelectorAll('[data-filtertube-whitelist-pending="true"], [data-filtertube-hidden], .filtertube-hidden, .filtertube-hidden-shelf').forEach(el => {
try {
toggleVisibility(el, false, '', true); // Clear stuck flags
} catch (e) {
// Individual element cleanup failure
}
});
}Impact: Ensures watch page elements don't remain stuck hidden after navigation between videos.
Follow-up note: this cleanup now matters for collaborator state too. During watch-to-watch SPA swaps, FilterTube re-validates stamped collaborator metadata/menu assumptions against stronger watch roots so a stale A and 2 more placeholder does not survive after the selected playlist row or watch metadata changes.
Optimized timing for re-evaluating pending content to reduce "recursive hiding" window:
// In initializeDOMFallback() - content bridge
try {
if (typeof applyDOMFallback === 'function') {
// Immediate re-evaluation
setTimeout(() => {
try {
applyDOMFallback(null, { preserveScroll: true, onlyWhitelistPending: true });
} catch (e) {
// Fallback handling
}
}, 0);
// Delayed re-evaluation for late-arriving content
setTimeout(() => {
try {
applyDOMFallback(null, { preserveScroll: true, onlyWhitelistPending: true });
} catch (e) {
// Fallback handling
}
}, 90);
}
} catch (e) {
// Initialization failure handling
}Impact: Reduces the window where content might be recursively hidden during search page loading.
Removed redundant identity checking for improved performance:
// Before: Redundant check
if (hasChannelIdentity && channelMetaMatchesIndex(channelMeta, index, channelMap)) {
return false;
}
// After: Streamlined check
if (channelMetaMatchesIndex(channelMeta, index, channelMap)) {
return false;
}Impact: Minor performance optimization by eliminating redundant boolean checks.
The UI adapts display text and controls based on the active mode:
// Render engine updates for mode awareness
function renderKeywords(state, profile = 'main') {
const mode = profile === 'kids' ? state.kids?.mode : state.mode;
const listType = mode === 'whitelist' ? 'whitelist' : 'blocked';
const emptyMessage = mode === 'whitelist'
? 'No keywords allowed'
: 'No keywords blocked';
// Render with appropriate labels
}The background script handles mode switching with list migration:
// Mode switching with staging merge
async function handleSetListMode(request) {
const { profileType, mode, copyBlocklist } = request;
// Load current profiles
const profilesV4 = await storageGet(FT_PROFILES_V4_KEY);
// Apply mode switch with optional merge
if (mode === 'whitelist' && copyBlocklist) {
mergeBlocklistIntoWhitelist(profilesV4, profileType);
}
// Update profile mode
profilesV4.profiles[activeId][profileType].mode = mode;
// Save and trigger refresh
await storageSet({ [FT_PROFILES_V4_KEY]: profilesV4 });
triggerContentRefresh();
}graph TD
A[YouTube Content] --> B[Main World Interception]
B --> C[Extract Channel Data]
C --> D[Background Processing]
D --> E{Profile Mode?}
E -->|Blocklist| F[Compile Blocklist Settings]
E -->|Whitelist| G[Compile Whitelist Settings]
F --> H[Send to Content Scripts]
G --> H
H --> I[DOM Stamping with Mode]
I --> J[Apply Filtering Logic]
J --> K{Content Matches Rules?}
K -->|Blocklist: Match| L[Hide Content]
K -->|Blocklist: No Match| M[Show Content]
K -->|Whitelist: Match| M
K -->|Whitelist: No Match| L
L --> N[Update UI State]
M --> N
N --> O[User Sees Filtered Results]
style E fill:#2196f3
style F fill:#f44336
style G fill:#4caf50
style K fill:#ff9800
style L fill:#f44336
style M fill:#4caf50
graph TD
A[User Toggles Mode] --> B[UI Component Event]
B --> C[Send to Background]
C --> D[Validate Request]
D --> E{Target Mode?}
E -->|Whitelist| F[Merge Blocklist → Whitelist]
E -->|Blocklist| G[Set Mode Directly]
F --> H[Update Profiles V4]
G --> H
H --> I[Clear Compiled Cache]
I --> J[Broadcast to All Tabs]
J --> K[Refresh Content Scripts]
K --> L[Update UI Controls]
L --> M[Apply New Filtering]
style F fill:#4caf50
style G fill:#2196f3
style H fill:#ff9800
style M fill:#4caf50
The most significant user experience improvement in v3.2.2 is the implementation of optimistic UI updates for channel blocking:
// Optimistic hide state tracking
const optimisticHideState = [];
let didOptimisticHide = false;
const recordOptimisticHide = (element, meta) => {
optimisticHideState.push({
element,
prevDisplay: element.style.display,
prevHiddenAttr: element.getAttribute('data-filtertube-hidden'),
prevHadHiddenClass: element.classList.contains('filtertube-hidden'),
prevBlocked: { /* capture all data attributes */ }
});
markElementAsBlocked(element, meta, 'pending');
element.style.display = 'none';
element.classList.add('filtertube-hidden');
};
const restoreOptimisticHide = () => {
// Restore all saved state if blocking fails
for (const item of optimisticHideState) {
// Restore display, classes, and attributes
element.style.display = item.prevDisplay || '';
// ... restore other properties
}
optimisticHideState.length = 0;
};Benefits:
- Instant Feedback: Content hides immediately when user clicks "Block Channel"
- No Uncertainty: Users see immediate results without wondering if the action worked
- Automatic Recovery: If blocking fails, content automatically reappears
- Error Handling: Failed operations show proper error states while maintaining UI consistency
v3.2.2 adds comprehensive support for YouTube mobile menu structures:
// Mobile menu detection and renderer selection
const isMobileMenu = Boolean(menuList.closest?.('ytm-menu-popup-renderer')) ||
Boolean(menuContainer?.matches?.('ytm-menu-popup-renderer'));
const rendererTag = isMobileMenu ? 'ytm-menu-service-item-renderer' : 'ytd-menu-service-item-renderer';
const rendererScope = isMobileMenu ? 'ytm-menu-popup-renderer' : 'ytd-menu-popup-renderer';
// Create appropriate menu item for the platform
const filterTubeItem = document.createElement(rendererTag);
filterTubeItem.className = `style-scope ${rendererScope} filtertube-block-channel-item`;Mobile Enhancements:
- Proper Renderer Tags: Uses
ytm-*tags for mobile instead ofytd-* - Scope Handling: Correct CSS scoping for mobile vs desktop
- Bottom Sheet Support: Handles mobile bottom-sheet containers
- Touch-Friendly: Maintains proper touch interactions on mobile devices
All console output is now gated behind a debug flag to reduce production noise:
const debugLog = (...args) => {
try {
if (window.__filtertubeDebug) {
console.log(...args);
}
} catch (e) {
// Silent fail if debug flag not available
}
};
// Usage throughout codebase
if (window.__filtertubeDebug) {
console.log('FilterTube: Detailed operation info');
}Debug Benefits:
- Clean Production: No console spam for regular users
- Full Debug Info: Developers can enable with
window.__filtertubeDebug = true - Performance: Reduced console overhead in production
- Selective Logging: Only important errors show without debug flag
Enhanced DOM fallback processing respects user scrolling:
const scrollState = window.__filtertubeScrollState || (window.__filtertubeScrollState = {
lastScrollTs: 0,
listenerAttached: false
});
// Track user scrolling
window.addEventListener('scroll', () => {
scrollState.lastScrollTs = Date.now();
}, { passive: true, capture: true });
// Preserve scroll position during filtering
const isUserScrolling = now - (scrollState.lastScrollTs || 0) < 150;
const allowPreserveScroll = preserveScroll && !forceReprocess && !isUserScrolling;Scroll Benefits:
- No Jarring Jumps: Scroll position stays stable during filtering
- User Respect: Doesn't fight user scrolling operations
- Smooth Experience: Filtering happens invisibly in the background
- Intelligent Behavior: Only preserves scroll when appropriate
The core performance breakthrough in v3.2.1+ is the conversion of applyDOMFallback() to async processing with main thread yielding:
async function applyDOMFallback(settings, options = {}) {
// Run state management prevents overlapping executions
const runState = window.__filtertubeDomFallbackRunState ||
(window.__filtertubeDomFallbackRunState = {
running: false,
pending: false,
latestSettings: null,
latestOptions: null
});
if (runState.running) {
runState.pending = true;
return;
}
runState.running = true;
// Yield to main thread every 30-60 elements
const yieldToMain = () => new Promise(resolve => setTimeout(resolve, 0));
try {
// Process videoElements with yielding
for (let elementIndex = 0; elementIndex < videoElements.length; elementIndex++) {
const element = videoElements[elementIndex];
// Process element...
if (elementIndex > 0 && elementIndex % 60 === 0) {
await yieldToMain();
}
}
} finally {
runState.running = false;
if (runState.pending) {
runState.pending = false;
setTimeout(() => applyDOMFallback(runState.latestSettings, runState.latestOptions), 0);
}
}
}Key Benefits:
- Prevents browser freezing during large DOM operations
- Maintains UI responsiveness during heavy filtering
- Queues overlapping calls instead of running simultaneously
v3.2.1+ introduces persistent caching for expensive regex operations:
// WeakMap-based caching for keyword regexes
const compiledKeywordRegexCache = new WeakMap();
function getCompiledKeywordRegexes(rawList) {
if (!Array.isArray(rawList) || rawList.length === 0) return [];
const cached = compiledKeywordRegexCache.get(rawList);
if (cached) return cached;
const compiled = [];
for (const keywordData of rawList) {
try {
if (keywordData instanceof RegExp) {
compiled.push(keywordData);
continue;
}
if (keywordData && keywordData.pattern) {
compiled.push(new RegExp(keywordData.pattern, keywordData.flags || 'i'));
continue;
}
if (typeof keywordData === 'string' && keywordData.trim()) {
const escaped = keywordData.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
compiled.push(new RegExp(escaped, 'i'));
}
} catch (e) {
// ignore invalid
}
}
compiledKeywordRegexCache.set(rawList, compiled);
return compiled;
}// Channel filter index caching
const compiledChannelFilterIndexCache = new WeakMap();
function getCompiledChannelFilterIndex(settings) {
const list = settings.filterChannels;
const channelMap = settings.channelMap || {};
const existing = compiledChannelFilterIndexCache.get(settings);
if (existing && existing.sourceList === list && existing.sourceChannelMap === channelMap) {
return existing;
}
// Build optimized index for fast lookups
const index = {
sourceList: list,
sourceChannelMap: channelMap,
ids: new Set(),
handles: new Set(),
customUrls: new Set(),
names: new Set(),
unresolvedHandleKeys: []
};
compiledChannelFilterIndexCache.set(settings, index);
return index;
}Performance Impact:
- Eliminates repeated regex compilation (expensive operation)
- Fast O(1) lookups for channel filtering
- Reduces CPU usage by 60-80% during filtering operations
Channel map updates are now batched to reduce storage I/O:
// Background script batching system
let channelMapFlushTimer = null;
const pendingChannelMapUpdates = new Map();
function enqueueChannelMapUpdate(key, value) {
pendingChannelMapUpdates.set(key, value);
scheduleChannelMapFlush();
}
function scheduleChannelMapFlush() {
if (channelMapFlushTimer) return;
channelMapFlushTimer = setTimeout(() => {
channelMapFlushTimer = null;
flushChannelMapUpdates(); // Batch write to storage
}, 250); // 250ms batch window
}Benefits:
- Reduces storage operations by 70-90%
- Prevents storage contention during rapid updates
- Maintains data consistency with batched writes
Settings updates are throttled to prevent excessive DOM reprocessing:
// Content script debouncing
let pendingStorageRefreshTimer = 0;
let lastStorageRefreshTs = 0;
const MIN_STORAGE_REFRESH_INTERVAL_MS = 250;
function scheduleSettingsRefreshFromStorage() {
const now = Date.now();
const elapsed = now - lastStorageRefreshTs;
if (elapsed >= MIN_STORAGE_REFRESH_INTERVAL_MS) {
lastStorageRefreshTs = now;
requestSettingsFromBackground();
return;
}
if (pendingStorageRefreshTimer) return;
const delay = Math.max(0, MIN_STORAGE_REFRESH_INTERVAL_MS - elapsed);
pendingStorageRefreshTimer = setTimeout(() => {
pendingStorageRefreshTimer = 0;
lastStorageRefreshTs = Date.now();
requestSettingsFromBackground();
}, delay);
}Chromium-based Browsers:
- Async yielding is highly effective
- Storage batching provides maximum efficiency
- Overall performance improvement: 90%+ lag reduction
Firefox-based Browsers:
- Good improvements but less dramatic
- Some yielding effectiveness but needs tuning
- Storage operations may need different batching strategy
- Ongoing optimization work required
// In seed.js - comprehensive network interception
function stashNetworkSnapshot(data, dataName) {
try {
if (!window.filterTube) return;
if (!data || typeof data !== 'object') return;
const ts = Date.now();
if (dataName.includes('/youtubei/v1/next')) {
window.filterTube.lastYtNextResponse = data;
window.filterTube.lastYtNextResponseName = dataName;
window.filterTube.lastYtNextResponseTs = ts;
}
if (dataName.includes('/youtubei/v1/browse')) {
window.filterTube.lastYtBrowseResponse = data;
window.filterTube.lastYtBrowseResponseName = dataName;
window.filterTube.lastYtBrowseResponseTs = ts;
}
if (dataName.includes('/youtubei/v1/player')) {
window.filterTube.lastYtPlayerResponse = data;
window.filterTube.lastYtPlayerResponseName = dataName;
window.filterTube.lastYtPlayerResponseTs = ts;
}
} catch (e) {
// Silently fail to avoid breaking YouTube
}
}Snapshot storage:
lastYtNextResponse- Latest next feed data with timestamplastYtBrowseResponse- Latest browse data with timestamplastYtPlayerResponse- Latest player data with timestamp
// In background.js - rate-limited enrichment
function schedulePostBlockEnrichment(channel, profile = 'main', metadata = {}) {
const source = metadata?.source || '';
if (source === 'postBlockEnrichment') return;
const id = channel?.id || '';
if (!id || !id.toUpperCase().startsWith('UC')) return;
// Rate limiting: 6-hour cooldown per channel
const key = `${profile === 'kids' ? 'kids' : 'main'}:${id.toLowerCase()}`;
const now = Date.now();
const lastAttempt = postBlockEnrichmentAttempted.get(key) || 0;
if (now - lastAttempt < 6 * 60 * 60 * 1000) return;
// Check if enrichment is needed
const needsEnrichment = (
(!channel.handle && !channel.customUrl) ||
!channel.logo ||
!channel.name
);
if (!needsEnrichment) return;
// Schedule with random delay (3.5-4s) to avoid patterns
const delayMs = 3500 + Math.floor(Math.random() * 750);
setTimeout(async () => {
await handleAddFilteredChannel(
id,
false,
null,
null,
{ source: 'postBlockEnrichment' },
profile,
''
);
}, delayMs);
}Enrichment features:
- Smart detection - only enriches channels missing key metadata
- Rate limited - 6-hour cooldown prevents excessive requests
- Background processing - doesn't block UI operations
- Random delays - avoids detectable request patterns
- Profile-aware - separate tracking for Main and Kids profiles
// In background.js - improved fetch with fallbacks
async function fetchChannelInfo(channelIdOrHandle) {
try {
const response = await fetch(channelUrl, {
credentials: 'include',
headers: { 'Accept': 'text/html' }
});
// Handle 404s for @handle/about by falling back to @handle
if (!response.ok && isHandle) {
const fallbackUrl = `https://www.youtube.com/@${encodedHandle}`;
return await fetch(fallbackUrl, {
credentials: 'include',
headers: { 'Accept': 'text/html' }
});
}
return response;
} catch (error) {
// CORS errors trigger alternative fetch methods
if (error.name === 'TypeError' && error.message.includes('CORS')) {
return await fetchAlternativeMethod(url);
}
throw error;
}
}// Extract channel info from HTML meta tags when JSON parsing fails
const extractMeta = (key) => {
const patterns = [
new RegExp(`<meta[^>]+property=["']${key}["'][^>]+content=["']([^"']+)["'][^>]*>`, 'i'),
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${key}["'][^>]*>`, 'i')
];
for (const re of patterns) {
const match = html.match(re);
if (match && match[1]) return decodeHtmlEntities(match[1]);
}
return null;
};
// Extract channel name, image, and URL from OG tags
const ogTitle = extractMeta('og:title');
const ogImage = extractMeta('og:image');
const ogUrl = extractMeta('og:url');// When channel page scraping fails, use video payload data
if (!channelInfo.success && effectiveVideoId) {
try {
const isKids = profile === 'kids';
const identity = isKids
? (await performKidsWatchIdentityFetch(effectiveVideoId) ||
await performWatchIdentityFetch(effectiveVideoId))
: await performWatchIdentityFetch(effectiveVideoId);
if (identity && (identity.id || identity.handle || identity.name)) {
channelInfo = {
success: true,
id: identity.id || mappedId || '',
handle: identity.handle || '',
name: identity.name || '',
logo: identity.logo || '',
customUrl: identity.customUrl || ''
};
}
} catch (e) {
// Silently fail and use minimal fallback
}
}// In background.js - prevent persisting bad channel names
const isProbablyNotChannelName = (value) => {
if (!value || typeof value !== 'string') return true;
const trimmed = value.trim();
if (!trimmed) return true;
if (trimmed.startsWith('@')) return true;
if (trimmed.toLowerCase() === 'channel') return true;
if (trimmed.includes('•')) return true;
if (/\bviews?\b/i.test(trimmed)) return true;
if (/\bago\b/i.test(trimmed)) return true;
if (/\bwatching\b/i.test(trimmed)) return true;
return false;
};
const sanitizePersistedChannelName = (value) => {
if (!value || typeof value !== 'string') return '';
const trimmed = value.trim();
if (!trimmed) return '';
if (isProbablyNotChannelName(trimmed)) return '';
return trimmed;
};- Enrichment is now rare thanks to proactive XHR interception
- Kids zero-network mode works entirely from intercepted JSON
- Handle → UC ID resolution uses persisted
channelMapfirst - Network fetches are avoided on Kids (
allowDirectFetch: false) - Main world searches use
ytInitialDatasnapshots when needed
- Zero-delay blocking - 3-dot menus show correct names instantly
- Reduced API calls - most identity comes from intercepted JSON
- Better cache hit rates - shared data across all surfaces
- Reliable Kids operation - works even when Kids blocks external requests
Motivation: YouTube loads content by injecting JSON data into the page. Traditional filtering waits for the DOM, causing a "flash of content". FilterTube intercepts this data before it renders.
How it works (Simplified): YouTube tries to hand a list of videos to the webpage. FilterTube steps in the middle, takes the list, crosses out the videos you don't want, and then hands the cleaned list to the webpage. The webpage never knows those videos existed.
Technical Flow:
sequenceDiagram
participant YT as YouTube Server
participant Hook as FilterTube Hook
participant Engine as Filter Engine
participant Page as Web Page
YT->>Hook: Sends Video Data (JSON)
Hook->>Engine: "Check this list!"
Engine->>Engine: Removes Blocked Videos
Engine->>Hook: Returns Clean List
Hook->>Page: Delivers Safe Content
Page->>Page: Renders (Zero Flash)
Motivation:
YouTube loads more content as you scroll (infinite scroll) using fetch requests. FilterTube must intercept these dynamic requests to ensure new content is also filtered.
How it works (Simplified): When you scroll down, YouTube asks its server for "more videos". FilterTube listens for this request. When the server replies with new videos, FilterTube quickly checks them, removes the bad ones, and then gives the rest to YouTube to show you.
Technical Flow:
+-----------+ +-------------+ +-------------+
| YouTube | ---> | window.fetch| ---> | Original |
| (Scroll) | | (Proxy) | | Fetch |
+-----------+ +-------------+ +-------------+
|
v
+-----------+ +-------------+ +-------------+
| Receive | <--- | New Response| <--- | Clone & |
| Filtered | | (Filtered) | | Parse |
+-----------+ +-------------+ +-------------+
|
v
| FilterTube |
| Engine |
+-------------+
Motivation:
YouTube also uses XMLHttpRequest for critical API endpoints (search, browse, guide, next, player). FilterTube intercepts these to ensure comprehensive coverage.
How it works:
FilterTube overrides the XMLHttpRequest.prototype.open and send methods to monitor specific YouTube API endpoints. When a matching request completes, it parses the JSON response and runs it through the filter engine before YouTube can process it.
Technical Implementation:
// In js/seed.js - setupXhrInterception()
const xhrEndpoints = [
'/youtubei/v1/search', // Search results
'/youtubei/v1/guide', // Sidebar recommendations
'/youtubei/v1/browse', // Home feed, channel pages
'/youtubei/v1/next', // Infinite scroll pagination
'/youtubei/v1/player' // Video player data
];
// Override XMLHttpRequest prototype
const proto = window.XMLHttpRequest.prototype;
const originalOpen = proto.open;
const originalSend = proto.send;
proto.open = function(method, url) {
this.__filtertube_url = url; // Store URL for later use
return originalOpen.apply(this, arguments);
};
proto.send = function() {
const urlStr = String(this.__filtertube_url || '');
if (xhrEndpoints.some(endpoint => urlStr.includes(endpoint))) {
this.addEventListener('load', () => {
try {
const text = this.responseText;
const jsonData = JSON.parse(text);
processWithEngine(jsonData, `xhr:${getPathname(urlStr)}`);
} catch (e) {
// Silently handle parsing errors
}
}, { once: true });
}
return originalSend.apply(this, arguments);
};Key XHR Endpoints Monitored:
/youtubei/v1/search- Search results and autocomplete/youtubei/v1/browse- Home feed, channel pages, recommendations/youtubei/v1/next- Infinite scroll pagination (load more)/youtubei/v1/guide- Sidebar guide recommendations/youtubei/v1/player- Video player metadata and related videos
Motivation: YouTube's data structure is complex and nested. Videos can appear inside "shelves", "grids", or "lists". The engine must find every video, extract its details (title, channel), and check if it matches your filters.
How it works (Simplified): The engine acts like a meticulous inspector. It opens every box (data object) YouTube sends. If it finds a video inside, it reads the label (title/channel). If the label is on your "Block List", it throws the video in the trash. If it's a box of boxes (a playlist or shelf), it opens those too and checks everything inside.
Technical Flow:
+-------------+
| processData |
+-------------+
|
v
+-------------+ +-------------+
| Traverse | ----> | Check Type |
| JSON Tree | | (Renderer?) |
+-------------+ +-------------+
^ | Yes
| v
| +-------------+
(Recurse) | Extract |
| | Metadata |
| +-------------+
| |
| v
+-------------+ +-------------+
| Keep Item | <---- | Match Rules?|
+-------------+ No +-------------+
| Yes
v
+-------------+
| Block Item |
| (Set Null) |
+-------------+
When user clicks 3-dot menu on any video card, FilterTube injects a "Block Channel" option:
sequenceDiagram
participant UI as UI
participant Content as Content
participant Background as Background
participant Cache as Cache
participant API as External API
UI->>Content: User clicks 3-dot menu
Content->>Content: extractChannelFromCard()
Note over Content: Often returns {id: "UC..."} (DOM), otherwise {needsFetch: true}
Content->>Background: Request channel enrichment
Background->>Cache: Check videoChannelMap
alt Cache has mapping
Cache-->>Background: Return cached channelId
else No mapping
Background->>API: Fetch channel details
API-->>Background: Return channel info
end
Background->>Content: Update menu label
Content->>UI: Display updated channel name
Filter All is selection state inside the custom fallback popover, not the action itself. The actual block is committed only when the user activates the real Block • Channel row, after which background can persist both:
- the channel entry
- the linked channel-derived keyword state
FilterTube uses a multi-layer approach to resolve channel identities:
// Extract what's available from DOM
function extractChannelFromCard(card) {
// Try multiple selectors based on card type
const channelLink = card.querySelector('a[href*="/channel/"], a[href*="/@"]');
const nameElement = card.querySelector('ytd-channel-name a, #channel-info a');
return {
id: extractChannelId(channelLink?.href),
handle: extractRawHandle(channelLink?.href),
name: nameElement?.textContent?.trim(),
videoId: extractVideoId(card),
needsFetch: !id || !name // Requires network if missing key info
};
}In many cases, channel ownership is learned before the menu is opened because the Main World interception layer processes:
window.ytInitialPlayerResponse/youtubei/v1/player(viafetch/XHRinterception)
This allows filter_logic.js to harvest videoId -> UC... mappings and persist them into videoChannelMap, making Shorts and Watch behave more like regular Home/Search cards (near-instant identity).
// Request data from main world (injector.js)
function requestChannelInfo(videoId) {
window.postMessage({
type: 'FilterTube_RequestChannelInfo',
videoId: videoId,
expectedHandle: extractedHandle,
expectedName: extractedName
});
// Listen for response
window.addEventListener('message', (event) => {
if (event.data.type === 'FilterTube_ChannelInfoResponse') {
return event.data.channelInfo;
}
});
}When a watch-page playlist / Mix row does not expose stable owner identity directly, the current fallback order is:
UC IDcustomUrl@handlewatch:VIDEO_ID
watch:VIDEO_ID is recovery-only. It is not treated as canonical channel identity; it is a bridge that lets background recover the real owner from watch-page payloads.
// Fetch from YouTube pages when needed
async function fetchChannelFromWatchUrl(videoId) {
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`);
const html = await response.text();
// Parse channel info from HTML
const channelMatch = html.match(/"channelId":"(UC[\w-]{22})"/);
const nameMatch = html.match(/"author":"([^"]+)"/);
return {
id: channelMatch?.[1],
name: nameMatch?.[1],
source: 'watch-fetch'
};
}Cross-surface behavior: the overall identity resolution strategy is intended to behave the same on YouTube Main and YouTube Kids:
- Prefer DOM-extracted
UC...when present. - Prefer
videoChannelMapwhen already learned. - Prefer Main World interception/harvesting (
ytInitialPlayerResponse,/youtubei/v1/player). - Use HTML fetch parsing only as a fallback.
The main difference is that Kids has stricter CORS limits, so the fallback network fetch layer may fail more often on youtubekids.com, making the interception + caching layers even more important.
The 3-dot menu intelligently upgrades placeholder labels:
// Initial render - may show placeholder
renderFilterTubeMenuEntries({
channelInfo: { name: '@handle', id: null }, // Placeholder
placeholder: true
});
// After enrichment - upgrade to real name
updateInjectedMenuChannelName(dropdown, {
name: 'Actual Channel Name',
id: 'UCxxxxxxxx...'
});Placeholder Detection Rules:
- UC IDs:
/^UC[\w-]{22}$/ - Mix titles:
/^mix\s+-/ior contains•separator - Metadata strings: Contains
views,ago,watching - Handles only: Starts with
@and no actual name - Collapsed collaboration labels: values like
A and 2 moreare display hints, not final channel identity
If later recovery returns a stronger canonical name for the same UC ID, the stored label can now be repaired instead of permanently keeping a title-like placeholder.
YouTube Main:
- Uses standard fetch pipeline
- Caches in
videoChannelMapandchannelMap - Full enrichment features available
YouTube Kids:
- Limited CORS for network requests
- Falls back to main-world extraction
- Uses
ftProfilesV3.kidsstorage namespace
Collaboration filtering relies on coordinated logic across all three execution worlds. The end-to-end detection pipeline is:
YouTube DOM (Isolated World)
├─ js/content/* (loaded before content_bridge.js)
│ • dom_extractors.js / dom_helpers.js (videoId, duration, card lookup)
│ • dom_fallback.js (MutationObserver fallback hide/restore)
│ • block_channel.js (3-dot dropdown observer + card resolver)
└─ content_bridge.js
• Settings sync + main-world injection
• Parse collaboration signals (#attributed-channel-name / yt-text-view-model / avatar stack)
• Inject menu items + handle clicks (Block Channel / Filter All / Block All Collaborators)
• If data missing → post FilterTube_RequestCollaboratorInfo →
Main World
└─ injector.js / filter_logic.js
• searchYtInitialDataForVideoChannel(videoId)
• Extract listItems[].listItemViewModel entries (name, handle, UC ID)
• Respond with allCollaborators[] payload →
Isolated World (resume)
• Generate collaborationGroupId (UUID)
• Send chrome.runtime.sendMessage(handleAddFilteredChannel, {
input, filterAll, collaborationWith, collaborationGroupId,
allCollaborators
}) →
Background Service Worker
• sanitizeChannelEntry() persists full metadata
• Broadcasts StateManager events to every UI context
UI Contexts (tab-view.js, popup.js)
• render_engine.buildCollaborationMeta() groups rows by collaborationGroupId
• A 🤝 badge + tooltip summarizes present/missing collaborators
collaborationGroupId: deterministic link between channels blocked in the same action.collaborationWith[]: per-channel "other members" list used for warnings and tooltips.allCollaborators[]: canonical roster (name/handle/id) stored on each channel row so the UI can reason about partial groups even after reordering or searching.
- Storage (
background.js+settings_shared.js):sanitizeChannelEntrypreservescollaborationGroupId,collaborationWith, andallCollaborators, meaning compiled settings always contain the metadata necessary for UI rehydration. - Render Engine:
buildCollaborationMetacompares the stored roster with currently filtered entries, computespresentCount/totalCount, and emits:collaboration-entry+ yellow rail (full groups)collaboration-partial+ dashed rail (missing members)titleattribute tooltips with “Originally blocked with / Still blocked / Missing now” copy.
- Search & Sort Integrity: Because every collaborator remains an independent row, FCFS ordering, keyword search, and sort toggles behave exactly as non-collab entries, avoiding clipping issues seen with floating group containers.
- Home Feed Menu Parity:
block_channel.jswatcher treatsbutton-view-modelwrappers as click anchors, so collaboration-aware menu injection (multi-channel + Block All) works on lockup-based home cards, grid shelves, and Shorts shelves alike. - Watch SPA Rehydration: recovered watch-page collaborator rosters reconnect to stored
collaborationGroupId/allCollaboratorsafter navigation, so the tab UI and subsequent menus can reflect the full group rather than the first visible name only.
- Extraction + decoding: handle parsing is percent-decoding + unicode-aware, so unicode handles and encoded links normalize consistently across DOM scraping and URL parsing.
- Canonicalization: normalization strips URLs/querystrings and enforces lowercase so duplicates and mixed input sources converge to the same key.
- Storage sync: once an association is learned (handle ↔ UC ID, and custom URL ↔ UC ID), it is persisted in
channelMapso future matching avoids network calls. - Regex Compilation:
compileKeywordsescapes user input but keeps literal dots/underscores intact, ensuring collaboration-derived keywords like@foo.barremain matchable.
Shorts cards rarely embed canonical IDs, so FilterTube performs a resolution pipeline that prefers cache + main-world data before network:
sequenceDiagram
participant DOM as block_channel.js + content_bridge.js (Shorts UI)
participant FETCH as Remote Fetches
participant BG as background.js
DOM->>DOM: Detect ytd-reel-item / ytd-shorts-lockup
DOM->>DOM: Prefer DOM-extracted /channel/UC... when present
DOM->>DOM: Prefer videoChannelMap (learned passively from player payloads)
DOM->>DOM: try channelMap / ytInitialData replay (no network)
DOM->>FETCH: fetch shorts/watch HTML only as a fallback
DOM->>BG: handleAddFilteredChannel({ id, handle, isShorts:true })
BG->>BG: Persist channel, broadcast state update
DOM->>DOM: hide container (parent rich-item) immediately → zero blank slots
- Grace period: while identity enrichment runs, DOM fallback can hide immediately so the user does not see blocked content.
- Convergence: once the canonical UC ID is known, subsequent interceptors (data + DOM) recognize the entry on every surface without repeated fetches.
- Collaborator Harvesting: When Shorts expose the avatar stack,
extractCollaboratorsFromAvatarStackElementseeds collaborator names/handles and the main-world hop fills UC IDs. The same multi-select UI appears regardless of layout.
Current behavior note: as of v3.2.1, Shorts identity is increasingly learned without explicit Shorts-page fetching because:
seed.jsinterceptsytInitialPlayerResponseand/youtubei/v1/player, andfilter_logic.jsharvestsvideoId -> UC...intovideoChannelMap.- Proactive XHR interception provides most channel identity before rendering.
- Many cards now expose
/channel/UC...anchors directly, allowing isolated-world extraction to returnidimmediately.
Motivation: Sometimes data interception misses something (e.g., complex updates). The DOM Fallback is a safety net that watches the screen itself and hides anything that slipped through.
How it works (Simplified): This is the backup security guard patrolling the building. If a banned video somehow snuck past the front door check, this guard spots it on the wall (the screen) and immediately throws a "Do Not Display" sheet over it so you can't see it.
Technical Flow:
+-------------+
| Mutation |
| Observer |
+-------------+
|
v
+-------------+ +-------------+
| New Node | ----> | Is Video? |
| Detected | | (Selector) |
+-------------+ +-------------+
| Yes
v
+-------------+
| Extract |
| Data |
+-------------+
|
v
+-------------+ +-------------+
| Do Nothing | <---- | Match Rules?|
+-------------+ No +-------------+
| Yes
v
+-------------+
| Apply CSS |
| (Hide) |
+-------------+
Motivation: Users want to know how much time they've saved by not watching unwanted content.
How it works:
- Detection: When a video is blocked, FilterTube looks for its duration (e.g., "10:05").
- Calculation: It parses "10:05" into 605 seconds.
- Accumulation: It adds this to a running total stored locally.
- Display: The UI converts the total seconds back into "Minutes/Hours Saved".
Technical Flow:
graph TD
A[Video Blocked] --> B{Has Duration?}
B -- Yes --> C[Parse Duration]
B -- No --> D[Use Default: 3m for videos, 30s for shorts]
C --> E[Add to Total Stats]
D --> E
E --> F[Save to Storage]
F --> G[Update UI Badge]
Note (v3.0.1): FilterTube now tracks actual video durations extracted from YouTube's metadata whenever available, providing accurate time saved calculations instead of generic estimates.
Motivation:
With multiple UIs (Popup, Tab View) and background processes, keeping settings in sync is critical. StateManager acts as the single source of truth.
How it works:
- Single Source: All components read/write settings via
StateManager. - Broadcasting: When settings change in one place,
StateManagernotifies all other parts of the extension. - Consistency: Ensures that if you add a filter in the Tab View, the Popup updates instantly.
This now includes shell follow-up behavior too:
- popup and tab shells both refresh from the same confirmed background state
- route changes reset the page-scroll position in tab view
- profile/dropdown fixes and row-shell behavior are driven by shared state updates rather than local one-off assumptions
Motivation: Channels can be identified by Name ("My Channel"), Handle ("@mychannel"), or ID ("UC..."). Users might use any of these. The algorithm must normalize and match correctly.
How it works (Simplified): If you ban "@coolguy", the system needs to know that "Cool Guy Vlogs" is the same person. It looks at the video's "ID card" which lists their Name, Handle, and ID number. It checks if any of those match what you banned.
Technical Flow:
+-------------+
| Channel In |
| (Name/ID/@) |
+-------------+
|
v
+-------------+ +-------------+
| Normalize | ----> | Compare |
| (Lowercase)| | (Rules) |
+-------------+ +-------------+
|
v
+-------------+
| Match Type? |
+-------------+
/ | \
(@Handle) (ID) (Name)
/ | \
+-------+ +-------+ +-------+
| Exact | | Exact | |Partial|
+-------+ +-------+ +-------+
The release notes experience is now shared between the banner that appears on YouTube and the new “What’s New” tab in the dashboard. Both consume data/release_notes.json.
graph TD
RN["data/release_notes.json"] --> BG["background.js"]
BG -->|"buildReleaseNotesPayload"| Storage["chrome.storage.local releaseNotesPayload"]
Storage --> Banner["content/release_notes_prompt.js<br/>YouTube CTA"]
RN --> Dashboard["tab-view.js<br/>loadReleaseNotesIntoDashboard"]
sequenceDiagram
participant CS as release_notes_prompt.js
participant BG as background.js
participant TAB as Browser Tabs
CS->>BG: FilterTube_OpenWhatsNew (target URL)
BG->>TAB: query tab-view.html*
alt tab exists
BG->>TAB: tabs.update(id, url=?view=whatsnew, active=true)
else new tab
BG->>TAB: tabs.create(url=?view=whatsnew)
end
tab-view.jsreads both the hash and?view=query param so deep links select the correct nav item and scroll it into view.data/release_notes.jsonentries supportbannerSummary,highlights[], anddetailsUrl. Dashboard cards render the highlights list; the banner usesbannerSummary.- Import/export doc updates reference this shared file so future releases update one source.
| Module | Responsibility |
|---|---|
js/io_manager.js |
Normalizes keywords/channels, adapters, merge logic, and v3 schema builder. |
state_manager.js + FilterTubeSettings |
Entry points that read/write storage so compilation remains centralized. |
html/tab-view.html + tab-view.js |
Provide UI controls (file picker, merge/replace). |
Tab View Export Button
|
v
StateManager.loadSettings()
|
v
io_manager.js::buildV3Export()
|
v
Blob + FileSaver
User selects JSON
|
v
io_manager.js::importV3()
|
v
normalizeIncomingV3()
|
v
mergeChannelLists() / mergeKeywordLists()
|
v
FilterTubeSettings.saveSettings()
- Inputs such as
https://www.youtube.com/c/Filmy_Gyaannormalize toc/filmy_gyaan. - Merge priority:
UCID > @handle > customUrl > name/originalInput. - When both a handle and custom URL are present,
sanitizeChannelEntryretains both to improve lookups across surfaces.
Imported backups often contain bare IDs/handles without canonical metadata (logo, custom URL, collaboration hints). To avoid hammering YouTube for every entry simultaneously, the UI defers enrichment through a queue managed inside state_manager.js:
sequenceDiagram
participant Import as importV3 / merge logic
participant SM as StateManager
participant Queue as channelEnrichmentQueue
participant BG as background.js
participant YT as youtube.com
Import->>SM: saveSettings({ channels })
SM->>Queue: enqueue channels lacking canonical metadata
Note over Queue: Deduplicate via channelEnrichmentAttempted Set
Queue->>BG: handleAddFilteredChannel (one entry)
BG->>YT: best-effort fetch (handle/custom URL lookup)
BG-->>SM: sanitized channel metadata
SM->>Queue: wait ~6s, pop next item (prevents floods/DDOS)
Key behaviors:
- Deduping:
channelEnrichmentAttemptedensures each unique handle/ID is only fetched once per session. - Throttle:
processChannelEnrichmentQueuenow sleeps a randomized 5–7 seconds between requests, preventing burst traffic after large imports. - Auto-drain: Once the queue is empty, enrichment stops until the next import or manual add; nothing persists that would keep pinging YouTube.
- Fallback safe: If a fetch fails (network/offline), the entry stays without enriched data but the queue still advances—avoiding infinite retries.
- Instrumentation: Every dequeued entry logs
channel enrichment start/completewith queue depth, duration, and the next randomized delay so QA can confirm pacing without extra tooling.
FilterTube maintains a second profile that mirrors the main blocklists but targets YouTube Kids domains:
- Separate storage:
state_manager.jsexposesgetKidsState,addKidsKeyword,addKidsChannel, etc., which persist toFilterTubeIO.saveProfilesV3()under thekidskey so main filters never leak into kids browsing. - Dashboard tabs:
tab-view.jsrendersinitializeKidsTabs()with keyword/channel managers, search & sort controls, and the same calendar presets used in the main Filters view—users can curate Kids lists without touching the main ones. - Passive capture on youtubekids.com:
content/block_channel.jsdetects the native “Block this video” toast. When a parent blocks content directly inside YouTube Kids, FilterTube synthesizes a minimal channel entry and sends it toFilterTube_KidsBlockChannel, keeping the Kids profile in sync even if the extension UI is never opened. - Shared enrichment safety: the regular enrichment queue runs for Kids entries too, but because Kids browsing happens on a distinct domain, the throttle + logging described above prevent the passive capture flow from overwhelming background requests.
Motivation: Shorts are unique because they often lack channel information in the initial DOM. To ensure robust blocking, the system uses cache-first + main-world recovery to resolve a canonical UC ID whenever possible.
How it works:
- User Action: User clicks "Block Channel" in 3 dots menu.
- Cache-first resolution: resolve using
channelMap(handle/customUrl ↔ UC ID) when possible. - Main-world recovery: if a
videoIdis available, query main-worldytInitialDatafor the uploader identity. - Network fallback: fetch handle/customUrl pages only when necessary.
- Finalize: persist the channel keyed by UC ID; the DOM fallback provides immediate visual feedback while enrichment completes.
The exact latency depends on which fallback path is needed (cache/main-world/network). The goal is that blocks remain correct and converge quickly to a canonical UC ID.
Technical Flow:
[User Click "Block"]
|
v
+-----------------------+
| 1. Identify Type |
+-----------------------+
|
+--------(If Short)-------+
| |
v v
+--------------------+ +----------------------+
| 2. Fetch Short URL | | (Standard Video) |
| -> Get Channel ID | | Have Channel ID |
+--------------------+ +----------------------+
| |
+-----------+-------------+
|
v
+-----------------------------------+
| 3. Fetch Canonical ID (Robustness)| <--- "The 1-sec Safety Check"
| -> Resolve to unique UC ID | (Ensures Zero Leakage)
+-----------------------------------+
|
v
+-----------------------------------+
| 4. Update Block List & Hide Card |
+-----------------------------------+
| 5. Update videoChannelMap (Global)|
+-----------------------------------+
The current extension UI is no longer just a set of styled legacy controls. The popup and tab view now sit inside a shared shell layer with:
- a compact popup tray rather than a mini full page
- a persistent app-like desktop shell in tab view
- ambient local hero video behind popup and tab surfaces
- stronger semantic pill colors for
Exact,Comments, andFilter All - separate compact popup-row behavior vs shared full tab-view row shells
- internal scroll containers and mobile drawer/sidebar handling tuned independently
Relevant runtime/build pieces:
/Users/devanshvarshney/FilterTube/src/extension-shell/popup.jsx/Users/devanshvarshney/FilterTube/src/extension-shell/tab-view-decor.jsx/Users/devanshvarshney/FilterTube/js/ui-shell/popup-shell.js/Users/devanshvarshney/FilterTube/js/ui-shell/tab-view-decor.js/Users/devanshvarshney/FilterTube/css/serene-shell.css/Users/devanshvarshney/FilterTube/css/design_tokens.css
FilterTube v3.2.6 introduces refined dropdown and select styling with left accent borders and improved interactivity:
Visual Design:
┌─────────────────────────────────────────┐
│ Select Component Anatomy │
├─────────────────────────────────────────┤
│ │
│ ┃ Sort by: Newest First ▼ │
│ ┃ ───────────────────────────── │
│ ┃ 3px brand accent border │
│ ┃ │
│ └─ Floating shadow + hover glow │
│ │
│ Dropdown Menu: │
│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
│ ┃ ● Newest First ┃ │
│ ┃ ○ Oldest First ┃ │
│ ┃ ○ A-Z ┃ │
│ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │
│ │
└─────────────────────────────────────────┘
Extension Status Label:
┌─────────────────────────────────┐
│ 🎯 FilterTube │
│ ENABLED │ ← 7px uppercase status
└─────────────────────────────────┘
Green border + background
┌─────────────────────────────────┐
│ 🎯 FilterTube │
│ DISABLED │ ← 7px uppercase status
└─────────────────────────────────┘
Red border + background
Follow-up shell/UI behavior now also includes:
- popup profile dropdown sizing tuned to popup scale
- detached dropdown anchoring fixes in tab view
- short-height sidebar internal scrolling
- route/page scroll reset between tab-view sections
- corrected list-row shells so long channel/keyword lists do not overlap or clip metadata
- custom fallback 3-dot feedback states: pressed, focus-visible, and open
- popup now registers the shared
window.FilterTubeIsUiLockedhook soStateManager.updateSetting()cannot be bypassed from the header brand toggle on locked profiles - popup header enabled/disabled state is visually disabled and hard-stopped while the active profile is PIN-locked
- tab-view profile switching mirrors popup on denied/cancelled PIN prompts by refreshing profile/badge/lock UI instead of leaving stale selection state behind
- tab-view exposes
resetTabViewScrollthe same way it exposescloseProfileDropdownTab, so lock-gate redirects and profile-driven view changes do not crash on scope boundaries
The popup now includes a collapsible Advance video filters section:
Visual Layout:
┌─────────────────────────────────────────┐
│ Content Controls │
│ ───────────────────────────────────── │
│ │
│ ▼ Advance Video Filters │ ← Collapsible header
│ ┌───────────────────────────────────┐ │
│ │ Duration: [5] to [20] minutes │ │ ← Inline inputs
│ │ Upload Date: [2024-01-01] to now │ │
│ │ Uppercase: Single word (min 2) │ │
│ │ │ │
│ │ [Manage in Settings →] │ │ ← Link button
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
Current note:
- the newer popup direction is a compact quick-control tray
- advanced controls still exist, but the shell is optimized around fast actions first
Filter Allin the custom fallback 3-dot popover is selection-only; only the actual block row commits the action
Custom Radio Buttons:
.custom-radio {
width: 18px;
height: 18px;
border: 2px solid var(--ft-color-sem-neutral-border);
border-radius: 50%;
position: relative;
}
.custom-radio::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ft-color-brand-secondary);
transition: transform 0.2s ease;
}
.video-filter-radio-card.selected .custom-radio::after {
transform: translate(-50%, -50%) scale(1);
}Visual Layout:
┌─────────────────────────────────────────────────────────┐
│ ┃ Duration Filter │
│ ┃ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ ┃ │ ◉ Longer │ │ ○ Shorter │ │ ○ Between │ │
│ ┃ │ than [10] │ │ than [5] │ │ [5] - [20] │ │
│ ┃ │ minutes │ │ minutes │ │ minutes │ │
│ ┃ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ┃ │
│ ┃ ← 3px brand accent border │
└─────────────────────────────────────────────────────────┘