Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ Todas as mudanças relevantes deste projeto são documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/)
e o projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).

## [Não lançado]

### Adicionado

- **Configurações que te acompanham (storage.sync)**: modo, indicadores,
memória por canal e tema agora seguem o perfil do navegador entre
computadores. Migração automática e única do `storage.local` na primeira
execução, com leitura de reserva no dado antigo até ela acontecer — nenhuma
chave é renomeada, e um downgrade encontra os padrões, nunca dado corrompido.
Ficam locais de propósito: contadores de doação (senão contariam em dobro),
o sinal de "ir ao vivo" e o canal da aba atual.

## [1.4.0] - 2026-07-07

### Adicionado
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ YouTube player UI.
- Keyboard shortcuts to toggle on/off (`Alt+Shift+Y`) and jump to live
(`Alt+Shift+L`) — `⌘+Shift+…` on Mac — remappable at
`chrome://extensions/shortcuts`
- Your mode, indicators and theme **follow your browser profile across
devices** (`storage.sync`) — donation choices and per-device state stay local

## Settings

Expand Down
2 changes: 2 additions & 0 deletions README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ YouTube.
- Atalhos de teclado para ligar/desligar (`Alt+Shift+Y`) e pular para o ao vivo
(`Alt+Shift+L`) — `⌘+Shift+…` no Mac —, reconfiguráveis em
`chrome://extensions/shortcuts`
- Modo, indicadores e tema **acompanham seu perfil do navegador entre
computadores** (`storage.sync`) — doação e estado por dispositivo ficam locais

## Configurações

Expand Down
5 changes: 3 additions & 2 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function evalBadge() {

function boot() {
common.ensureInstalledAt();
common.ensureSettingsMigrated(); // one-time local→sync move (idempotent)
chrome.alarms.create('donate-eval', { periodInMinutes: 30 });
evalBadge();
}
Expand Down Expand Up @@ -82,11 +83,11 @@ function messageActiveTab(type) {
*/
function onCommand(command) {
if (command === 'toggle-enabled') {
chrome.storage.local.get([...common.storage, common.lastModeKey], data => {
common.getSettings([...common.storage, common.lastModeKey], data => {
const { apply, remember } = common.toggleEnabledAction(data, data[common.lastModeKey]);
const patch = { ...apply };
if (remember) patch[common.lastModeKey] = remember;
chrome.storage.local.set(patch);
common.setSettings(patch);
});
} else if (command === 'go-live') {
messageActiveTab('go-live');
Expand Down
99 changes: 99 additions & 0 deletions common.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,105 @@ export function forgetChannelMode(data, channelId) {
return modes;
}

// ---------------------------------------------------------------------------
// Settings roaming (chrome.storage.sync). The PREFERENCE-class keys — engine
// settings, last mode, channel memory, theme — follow the user's browser
// profile across devices. Device-local state stays in storage.local:
// • donate* keys (usage time is per-device; syncing would double-count),
// • goLiveSignal (a transient one-shot),
// • currentChannelId (literally "the channel of the tab last seen HERE").
// Reads go through getSettings(), which falls back to storage.local until the
// one-time migration (ensureSettingsMigrated, run at worker boot and popup
// open) has moved existing users' data over. All keys keep their names — no
// rename, no reshape — so a downgrade simply finds defaults, never corruption.
// ---------------------------------------------------------------------------
export const syncedKeys = [...storage, lastModeKey, channelMemoryKey, channelModesKey, themeKey];

const hasChromeStorage = typeof chrome !== 'undefined' && !!chrome.storage;

/** The area settings live in: sync when the browser offers it, else local. */
export function settingsArea() {
return (hasChromeStorage && chrome.storage.sync) ? chrome.storage.sync : chrome.storage.local;
}

/**
* Read settings-class keys. Prefers the sync area; when none of the requested
* keys exist there yet (pre-migration, or sync unavailable) it re-reads from
* storage.local so existing users never see a flash of defaults.
* @param {string[]} keys - Keys to read (settings-class only).
* @param {(items: Object) => void} cb - Receives the resolved values.
*/
export function getSettings(keys, cb) {
const area = settingsArea();
area.get(keys, vals => {
if (area === chrome.storage.local) { cb(vals); return; }
const empty = keys.every(k => vals[k] === undefined);
if (!empty) { cb(vals); return; }
chrome.storage.local.get(keys, cb);
});
}

/**
* Write settings-class keys to the roaming area. Best-effort: a quota/offline
* error is swallowed (the keys are tiny; the realistic trip is the per-minute
* write limit, which a retry naturally clears).
* @param {Object} items - Key/value pairs to persist.
* @param {() => void} [cb] - Called after the write settles.
*/
export function setSettings(items, cb) {
settingsArea().set(items, () => {
void chrome.runtime.lastError;
if (cb) cb();
});
}

/**
* Decide what the one-time local→sync migration should do. Pure, so the three
* outcomes stay unit-testable:
* • local has settings, sync has none -> copy them over, then clean local;
* • sync already has settings -> sync wins (another device migrated
* first); just clean local so reads stop being ambiguous;
* • local has nothing -> nothing to do.
* @param {Object} localData - Values read from storage.local.
* @param {Object} syncData - Values read from storage.sync.
* @param {string[]} [keys] - The settings-class key set.
* @returns {{copy: (Object|null), removeLocal: string[]}}
*/
export function planSyncMigration(localData, syncData, keys = syncedKeys) {
const localKeys = keys.filter(k => localData && localData[k] !== undefined);
if (!localKeys.length) return { copy: null, removeLocal: [] };
const syncHas = keys.some(k => syncData && syncData[k] !== undefined);
if (syncHas) return { copy: null, removeLocal: localKeys };
const copy = {};
for (const k of localKeys) copy[k] = localData[k];
return { copy, removeLocal: localKeys };
}

/**
* Run the local→sync migration once, idempotently. Safe to call from several
* contexts: after it has run, local holds none of the keys and the plan is a
* no-op. When the copy write fails (quota/offline) local is kept untouched so
* the next boot retries.
* @param {() => void} [done] - Called when the migration settles.
*/
export function ensureSettingsMigrated(done) {
if (!hasChromeStorage || !chrome.storage.sync) { if (done) done(); return; }
chrome.storage.local.get(syncedKeys, loc => {
chrome.storage.sync.get(syncedKeys, syn => {
const plan = planSyncMigration(loc, syn);
const clean = () => {
if (!plan.removeLocal.length) { if (done) done(); return; }
chrome.storage.local.remove(plan.removeLocal, () => { if (done) done(); });
};
if (!plan.copy) { clean(); return; }
chrome.storage.sync.set(plan.copy, () => {
if (chrome.runtime.lastError) { if (done) done(); return; } // retry next boot
clean();
});
});
});
}

// International donation links — shown to users whose browser is NOT in pt_BR
// (see `isBrazil`). Replace the placeholders with your own usernames/links.
// Leave a value as '' (or keep the REPLACE placeholder) to hide that button.
Expand Down
57 changes: 33 additions & 24 deletions content.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const extensionAlive = () => Boolean(chrome.runtime?.id);
function main(common) {
function loadSettings() {
if (!extensionAlive()) return;
chrome.storage.local.get(common.storage, data => {
common.getSettings(common.storage, data => {
sendLoadSettingsEvent(common.resolveSettings(data));
});
}
Expand Down Expand Up @@ -52,8 +52,9 @@ function main(common) {
// Reload only when an engine setting actually changed — the donation
// counter and control keys write storage frequently, and re-sending
// settings to every YouTube frame on each of those writes is pure churn.
// Settings roam via storage.sync, but 'local' still matters pre-migration.
function onEngineSettingsChanged(changes, area) {
if (area === 'local' && common.storage.some(k => k in changes)) loadSettings();
if ((area === 'sync' || area === 'local') && common.storage.some(k => k in changes)) loadSettings();
}

/** Tell the engine (inject.js) to jump to the live edge now. */
Expand Down Expand Up @@ -109,7 +110,7 @@ function main(common) {
document.addEventListener('_live_catch_up_video_meta', e => onChannelIdUpdate(common, e.detail));
// Turning the feature ON should take effect on the channel you're already on.
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes[common.channelMemoryKey]?.newValue && lastChannelId) {
if ((area === 'sync' || area === 'local') && changes[common.channelMemoryKey]?.newValue && lastChannelId) {
onChannelIdUpdate(common, { channel_id: lastChannelId });
}
});
Expand Down Expand Up @@ -164,18 +165,22 @@ function initDonation(common) {
const usageTimer = setInterval(() => {
if (!extensionAlive()) { clearInterval(usageTimer); return; }
if (document.hidden || Date.now() - donateLastActive > 5000) return;
chrome.storage.local.get(['enabled', 'donateUsageSeconds', 'donateLastCountedAt'], d => {
if (!common.value(d.enabled, common.defaultEnabled)) return;
// Every YouTube tab runs this loop; only one may count each minute
// of wall-clock time, or N tabs would accrue N× the real usage.
// (5s of slack absorbs timer jitter between the tabs.)
const now = Date.now();
if (now - (d.donateLastCountedAt || 0) < (TICK - 5) * 1000) return;
chrome.storage.local.set({
donateUsageSeconds: (d.donateUsageSeconds || 0) + TICK,
donateLastCountedAt: now,
// `enabled` roams with the settings (sync); the usage counter itself is
// deliberately per-device local — syncing it would double-count time.
common.getSettings(['enabled'], s => {
if (!common.value(s.enabled, common.defaultEnabled)) return;
chrome.storage.local.get(['donateUsageSeconds', 'donateLastCountedAt'], d => {
// Every YouTube tab runs this loop; only one may count each minute
// of wall-clock time, or N tabs would accrue N× the real usage.
// (5s of slack absorbs timer jitter between the tabs.)
const now = Date.now();
if (now - (d.donateLastCountedAt || 0) < (TICK - 5) * 1000) return;
chrome.storage.local.set({
donateUsageSeconds: (d.donateUsageSeconds || 0) + TICK,
donateLastCountedAt: now,
});
maybeShowBanner(common);
});
maybeShowBanner(common);
});
}, TICK * 1000);
}
Expand Down Expand Up @@ -354,7 +359,7 @@ let stallOfferShown = false;

function onStallDetected(common) {
if (stallOfferShown || stallOfferEl || !extensionAlive()) return;
chrome.storage.local.get(common.storage, data => {
common.getSettings(common.storage, data => {
const target = common.calmerMode(common.deriveMode(data));
if (!target) return; // already on the calmest mode (or off)
showStallOffer(common, target);
Expand All @@ -379,7 +384,7 @@ function showStallOffer(common, target) {
stallOfferEl = buildOverlayCard({
side: 'left', maxWidth: '330px', content: body,
ctaLabel: `${L.stallSwitch} ${common.modeMeta[target].title}`,
onCta: () => chrome.storage.local.set(common.presets[target]),
onCta: () => common.setSettings(common.presets[target]),
closeLabel: L.donateBannerClose,
autoHideMs: 14000,
onRemove: () => { stallOfferEl = null; },
Expand All @@ -397,13 +402,17 @@ function onChannelIdUpdate(common, detail) {
const channelId = detail && detail.channel_id;
if (!channelId || !extensionAlive()) return;
lastChannelId = channelId;
chrome.storage.local.get([common.channelMemoryKey, common.channelModesKey, common.currentChannelIdKey, ...common.storage], data => {
const upd = {};
if (data[common.currentChannelIdKey] !== channelId) upd[common.currentChannelIdKey] = channelId;
if (data[common.channelMemoryKey]) {
const saved = common.getSuggestedModeForChannel(data, channelId);
if (saved && common.deriveMode(data) !== saved) Object.assign(upd, common.presets[saved]);
}
if (Object.keys(upd).length) chrome.storage.local.set(upd);
// The channel map + engine settings roam (storage.sync); currentChannelId
// is this device's "channel of the tab last seen" and stays local.
common.getSettings([common.channelMemoryKey, common.channelModesKey, ...common.storage], data => {
chrome.storage.local.get([common.currentChannelIdKey], meta => {
if (meta[common.currentChannelIdKey] !== channelId) {
chrome.storage.local.set({ [common.currentChannelIdKey]: channelId });
}
if (data[common.channelMemoryKey]) {
const saved = common.getSuggestedModeForChannel(data, channelId);
if (saved && common.deriveMode(data) !== saved) common.setSettings(common.presets[saved]);
}
});
});
}
41 changes: 28 additions & 13 deletions popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ function sanitizeSvg(node) {
for (const child of [...node.children]) sanitizeSvg(child);
}

const getStorage = keys => new Promise(res => chrome.storage.local.get(keys, res));
// Settings-class keys roam via storage.sync (with a pre-migration fallback to
// local) — see common.getSettings. Donation/meta keys keep using storage.local.
const getStorage = keys => new Promise(res => common.getSettings(keys, res));

/**
* Wire ARIA radiogroup keyboard behavior: one tab-stop for the group (roving
Expand Down Expand Up @@ -165,18 +167,18 @@ let rovingModes = null; // wireRadiogroup roving-tabindex setter for modes

function setOne(key, val) {
state[key] = val;
chrome.storage.local.set({ [key]: val });
common.setSettings({ [key]: val });
refresh();
}

function applyPreset(name) {
const preset = common.presets[name];
chrome.storage.local.set(preset);
common.setSettings(preset);
Object.assign(state, preset);
// Per-channel memory (opt-in): record this EXPLICIT pick for the current channel.
if (channelMemoryOn && currentChannelId && name !== 'off') {
channelModes = common.saveChannelMode({ [common.channelModesKey]: channelModes }, currentChannelId, name);
chrome.storage.local.set({ [common.channelModesKey]: channelModes });
common.setSettings({ [common.channelModesKey]: channelModes });
}
refresh();
}
Expand Down Expand Up @@ -366,6 +368,8 @@ function renderReset() {

function doReset() {
// Only clear engine settings — keep the donation opt-out / snooze choices.
// Both areas: sync is where they live now, local catches pre-migration data.
common.settingsArea().remove(common.storage);
chrome.storage.local.remove(common.storage);
state = common.resolveSettings({});
refresh();
Expand Down Expand Up @@ -604,15 +608,15 @@ function renderThemeToggle() {
btn.setAttribute('aria-label', title);
};
relabel();
chrome.storage.local.get([common.themeKey], d => {
common.getSettings([common.themeKey], d => {
const saved = d[common.themeKey];
if (saved === 'light' || saved === 'dark') root.dataset.theme = saved;
relabel();
});
btn.addEventListener('click', () => {
const next = current() === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
chrome.storage.local.set({ [common.themeKey]: next });
common.setSettings({ [common.themeKey]: next });
relabel();
});
}
Expand Down Expand Up @@ -645,7 +649,7 @@ function refresh() {
function renderChannelMemory() {
const rows = $('#channel-memory-rows');
if (!rows) return;
const input = el('input', { type: 'checkbox', onchange: () => chrome.storage.local.set({ [common.channelMemoryKey]: input.checked }) });
const input = el('input', { type: 'checkbox', onchange: () => common.setSettings({ [common.channelMemoryKey]: input.checked }) });
const sw = el('label', { class: 'switch' }, input, el('span', { class: 'track' }), el('span', { class: 'thumb' }));
const row = buildRow({ label: L.channelMemoryLabel, control: sw });
row.querySelector('.row-label').title = L.channelMemoryHint;
Expand All @@ -657,20 +661,26 @@ function renderChannelMemory() {
forget.addEventListener('click', () => {
if (!currentChannelId) return;
channelModes = common.forgetChannelMode({ [common.channelModesKey]: channelModes }, currentChannelId);
chrome.storage.local.set({ [common.channelModesKey]: channelModes });
common.setSettings({ [common.channelModesKey]: channelModes });
});

// The toggle + map roam (storage.sync); currentChannelId is this device's
// "channel of the tab last seen" and stays in storage.local.
const sync = d => {
channelMemoryOn = d[common.channelMemoryKey] == null ? common.defaultChannelMemory : !!d[common.channelMemoryKey];
input.checked = channelMemoryOn;
channelModes = d[common.channelModesKey] || {};
currentChannelId = d[common.currentChannelIdKey] || null;
updateChannelHint();
};
const keys = [common.channelMemoryKey, common.channelModesKey, common.currentChannelIdKey];
chrome.storage.local.get(keys, sync);
const roamKeys = [common.channelMemoryKey, common.channelModesKey];
const readAll = () => common.getSettings(roamKeys, roamed => {
chrome.storage.local.get([common.currentChannelIdKey], meta => sync({ ...roamed, ...meta }));
});
readAll();
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && keys.some(k => k in changes)) chrome.storage.local.get(keys, sync);
if (area !== 'sync' && area !== 'local') return;
if ([...roamKeys, common.currentChannelIdKey].some(k => k in changes)) readAll();
});
}

Expand All @@ -688,6 +698,10 @@ function updateChannelHint() {
// screen readers and hyphenation (popup.html can't hardcode one).
document.documentElement.lang = chrome.i18n.getUILanguage() || 'en';

// Belt-and-braces with the worker's boot: make sure existing users' data
// has moved to the roaming area before the first read or write from here.
await new Promise(res => common.ensureSettingsMigrated(res));

const data = await getStorage(common.storage);
state = common.resolveSettings(data);
renderStatic();
Expand All @@ -703,9 +717,10 @@ function updateChannelHint() {
refresh();

// Keep the UI in sync with changes made elsewhere while the popup is open
// (keyboard shortcut, the player's stall offer, another options window).
// (keyboard shortcut, the player's stall offer, another options window —
// and now another DEVICE, via the sync area).
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local' || !common.storage.some(k => k in changes)) return;
if ((area !== 'sync' && area !== 'local') || !common.storage.some(k => k in changes)) return;
getStorage(common.storage).then(d => {
state = common.resolveSettings(d);
refresh();
Expand Down
Loading