Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/content-script/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ html[global_enable="true"][foo=bar]

}

html[global_enable='true'][grayscale_mode='true'] ytd-app {
html[global_enable='true'][grayscale_mode='true']:not([grayscale_exempt='true']) ytd-app {
filter: grayscale(100%);
}

Expand Down
22 changes: 22 additions & 0 deletions src/content-script/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,24 @@ function runDynamicSettings() {
document.title = document.title.replace(/^\(\d+\)/g, '');
}

// Grayscale color exceptions — pages belonging to an exempt channel
// render in color. Channel pages match by URL; watch pages match by the
// owner link once YouTube renders it (the loop re-checks each pass, so
// SPA navigations self-correct).
if (cache['grayscale_mode'] === true) {
const handles = parseGrayscaleExemptChannels(cache['grayscale_exempt_channels']);
let exempt = false;
if (handles.length) {
if (onChannel) {
exempt = isGrayscaleExemptPath(location.pathname, handles);
} else if (onVideo) {
const owner = qs('ytd-video-owner-renderer ytd-channel-name a[href^="/@"]');
exempt = isGrayscaleExemptPath(owner?.getAttribute('href'), handles);
}
}
HTML.setAttribute('grayscale_exempt', exempt);
}

// Show video length when thumbnails are hidden
if (cache['search_engine_mode'] || cache['remove_video_thumbnails']) {
const thumbnails = qsa('ytd-thumbnail');
Expand Down Expand Up @@ -706,6 +724,10 @@ function handleNewPage() {
// Mark whether or not we're on a video page
HTML.setAttribute('on_video', onVideo);

// Reset the grayscale exemption on navigation; the dynamic loop re-derives
// it for the new page (prevents a stale color/gray state carrying over).
HTML.setAttribute('grayscale_exempt', false);

// Refresh HTML attributes
Object.entries(cache).forEach(([key, value]) => HTML.setAttribute(key, value));

Expand Down
8 changes: 8 additions & 0 deletions src/options/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,14 @@
<input>
</div>

<!-- Sub-option of grayscale_mode; moved beneath its toggle at init. -->
<div id="grayscale_exempt_container" hidden>
<div id="grayscale_exempt_label">Keep these channels in color</div>
<div id="grayscale_pill_box">
<input id="grayscale_pill_input" type="text" placeholder="@channel" spellcheck="false">
</div>
</div>

<div id='settings-menu' class='hidden'>
<div id='settings-account'>Sign In</div>
<hr>
Expand Down
8 changes: 8 additions & 0 deletions src/options/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
const openScheduleButton = document.getElementById('disabled_message_open_schedule');
openScheduleButton.addEventListener('click', e => openScheduleModal());

initGrayscaleExemptUI(SETTING_VALUES['grayscale_exempt_channels'] || '');

// Begin time loop -- checks for timedChanges, scheduling
timeLoop();
refreshActiveSection();
Expand Down Expand Up @@ -407,6 +409,12 @@ function updateSetting(id, value, { write=true, manual=false }={}) {
updateSetting('nextTimedChange', false);
}

// Keep the pill UI in sync when the list changes (e.g. from another window)
if (id === 'grayscale_exempt_channels' &&
typeof renderGrayscalePills === 'function') {
renderGrayscalePills(value);
}

const timeInfoIds = [
'nextTimedChange', 'schedule',
'scheduleTimes', 'scheduleDays'
Expand Down
68 changes: 68 additions & 0 deletions src/options/options.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,71 @@ html[dark_mode='true'] div.option:hover {
.noselect {
user-select: none;
}

/* Grayscale color exceptions — sub-option revealed under the grayscale toggle */
#grayscale_exempt_container {
display: none;
flex-direction: column;
gap: 4px;
margin: 2px 12px 6px 56px;
}
html[grayscale_mode='true'] #grayscale_exempt_container {
display: flex;
}
/* Keep the sub-row hidden when search/section filters hide its parent row */
div#grayscale_mode.removed + #grayscale_exempt_container {
display: none !important;
}

#grayscale_exempt_label {
font-size: 0.8rem;
color: var(--label-color);
opacity: 0.75;
}

#grayscale_pill_box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 4px 6px;
border-radius: var(--border-radius);
box-shadow: 0px 0px 1px 0.5px var(--box-shadow-color);
}

#grayscale_pill_box .pill {
display: flex;
align-items: center;
gap: 4px;
padding: 1px 4px 1px 10px;
border-radius: 999px;
background-color: var(--box-shadow-color);
font-size: 0.8rem;
white-space: nowrap;
}

#grayscale_pill_box .pill button {
border: none;
background: none;
padding: 0 4px;
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
opacity: 0.6;
}
#grayscale_pill_box .pill button:hover {
opacity: 1;
}

#grayscale_pill_input {
flex: 1;
min-width: 90px;
border: none;
box-shadow: none;
background: transparent;
font-size: 0.8rem;
color: var(--body-color);
}
#grayscale_pill_input:focus {
box-shadow: none;
}
93 changes: 93 additions & 0 deletions src/options/settings-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -836,3 +836,96 @@ upgradeCheckoutButton.addEventListener('click', async () => {
upgradeCheckoutButton.disabled = false;
}
});


/*******************************
* Grayscale color exceptions
*******************************/

// Rebuild the pill list from the stored comma-separated string. Called on
// init and whenever the setting changes (including from another window).
function renderGrayscalePills(value) {
const box = qs('#grayscale_pill_box');
const input = qs('#grayscale_pill_input');
if (!box || !input) return;

qsa('.pill', box).forEach(pill => pill.remove());

parseGrayscaleExemptChannels(value).forEach(handle => {
const pill = document.createElement('div');
pill.className = 'pill';

const label = document.createElement('span');
label.innerText = handle;

const remove = document.createElement('button');
remove.innerText = '×';
remove.setAttribute('title', `Remove ${handle}`);
remove.addEventListener('click', () => {
commitGrayscalePillInput(handle);
});

pill.append(label, remove);
box.insertBefore(pill, input);
});
}

function saveGrayscalePills() {
const handles = qsa('#grayscale_pill_box .pill span').map(s => s.innerText);
updateSetting('grayscale_exempt_channels', handles.join(', '), { manual: true });
}

function commitGrayscalePillInput(removeHandle) {
const input = qs('#grayscale_pill_input');
const raw = input.value.trim().replace(/,+$/, '').trim();
input.value = '';
if (!raw && !removeHandle) return;

const handles = qsa('#grayscale_pill_box .pill span')
.map(s => s.innerText)
.filter(handle => handle !== removeHandle);
if (raw) {
const handle = (raw.startsWith('@') ? raw : '@' + raw).toLowerCase();
if (!handles.includes(handle)) handles.push(handle);
}

// Save the removal and pending input together, before rebuilding the pills.
updateSetting('grayscale_exempt_channels', handles.join(', '), { manual: true });
}

// Move the container beneath the grayscale toggle row and wire the input.
// Runs once, at the end of populateOptions.
function initGrayscaleExemptUI(value) {
const container = qs('#grayscale_exempt_container');
const toggleRow = qs('div#grayscale_mode');
const input = qs('#grayscale_pill_input');
if (!container || !toggleRow || !input) return;

toggleRow.insertAdjacentElement('afterend', container);
container.removeAttribute('hidden');
renderGrayscalePills(value);

input.addEventListener('keydown', e => {
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitGrayscalePillInput();
} else if (e.key === 'Backspace' && input.value === '') {
const pills = qsa('#grayscale_pill_box .pill');
if (pills.length) {
pills[pills.length - 1].remove();
saveGrayscalePills();
}
}
});
// Moving focus to a remove button must not rebuild it before its click.
// Commit when focus leaves the box, or after removal in the click handler.
qs('#grayscale_pill_box').addEventListener('focusout', e => {
if (!e.currentTarget.contains(e.relatedTarget)) commitGrayscalePillInput();
});

// Focus the input when the empty area of the box is clicked
qs('#grayscale_pill_box').addEventListener('click', e => {
if (e.target.id === 'grayscale_pill_box') input.focus();
});
}
30 changes: 30 additions & 0 deletions src/shared/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,7 @@ const OTHER_SETTINGS = {
dark_mode: false,
log_enabled: false,
log_prompt_answered: false,
grayscale_exempt_channels: '',
...TIMED_SETTINGS,
...SCHEDULE_SETTINGS,
...PASSWORD_SETTINGS,
Expand Down Expand Up @@ -828,6 +829,7 @@ const idToShortId = {
"remove_playables": '95',
"remove_sub_most_relevant": '96',
"remove_youtube_logo": '97',
"grayscale_exempt_channels": '98',
};


Expand Down Expand Up @@ -890,3 +892,31 @@ function enforceSlotBudget(settings, slotLimit) {
});
return writeBack;
}

// Grayscale color exceptions: parse the user's comma-separated channel list
// into normalized, deduplicated @handles (lowercased for comparison).
function parseGrayscaleExemptChannels(str) {
if (!str || typeof str !== 'string') return [];
const handles = str.split(',')
.map(t => t.trim())
.filter(Boolean)
.map(t => (t.startsWith('@') ? t : '@' + t).toLowerCase());
return Array.from(new Set(handles));
}

// True when a YouTube path (or channel-link href) belongs to one of the
// exempt @handles. Matches the first path segment exactly, so "@veri" never
// matches "/@veritasium" and deeper paths like "/@handle/videos" still match.
function isGrayscaleExemptPath(pathname, handles) {
if (!pathname || !handles || !handles.length) return false;
let seg = pathname.split('/').find(Boolean);
if (!seg) return false;
// Browser pathnames encode non-ASCII handles; DOM hrefs may be literal.
try {
seg = decodeURIComponent(seg);
} catch {
return false;
}
if (!seg || !seg.startsWith('@')) return false;
return handles.includes(seg.toLowerCase());
}
63 changes: 63 additions & 0 deletions tests/shared/main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,66 @@ describe('Main Settings', () => {
});
});
});

describe('Grayscale color exceptions', () => {
const main2 = loadSourceFile('shared/main.js');

describe('parseGrayscaleExemptChannels', () => {
it('parses a comma-separated list into normalized handles', () => {
assertDeepEqual(
main2.parseGrayscaleExemptChannels('@Veritasium, kurzgesagt ,@3blue1brown'),
['@veritasium', '@kurzgesagt', '@3blue1brown']
);
});

it('deduplicates and drops empty tokens', () => {
assertDeepEqual(
main2.parseGrayscaleExemptChannels('@a,, @A , a,'),
['@a']
);
});

it('returns [] for empty or non-string input', () => {
assertDeepEqual(main2.parseGrayscaleExemptChannels(''), []);
assertDeepEqual(main2.parseGrayscaleExemptChannels(null), []);
assertDeepEqual(main2.parseGrayscaleExemptChannels(undefined), []);
assertDeepEqual(main2.parseGrayscaleExemptChannels(false), []);
});
});

describe('isGrayscaleExemptPath', () => {
const handles = ['@veritasium', '@kurzgesagt'];

it('matches channel root and deeper paths', () => {
assert.strictEqual(main2.isGrayscaleExemptPath('/@veritasium', handles), true);
assert.strictEqual(main2.isGrayscaleExemptPath('/@veritasium/videos', handles), true);
assert.strictEqual(main2.isGrayscaleExemptPath('/@Veritasium', handles), true);
});

it('matches encoded and literal non-ASCII handles', () => {
const unicodeHandles = main2.parseGrayscaleExemptChannels('@日本語, @CAFÉ');
const pathname = new URL('https://www.youtube.com/@日本語/videos').pathname;
assert.strictEqual(main2.isGrayscaleExemptPath(pathname, unicodeHandles), true);
assert.strictEqual(main2.isGrayscaleExemptPath('/@日本語', unicodeHandles), true);
assert.strictEqual(main2.isGrayscaleExemptPath('/@CAF%C3%89', unicodeHandles), true);
assert.strictEqual(main2.isGrayscaleExemptPath('/@日本語2', unicodeHandles), false);
});

it('rejects malformed percent encoding without throwing', () => {
assert.strictEqual(main2.isGrayscaleExemptPath('/@bad%', ['@bad%']), false);
assert.strictEqual(main2.isGrayscaleExemptPath('/@%E6%97', handles), false);
});

it('requires an exact handle segment (no prefix matches)', () => {
assert.strictEqual(main2.isGrayscaleExemptPath('/@veri', handles), false);
assert.strictEqual(main2.isGrayscaleExemptPath('/@veritasium2', handles), false);
});

it('ignores non-channel paths and empty input', () => {
assert.strictEqual(main2.isGrayscaleExemptPath('/watch?v=abc', handles), false);
assert.strictEqual(main2.isGrayscaleExemptPath('/', handles), false);
assert.strictEqual(main2.isGrayscaleExemptPath(null, handles), false);
assert.strictEqual(main2.isGrayscaleExemptPath('/@veritasium', []), false);
});
});
});