diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39d5e98..58006d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,15 @@ name: Run Tests on: - push: - branches: - - main - - develop pull_request: branches: - main - develop + workflow_dispatch: + +concurrency: + group: run-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true env: # CI is a deterministic release gate. Never let an inherited environment diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f073c6a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Repository instructions + +1. All tests must be implemented, fixed, and run in both Chrome and Firefox. Apply fixes made for one browser to the other while respecting each browser's behavior. +2. Always use `gh` first for GitHub interaction. +3. Do not require reviewers for new commits. +4. Run Chrome tests with elevated rights: sandboxed Playwright can cause system crashes. Tests the user runs in their console are not affected. diff --git a/CHANGELOG.md b/CHANGELOG.md index e85c114..aa933dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,34 @@ All notable changes to YT re:Watch will be documented in this file. - Added a dedicated IndexedDB cache for YouTube disclosure results, with long-lived positive and unlabeled results plus bounded retry backoff for unavailable or malformed responses. +- Added the installed extension version beside the YT re:Watch identity in the + popup and full-page feed, using the extension icon consistently in both. + +### Changed + +- Subscriptions now retains the selected upload-date or detection-date ordering + across **Reload view** and **Show**. Newly discovered records no longer jump + ahead of that selection, while the default newest-first view continues to use + canonical paginated order. +- Refined full-page view headers so the content title appears when the sidebar + is collapsed and is hidden when the expanded sidebar already supplies the + same label. The remaining description is larger and vertically aligned with + the view icon. +- Reduced redundant YouTube-card work by targeting thumbnail refreshes to the + changed video IDs, coalescing overlay startup, and reserving full focus-driven + rescans for actual overlay-presentation changes. +- Routed background, storage, and AI-label informational traces through the + saved debug setting while keeping warnings and errors visible. + +### Fixed + +- Fixed popup initialization and live updates so reopening it after a long-lived + YouTube tab does not overlap snapshots, rebuild the same content repeatedly, + or pulse while already-loaded data is being rendered. +- Fixed **Show** after a subscription scan so asynchronously loaded cards settle + in the selected order before the view scrolls to the newly available records. +- Treat removed, private, or otherwise incomplete YouTube AI-label responses as + indeterminate instead of caching them as valid unlabeled results. ### Privacy and reliability @@ -18,6 +46,9 @@ All notable changes to YT re:Watch will be documented in this file. YouTube lookups for previously unchecked cards near the viewport. - It reflects only YouTube's disclosure, not a general AI detector; an unmarked video is not confirmed non-AI. +- Live Chrome and Firefox canaries use a small curated disclosure set and a + bounded TimeLapse Lords channel-feed fallback to detect external fixture + removal without scanning an entire channel page. ## [5.1.0] - 2026-08-14 diff --git a/README.md b/README.md index 3030d14..7b734ad 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The full feed page contains: | View | Purpose | | --- | --- | | **Home** | A locally regenerated mix of cached videos using freshness, watch activity, and local feedback | -| **Subscriptions** | Cached uploads from locally followed channels in chronological order | +| **Subscriptions** | Cached uploads from locally followed channels, sortable by upload or detection time | | **Watch Later** | Locally saved videos, newest first | | **Shorts** | Known short-form videos and watched Shorts | | **Playlists** | Local playlists you manage, alongside saved YouTube playlist references that open on YouTube | @@ -47,7 +47,9 @@ The full feed page contains: | **Analytics** | Locally calculated watch statistics and insights | | **Settings** | Appearance, retention, feed timing, imports, backup/restore, and reset controls | -Home and Subscriptions render stable 50-card pages as you scroll. Checking for new videos is separate from reloading the current local view, so a background scan does not unexpectedly replace or reorder visible cards. When uploads are found, re:Watch offers a deliberate **Show** action. +Home and Subscriptions render stable 50-card pages as you scroll. Subscriptions retains its selected upload-date or detection-date ordering through **Reload view** and **Show**. Checking for new videos is separate from reloading the current local view, so a background scan does not unexpectedly replace or reorder visible cards. When uploads are found, re:Watch offers a deliberate **Show** action. + +The compact popup and full-page interface show the installed extension version beside the re:Watch icon, making it easier to identify the active build when reporting a problem. Watch Later is independent of YouTube's account playlist. Right-click a recognized video or Short and choose **Save to Watch Later (local)**, or use a re:Watch video menu; the full-page Watch Later view provides newest-first Open and Remove actions. diff --git a/docs/assets/guide/feed-analytics.png b/docs/assets/guide/feed-analytics.png index dfa5871..908d225 100644 Binary files a/docs/assets/guide/feed-analytics.png and b/docs/assets/guide/feed-analytics.png differ diff --git a/docs/assets/guide/feed-channels.png b/docs/assets/guide/feed-channels.png index 759bb28..9cbb899 100644 Binary files a/docs/assets/guide/feed-channels.png and b/docs/assets/guide/feed-channels.png differ diff --git a/docs/assets/guide/feed-history.png b/docs/assets/guide/feed-history.png index 3a1806a..955f78b 100644 Binary files a/docs/assets/guide/feed-history.png and b/docs/assets/guide/feed-history.png differ diff --git a/docs/assets/guide/feed-home.png b/docs/assets/guide/feed-home.png index af8e771..764e1a4 100644 Binary files a/docs/assets/guide/feed-home.png and b/docs/assets/guide/feed-home.png differ diff --git a/docs/assets/guide/feed-playlists.png b/docs/assets/guide/feed-playlists.png index 57e7976..f2fc6ed 100644 Binary files a/docs/assets/guide/feed-playlists.png and b/docs/assets/guide/feed-playlists.png differ diff --git a/docs/assets/guide/feed-settings.png b/docs/assets/guide/feed-settings.png index 7a0ceed..dd82432 100644 Binary files a/docs/assets/guide/feed-settings.png and b/docs/assets/guide/feed-settings.png differ diff --git a/docs/assets/guide/feed-shorts.png b/docs/assets/guide/feed-shorts.png index 124762a..3fc203e 100644 Binary files a/docs/assets/guide/feed-shorts.png and b/docs/assets/guide/feed-shorts.png differ diff --git a/docs/assets/guide/feed-subscriptions.png b/docs/assets/guide/feed-subscriptions.png index 5cb3519..eb0763e 100644 Binary files a/docs/assets/guide/feed-subscriptions.png and b/docs/assets/guide/feed-subscriptions.png differ diff --git a/docs/assets/guide/feed-watch-later.png b/docs/assets/guide/feed-watch-later.png index 71d6987..e8e21a7 100644 Binary files a/docs/assets/guide/feed-watch-later.png and b/docs/assets/guide/feed-watch-later.png differ diff --git a/docs/assets/guide/popup-continue-watching.png b/docs/assets/guide/popup-continue-watching.png index 038a058..cc76562 100644 Binary files a/docs/assets/guide/popup-continue-watching.png and b/docs/assets/guide/popup-continue-watching.png differ diff --git a/docs/changelog.md b/docs/changelog.md index f2b509f..557e831 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,10 +4,25 @@ - Added experimental **AI-labeled video handling** with Off, Badge, Dim, and Hide modes for videos YouTube itself discloses as made with AI. -- Added a dedicated IndexedDB cache, viewport-driven one-at-a-time lookups, - long cache lifetimes, and bounded retry backoff to keep request volume low. +- Added a dedicated IndexedDB cache, paced viewport-driven lookups with at most + two requests in flight, long cache lifetimes, and bounded retry backoff to + keep request volume low. - The setting explains that it makes direct YouTube requests when enabled and is not a general or fully reliable AI detector. +- Fixed Subscriptions so the chosen upload-date or detection-date order survives + **Reload view** and **Show**, without pinning newly discovered videos ahead of + the selected order. +- Fixed popup startup and live-update races that could make already-loaded + content pulse or render repeatedly after a YouTube tab had been open longer. +- Added the installed extension version and consistent re:Watch icon branding + to both the popup and full-page feed. +- Refined full-page headers to avoid duplicate nearby titles when the sidebar is + expanded, while keeping the current view title visible in collapsed mode. +- Reduced redundant overlay and thumbnail work by targeting changed video IDs, + coalescing startup scans, and limiting focus rescans to presentation changes. +- Made background, storage, and AI-label informational output respect debug mode. +- Treats removed or incomplete AI-label responses as indeterminate and uses + bounded live Chrome/Firefox canary fallbacks when an external fixture changes. ## v5.1.0 — 14 August 2026 diff --git a/docs/detailed_guide.md b/docs/detailed_guide.md index e631d12..de9d94e 100644 --- a/docs/detailed_guide.md +++ b/docs/detailed_guide.md @@ -58,15 +58,17 @@ Home uses stable 50-card pages. Moving between pages does not duplicate cards or ![The re:Watch Subscriptions tab showing recent cached uploads from locally followed channels.](assets/guide/feed-subscriptions.png) -*Subscriptions lists cached uploads in chronological order.* +*Subscriptions lists cached uploads in the selected upload-date or detection-date order.* -Subscriptions is the complete chronological inventory of regular cached uploads. It also uses stable 50-card pages. +Subscriptions is the complete inventory of regular cached uploads. Choose +upload-date or detection-date ordering; the selection remains active through +**Reload** and **Show**. The view also uses stable 50-card pages. ### Check, Reload, and Show - **Check** scans eligible followed channels and updates cached feed records. The status beside it reports progress, scheduling, and outcomes. - **Reload** renders current local storage again without starting a feed request. -- **Show** opens the chronological Subscriptions inventory after a scan or subscription-import handoff. +- **Show** reloads the complete Subscriptions inventory after a scan or subscription-import handoff and retains its selected ordering. The interface renders cached data before initialization work completes, so an existing feed remains usable during a scan. diff --git a/docs/faq.md b/docs/faq.md index 76bdc05..728529f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -52,13 +52,15 @@ Yes. Use the global playlist pause setting or the ignore toggle on a saved playl ### Where do Home and Subscriptions videos come from? -From public RSS uploads cached for channels followed in re:Watch. Home arranges that local inventory; Subscriptions shows it chronologically. +From public RSS uploads cached for channels followed in re:Watch. Home arranges +that local inventory; Subscriptions can order it by upload date or by when +re:Watch detected it. ### What is the difference between Check, Reload, and Show? - **Check** scans eligible channel feeds and updates local records. - **Reload** redraws from local storage without starting a scan. -- **Show** opens the full chronological Subscriptions inventory. +- **Show** reloads the full Subscriptions inventory while retaining its selected upload-date or detection-date order. ### Does feed search query YouTube? diff --git a/docs/index.md b/docs/index.md index 95414ef..1d6db29 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ YT re:Watch keeps a private, account-independent YouTube history in your browser | --- | --- | | History | Keeps watched videos, progress, duration, and resume points locally | | Home | Builds a mixed local feed from followed channels | -| Subscriptions | Shows a chronological inventory of cached subscription videos | +| Subscriptions | Shows cached subscription videos ordered by upload or detection time | | Shorts | Separates watched Shorts from ordinary history | | Watch Later | Keeps videos explicitly saved for later, independently of a YouTube account | | Channels | Manages local follows and reviewable ignored-channel records | @@ -44,7 +44,7 @@ The feed renders cached local records first. - **Check** scans eligible followed channels and updates cached feed records. - **Reload** rebuilds the visible view from local storage without starting a network scan. -- **Show** opens the complete chronological subscription inventory after a scan or import handoff. +- **Show** reloads the complete subscription inventory after a scan or import handoff while preserving the selected upload-date or detection-date order. Public YouTube RSS feeds provide new uploads. re:Watch does not use OAuth, mutate the user's YouTube subscriptions, or perform remote search. diff --git a/docs/technical.md b/docs/technical.md index 246d1be..e898d33 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -81,12 +81,16 @@ The interface distinguishes network work from presentation: - Check asks the scheduler to scan eligible channels. - Reload reconstructs projections from local storage. -- Show changes to the chronological inventory. +- Show reloads the complete subscription inventory without replacing its selected ordering. - Opening Home regenerates local ranking without initiating a Home-owned request. ## Views and pagination -`feed-view-data.js` creates canonical projections. Home ranks the ready inventory with freshness, affinity, impression, and channel-diversity signals. Subscriptions sorts regular feed records newest first. +`feed-view-data.js` creates canonical projections. Home ranks the ready inventory +with freshness, affinity, impression, and channel-diversity signals. +Subscriptions defaults to canonical newest-first pagination and can apply a +persisted upload-date or detection-date ordering without pinning newly +discovered records outside that selection. Home and Subscriptions use stable 50-record keyset pages. Page traversal must neither duplicate records nor allow a concurrent ordering change to corrupt the current boundary. diff --git a/helpers/screenshots/chrome_analytics.png b/helpers/screenshots/chrome_analytics.png index 126c424..03e34cf 100644 Binary files a/helpers/screenshots/chrome_analytics.png and b/helpers/screenshots/chrome_analytics.png differ diff --git a/helpers/screenshots/chrome_channels.png b/helpers/screenshots/chrome_channels.png index 1fcd9bb..8ecf894 100644 Binary files a/helpers/screenshots/chrome_channels.png and b/helpers/screenshots/chrome_channels.png differ diff --git a/helpers/screenshots/chrome_history.png b/helpers/screenshots/chrome_history.png index 63aa4e0..e9170d0 100644 Binary files a/helpers/screenshots/chrome_history.png and b/helpers/screenshots/chrome_history.png differ diff --git a/helpers/screenshots/chrome_home.png b/helpers/screenshots/chrome_home.png index 4d86f8c..343e9ce 100644 Binary files a/helpers/screenshots/chrome_home.png and b/helpers/screenshots/chrome_home.png differ diff --git a/helpers/screenshots/chrome_subscriptions.png b/helpers/screenshots/chrome_subscriptions.png index 633e10e..7671424 100644 Binary files a/helpers/screenshots/chrome_subscriptions.png and b/helpers/screenshots/chrome_subscriptions.png differ diff --git a/helpers/screenshots/firefox_analytics.png b/helpers/screenshots/firefox_analytics.png index a553dd7..74e922a 100644 Binary files a/helpers/screenshots/firefox_analytics.png and b/helpers/screenshots/firefox_analytics.png differ diff --git a/helpers/screenshots/firefox_channels.png b/helpers/screenshots/firefox_channels.png index 3a70be9..c0aeb08 100644 Binary files a/helpers/screenshots/firefox_channels.png and b/helpers/screenshots/firefox_channels.png differ diff --git a/helpers/screenshots/firefox_history.png b/helpers/screenshots/firefox_history.png index f23488b..b387533 100644 Binary files a/helpers/screenshots/firefox_history.png and b/helpers/screenshots/firefox_history.png differ diff --git a/helpers/screenshots/firefox_home.png b/helpers/screenshots/firefox_home.png index bd4fec5..7b38d32 100644 Binary files a/helpers/screenshots/firefox_home.png and b/helpers/screenshots/firefox_home.png differ diff --git a/helpers/screenshots/firefox_subscriptions.png b/helpers/screenshots/firefox_subscriptions.png index b4e152e..330b2dd 100644 Binary files a/helpers/screenshots/firefox_subscriptions.png and b/helpers/screenshots/firefox_subscriptions.png differ diff --git a/scripts/build-chrome-unpacked.sh b/scripts/build-chrome-unpacked.sh index 5c4e1ef..d0a4daf 100755 --- a/scripts/build-chrome-unpacked.sh +++ b/scripts/build-chrome-unpacked.sh @@ -25,4 +25,13 @@ cp "$ROOT"/src/*.js "$ROOT"/src/*.html "$ROOT/build/e2e/chrome/" cp "$ROOT/src/icon"*.png "$ROOT/build/e2e/chrome/" cp "$ROOT/src/manifest.chrome.json" "$ROOT/build/e2e/chrome/manifest.json" +# Enable production-owned diagnostic counters only in the generated E2E build. +node - "$ROOT/build/e2e/chrome/background.js" "$ROOT/build/e2e/chrome/content.js" <<'NODE' +const fs = require('node:fs'); +const marker = 'globalThis.__YTVHT_TEST__ = globalThis.__YTVHT_TEST__ || {};\n'; +for (const file of process.argv.slice(2)) { + fs.writeFileSync(file, marker + fs.readFileSync(file, 'utf8')); +} +NODE + echo "Unpacked Chrome E2E extension: $ROOT/build/e2e/chrome" diff --git a/scripts/build-firefox-unpacked.sh b/scripts/build-firefox-unpacked.sh index 0721308..049dc99 100755 --- a/scripts/build-firefox-unpacked.sh +++ b/scripts/build-firefox-unpacked.sh @@ -14,6 +14,15 @@ cp "$ROOT"/src/*.js "$ROOT"/src/*.html "$ROOT/build/e2e/firefox/" cp "$ROOT/src/icon"*.png "$ROOT/build/e2e/firefox/" cp "$ROOT/src/manifest.firefox.json" "$ROOT/build/e2e/firefox/manifest.json" +# Enable production-owned diagnostic counters only in the generated E2E build. +node - "$ROOT/build/e2e/firefox/background.js" "$ROOT/build/e2e/firefox/content.js" <<'NODE' +const fs = require('node:fs'); +const marker = 'globalThis.__YTVHT_TEST__ = globalThis.__YTVHT_TEST__ || {};\n'; +for (const file of process.argv.slice(2)) { + fs.writeFileSync(file, marker + fs.readFileSync(file, 'utf8')); +} +NODE + node - "$ROOT/build/e2e/firefox/manifest.json" <<'NODE' const fs = require('node:fs'); diff --git a/src/background.js b/src/background.js index ae5d840..b409673 100644 --- a/src/background.js +++ b/src/background.js @@ -1,4 +1,37 @@ -console.log('YouTube Video History Tracker background script running.'); +let backgroundDebugEnabled = false; + +function debugLog(...args) { + if (backgroundDebugEnabled) console.log(...args); +} + +function updateBackgroundDebugSetting(settings) { + backgroundDebugEnabled = settings?.debug === true; +} + +function recordTestMessage(message) { + const metrics = globalThis.__YTVHT_TEST__?.backgroundMetrics; + if (!metrics) return; + metrics.messageTypes = metrics.messageTypes || {}; + metrics.storageMethods = metrics.storageMethods || {}; + metrics.messageTypes[message.type] = (metrics.messageTypes[message.type] || 0) + 1; + if (message.type === 'ytStorageCall' && message.method) { + metrics.storageMethods[message.method] = (metrics.storageMethods[message.method] || 0) + 1; + } +} + +// Keep informational service-worker traces behind the user-facing debug +// setting. Warnings and errors remain unconditional below. +globalThis.ytvhtDebugLog = debugLog; +chrome.storage.local.get(['settings'], (result) => { + if (chrome.runtime.lastError) return; + updateBackgroundDebugSetting(result?.settings); + debugLog('YouTube Video History Tracker background script running.'); +}); +chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.settings) { + updateBackgroundDebugSetting(changes.settings.newValue); + } +}); // Load shared storage modules when this file runs as a Chrome MV3 service worker. if (typeof importScripts === 'function') { @@ -40,7 +73,8 @@ const stateManager = { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { (async () => { - console.log('Background script received message:', message.type, 'from sender:', sender.tab ? 'content' : 'popup'); + recordTestMessage(message); + debugLog('Background script received message:', message.type, 'from sender:', sender.tab ? 'content' : 'popup'); if (message.type === 'getAnnouncements') { if (!isLocalSubscriptionSender(sender) || !Number.isInteger(sender.tab?.id)) { diff --git a/src/content-ai-labels-page.js b/src/content-ai-labels-page.js index e30fe5b..136ebd7 100644 --- a/src/content-ai-labels-page.js +++ b/src/content-ai-labels-page.js @@ -21,8 +21,9 @@ const controller = new AbortController(); controllers.set(requestId, controller); + const debug = event.data.debug === true; try { - console.info('[YTVHT AI] Page-world lookup started', { videoId: event.data.payload?.videoId }); + if (debug) console.info('[YTVHT AI] Page-world lookup started', { videoId: event.data.payload?.videoId }); const response = await fetch('/youtubei/v1/next?prettyPrint=false&alt=json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -31,7 +32,7 @@ body: JSON.stringify(event.data.payload) }); const responseData = response.ok ? await response.json() : null; - console.info('[YTVHT AI] Page-world lookup finished', { + if (debug) console.info('[YTVHT AI] Page-world lookup finished', { videoId: event.data.payload?.videoId, httpStatus: response.status }); diff --git a/src/content-ai-labels.js b/src/content-ai-labels.js index c19eb18..2897558 100644 --- a/src/content-ai-labels.js +++ b/src/content-ai-labels.js @@ -33,6 +33,7 @@ let activeLookups = 0; let timer = null; let nextRequestAt = 0; + let debug = false; let pageBridgeReady = false; let pageBridgePromise = null; let nextRequestId = 0; @@ -42,11 +43,7 @@ const queuedIds = new Set(); function diagnostic(message, data) { - // This is intentionally visible while the experimental option is - // enabled. It gives users a way to distinguish a real YouTube - // response from an unchanged card when the external contract - // changes. - console.info('[YTVHT AI]', message, data || ''); + log(`[YTVHT AI] ${message}`, data); } function videoIdFor(card) { @@ -100,7 +97,11 @@ function parse(response) { const contents = response?.contents?.twoColumnWatchNextResults?.results?.results?.contents; if (!Array.isArray(contents)) return 'unknown'; - return contents.some((item) => item?.videoPrimaryInfoRenderer?.badges?.some((badge) => { + const primaryInfo = contents.find((item) => item?.videoPrimaryInfoRenderer)?.videoPrimaryInfoRenderer; + // Removed/private videos can return HTTP 200 with an item section + // but no video metadata. Do not cache that as a valid no-label result. + if (!primaryInfo) return 'unknown'; + return primaryInfo.badges?.some((badge) => { const renderer = badge?.metadataBadgeRenderer; // `label` is the stable signal observed in the response. The // accessibility text is a defensive fallback for a renderer @@ -108,7 +109,7 @@ return renderer?.label === 'AI' || /made with ai/i.test( renderer?.accessibilityData?.label || '' ); - })) ? 'ai' : 'unlabeled'; + }) ? 'ai' : 'unlabeled'; } function ensurePageBridge() { @@ -156,7 +157,7 @@ }; window.addEventListener('message', onMessage); signal?.addEventListener('abort', onAbort, { once: true }); - window.postMessage({ channel: PAGE_CHANNEL, type: 'lookup', requestId, token, payload }, location.origin); + window.postMessage({ channel: PAGE_CHANNEL, type: 'lookup', requestId, token, payload, debug }, location.origin); }); } @@ -257,8 +258,20 @@ } function start(settings) { + const nextMode = MODES.has(settings?.aiLabeledVideoHandling) + ? settings.aiLabeledVideoHandling + : 'off'; + const nextDebug = settings?.debug === true; + // Focus/settings refresh and general content initialization can + // converge on startup. Keep an in-flight scan instead of aborting + // it and caching the aborted request as an unknown result. + if (!stopped && nextMode === mode) { + debug = nextDebug; + return; + } stop(); - mode = MODES.has(settings?.aiLabeledVideoHandling) ? settings.aiLabeledVideoHandling : 'off'; + debug = nextDebug; + mode = nextMode; if (mode === 'off') return; diagnostic('Enabled', { mode }); stopped = false; @@ -302,11 +315,15 @@ const nextMode = MODES.has(settings?.aiLabeledVideoHandling) ? settings.aiLabeledVideoHandling : 'off'; + const nextDebug = settings?.debug === true; // A focus refresh reads the same setting most of the time. Do // not tear down visual state and make already-known AI cards // flash back to normal merely because the YouTube tab regained // focus. - if (!stopped && nextMode === mode) return; + if (!stopped && nextMode === mode) { + debug = nextDebug; + return; + } start(settings); } diff --git a/src/content-thumbnails.js b/src/content-thumbnails.js index b820ca3..338583d 100644 --- a/src/content-thumbnails.js +++ b/src/content-thumbnails.js @@ -520,6 +520,39 @@ }); } + function processThumbnailsForVideoIds(videoIds) { + const normalizedIds = Array.from(new Set((videoIds || []) + .map((videoId) => String(videoId || '').trim()) + .filter((videoId) => /^[A-Za-z0-9_-]+$/.test(videoId)))); + if (!normalizedIds.length) return; + + const cardSelector = [ + 'ytd-playlist-panel-video-renderer', + 'ytd-rich-item-renderer', + 'ytd-grid-video-renderer', + 'ytd-rich-grid-media', + 'ytd-compact-video-renderer', + 'ytd-compact-radio-renderer', + 'ytd-video-renderer', + 'yt-lockup-view-model' + ].join(', '); + const cards = new Set(); + + normalizedIds.forEach((videoId) => { + const selectors = [ + `[data-ytvht-video-id="${videoId}"]`, + `a[href*="/watch?v=${videoId}"]`, + `a[href*="/shorts/${videoId}"]` + ].join(', '); + document.querySelectorAll(selectors).forEach((node) => { + const card = node.closest(cardSelector) || node; + if (getVideoIdFromThumbnail(card) === videoId) cards.add(card); + }); + }); + + cards.forEach((card) => processVideoElement(card)); + } + function processVideoElement(element) { const currentSettings = getCurrentSettings(); if (!element || !element.isConnected) { @@ -671,6 +704,7 @@ return { thumbnailObserver, processExistingThumbnails, + processThumbnailsForVideoIds, processVideoElement, startRemovedElementCleanupObserver }; diff --git a/src/content.js b/src/content.js index ce11a6c..4f3bbe0 100644 --- a/src/content.js +++ b/src/content.js @@ -151,6 +151,8 @@ const pendingOperations = new Map(); const ENABLE_NATIVE_THUMBNAIL_OVERLAYS = true; let processExistingThumbnails = null; + let processThumbnailsForVideoIds = null; + let nativeThumbnailOverlaysStarted = false; let aiLabels = null; // Last video handled by the tracker; prevents duplicate SPA setup. @@ -333,6 +335,7 @@ if (!ENABLE_NATIVE_THUMBNAIL_OVERLAYS || !document.body || !thumbnailObserver || !processExistingThumbnails) { return; } + if (nativeThumbnailOverlaysStarted) return; try { thumbnailObserver.observe(document.body, { @@ -341,12 +344,16 @@ attributes: true, attributeFilter: ['src', 'href', 'data-visibility-tracking'] }); + nativeThumbnailOverlaysStarted = true; } catch (error) { - log('[Overlay] Thumbnail observer already active or failed to start', error); + log('[Overlay] Thumbnail observer failed to start', error); + return; } processExistingThumbnails(); - setTimeout(processExistingThumbnails, 2000); + setTimeout(() => { + processExistingThumbnails(); + }, 2000); } function clearNativeThumbnailOverlayArtifacts() { @@ -1972,6 +1979,7 @@ }); thumbnailObserver = thumbnailHelpers.thumbnailObserver; processExistingThumbnails = thumbnailHelpers.processExistingThumbnails; + processThumbnailsForVideoIds = thumbnailHelpers.processThumbnailsForVideoIds; thumbnailHelpers.startRemovedElementCleanupObserver(); startNativeThumbnailOverlays(); const aiLabelCache = { @@ -2039,16 +2047,24 @@ // than keeping the color that was loaded when the tab was first opened. // This also covers settings edits made in another extension surface. let overlaySettingsRefresh = null; + function overlayPresentationChanged(previous, next) { + return ['overlayTitle', 'overlayLabelSize', 'overlayColor', 'accentColor'] + .some((key) => previous?.[key] !== next?.[key]); + } + function refreshOverlaySettingsFromStorage() { if (overlaySettingsRefresh) return overlaySettingsRefresh; overlaySettingsRefresh = (async () => { + const previousSettings = currentSettings; const settings = await loadSettings(); currentSettings = settings; updateOverlayCSS( OVERLAY_LABEL_SIZE_MAP[settings.overlayLabelSize] || OVERLAY_LABEL_SIZE_MAP.medium, getAccentOverlayColor(settings) ); - if (ENABLE_NATIVE_THUMBNAIL_OVERLAYS) processExistingThumbnails?.(); + if (ENABLE_NATIVE_THUMBNAIL_OVERLAYS && overlayPresentationChanged(previousSettings, settings)) { + processExistingThumbnails?.(); + } aiLabels?.update(settings); })().catch((error) => { log('[Overlay] Could not refresh settings:', error); @@ -2198,30 +2214,22 @@ // Direct playlist URLs may not trigger the normal page-data event. ensurePlaylistIgnoreToggles(); - // Refresh extension-feed overlays when local history/playlists change. + // Refresh only cards affected by local history changes. A progress save + // occurs every five seconds while playing, so rescanning the whole YouTube + // grid here would multiply one write into hundreds of storage lookups. + const refreshChangedVideoThumbnails = (changes, area) => { + if (area !== 'local' || !ENABLE_NATIVE_THUMBNAIL_OVERLAYS || !processThumbnailsForVideoIds) return; + const videoIds = Object.keys(changes || {}) + .filter((key) => key.startsWith('video_')) + .map((key) => key.slice('video_'.length)) + .filter(Boolean); + if (videoIds.length) processThumbnailsForVideoIds(videoIds); + }; + if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) { - chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'local') { - const hasVideoChanges = Object.keys(changes).some(key => - key.startsWith('video_') || key.startsWith('playlist_') - ); - if (ENABLE_NATIVE_THUMBNAIL_OVERLAYS && hasVideoChanges) { - processExistingThumbnails(); - } - } - }); + chrome.storage.onChanged.addListener(refreshChangedVideoThumbnails); } else if (typeof browser !== 'undefined' && browser.storage && browser.storage.onChanged) { - browser.storage.onChanged.addListener((changes, area) => { - if (area === 'local') { - const hasVideoChanges = Object.keys(changes).some(key => - key.startsWith('video_') || key.startsWith('playlist_') - ); - if (ENABLE_NATIVE_THUMBNAIL_OVERLAYS && hasVideoChanges) { - // Use the improved processing function - processExistingThumbnails(); - } - } - }); + browser.storage.onChanged.addListener(refreshChangedVideoThumbnails); } // Expose internal navigation helpers for tests only. diff --git a/src/feed-home.js b/src/feed-home.js index fa2bb0b..299f551 100644 --- a/src/feed-home.js +++ b/src/feed-home.js @@ -17,7 +17,21 @@ function isShort(v) { // and Shorts shows only Shorts. let shortsOnly = false; let subscriptionsChronological = false; -let subscriptionSort = ytvhtSubscriptionSort.DEFAULT_SUBSCRIPTION_SORT; +const SUBSCRIPTION_SORT_STORAGE_KEY = 'ytvht.subscriptionSort.v1'; +const SUBSCRIPTION_SORT_VALUES = new Set(['published_desc', 'published_asc', 'discovered_desc']); +function normalizeSubscriptionSort(value) { + return SUBSCRIPTION_SORT_VALUES.has(value) + ? value + : ytvhtSubscriptionSort.DEFAULT_SUBSCRIPTION_SORT; +} +function loadSubscriptionSort() { + try { + return normalizeSubscriptionSort(localStorage.getItem(SUBSCRIPTION_SORT_STORAGE_KEY)); + } catch (_) { + return ytvhtSubscriptionSort.DEFAULT_SUBSCRIPTION_SORT; + } +} +let subscriptionSort = loadSubscriptionSort(); const VISIBLE_FEED_LIMIT = 300; const FEED_RENDER_PAGE_SIZE = typeof ytvhtFeedContracts !== 'undefined' ? Number(ytvhtFeedContracts.FEED_PAGE_SIZE || 50) @@ -454,14 +468,7 @@ function currentView() { rememberHomeRecommendations(homeList); return { list: homeList, q }; } - const sorted = sortList(list, sort); - if (subscriptionsChronological && newlyShownFeedVideoIds.length) { - const highlighted = new Set(newlyShownFeedVideoIds); - const newlyShown = sorted.filter((video) => highlighted.has(video.videoId)); - const remaining = sorted.filter((video) => !highlighted.has(video.videoId)); - return { list: newlyShown.concat(remaining).slice(0, VISIBLE_FEED_LIMIT), q }; - } - return { list: sorted.slice(0, VISIBLE_FEED_LIMIT), q }; + return { list: sortList(list, sort).slice(0, VISIBLE_FEED_LIMIT), q }; } // Query: YouTube-style search over feed + history. Match every word in any @@ -679,14 +686,6 @@ function subscriptionVideoPassesFilters(video) { return true; } -function subscriptionPinnedVideos() { - if (!newlyShownFeedVideoIds.length) return []; - const byId = new Map(allVideos.map((video) => [video.videoId, video])); - return newlyShownFeedVideoIds - .map((videoId) => byId.get(videoId)) - .filter(subscriptionVideoPassesFilters); -} - function appendBufferedSubscriptionVideos(state, grid, targetCount) { const next = state.videos.slice(state.renderedCount, targetCount); appendFeedCards(grid, next); @@ -752,13 +751,13 @@ async function fillSubscriptionPage(state, grid, empty, targetCount) { function startSubscriptionPagination(grid, empty) { const filtered = allVideos.filter(subscriptionVideoPassesFilters); const useInMemorySort = subscriptionSort !== 'published_desc'; - const pinned = useInMemorySort + const initialVideos = useInMemorySort ? ytvhtSubscriptionSort.sortSubscriptionVideos(filtered, subscriptionSort) - : subscriptionPinnedVideos(); + : []; const state = { generation: feedPaginationGeneration, - videos: pinned.slice(), - seenVideoIds: new Set(pinned.map((video) => video.videoId)), + videos: initialVideos.slice(), + seenVideoIds: new Set(initialVideos.map((video) => video.videoId)), cursor: null, exhausted: useInMemorySort, renderedCount: 0, @@ -793,6 +792,8 @@ function render() { const { list, q, metadataCandidates = [] } = subscriptionsBrowse ? { list: [], q: '' } : currentView(); + const titleText = heading?.closest('.feed-title-text'); + if (titleText) titleText.classList.toggle('nav-mirrored-title', !q); const visibleList = q ? list.slice(0, searchVisibleLimit) : list; document.body.classList.toggle('shorts-mode', shortsOnly && !q); grid.textContent = ''; diff --git a/src/feed-refresh.js b/src/feed-refresh.js index 1204b08..69d953b 100644 --- a/src/feed-refresh.js +++ b/src/feed-refresh.js @@ -105,10 +105,14 @@ function syncFeedStatusRow() { row.hidden = !status.textContent && subscriptionSortControl.hidden; } -function pendingFeedHiddenReasons(videoIds) { +function pendingFeedVisibility(videoIds) { const pendingIds = new Set(videoIds || []); const counts = new Map(); - const add = (label) => counts.set(label, (counts.get(label) || 0) + 1); + let hiddenCount = 0; + const add = (label) => { + hiddenCount += 1; + counts.set(label, (counts.get(label) || 0) + 1); + }; const isUnwatchedOnly = document.getElementById('unwatched')?.checked; const hidesMembers = document.getElementById('hideMembers')?.checked; const videos = (allVideos || []).filter((video) => pendingIds.has(video?.videoId)); @@ -125,7 +129,11 @@ function pendingFeedHiddenReasons(videoIds) { } }); - return Array.from(counts, ([label, count]) => `${label} (${feedFormatNumber(count)})`).join(', '); + return { + visibleCount: Math.max(0, videos.length - hiddenCount), + hiddenCount, + reasons: Array.from(counts, ([label, count]) => `${label} (${feedFormatNumber(count)})`).join(', ') + }; } function setRefreshUi(busy) { @@ -261,21 +269,43 @@ async function showNewFeedVideos(videoIds) { return pendingFeedDiscovery; } -function visiblePendingFeedVideoCount(videoIds) { - const pending = new Set(videoIds); - return Array.from(document.querySelectorAll('[data-ytvht-video-id]')) - .filter((card) => pending.has(card.dataset.ytvhtVideoId)).length; -} - function scrollToShownFeedVideos(videoIds) { const pending = new Set(videoIds); - const firstCard = Array.from(document.querySelectorAll('[data-ytvht-video-id]')) + const findCard = () => Array.from(document.querySelectorAll('[data-ytvht-video-id]')) .find((card) => pending.has(card.dataset.ytvhtVideoId)); - if (firstCard && typeof firstCard.scrollIntoView === 'function') { + const scrollToCard = () => { + const firstCard = findCard(); + if (!firstCard || typeof firstCard.scrollIntoView !== 'function') return false; firstCard.scrollIntoView({ block: 'start', behavior: 'smooth' }); - } else if (typeof window.scrollTo === 'function') { - window.scrollTo({ top: 0, behavior: 'smooth' }); + return true; + }; + if (scrollToCard()) return Promise.resolve(true); + + const grid = document.getElementById('grid'); + if (!grid || typeof MutationObserver === 'undefined') { + if (typeof window.scrollTo === 'function') window.scrollTo({ top: 0, behavior: 'smooth' }); + return Promise.resolve(false); } + + return new Promise((resolve) => { + let settled = false; + const finish = (found) => { + if (settled) return; + settled = true; + observer.disconnect(); + clearTimeout(timeout); + if (!found && typeof window.scrollTo === 'function') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + resolve(found); + }; + const observer = new MutationObserver(() => { + if (scrollToCard()) finish(true); + }); + const timeout = setTimeout(() => finish(false), 1500); + observer.observe(grid, { childList: true, subtree: true }); + if (scrollToCard()) finish(true); + }); } async function showPendingFeedVideos(onReady) { @@ -301,8 +331,7 @@ async function showPendingFeedVideos(onReady) { // entire inventory to detection time. if (typeof onReady === 'function') onReady(); else showFeed(); - const visibleCount = visiblePendingFeedVideoCount(videoIds); - const hiddenCount = Math.max(0, videoIds.length - visibleCount); + const { visibleCount, hiddenCount, reasons } = pendingFeedVisibility(videoIds); let message = feedPlural( 'feed_new_videos_shown', visibleCount, @@ -310,18 +339,16 @@ async function showPendingFeedVideos(onReady) { '$1 new videos shown.' ); if (hiddenCount) { - const reasons = pendingFeedHiddenReasons(videoIds) - || tFeed('feed_hidden_reason_unavailable', 'a saved visibility preference'); message += ` ${feedPlural( 'feed_new_videos_hidden_by_filters', hiddenCount, '$1 new video is hidden: $2.', '$1 new videos are hidden: $2.', - [reasons] + [reasons || tFeed('feed_hidden_reason_unavailable', 'a saved visibility preference')] )}`; } setStatus(message, false); - scrollToShownFeedVideos(videoIds); + await scrollToShownFeedVideos(videoIds); } catch (error) { // Pending discoveries created before a retention pass can include IDs // that were legitimately removed before the user clicks Show. Recover diff --git a/src/feed.html b/src/feed.html index 88c92f6..1145774 100644 --- a/src/feed.html +++ b/src/feed.html @@ -65,8 +65,9 @@ background: var(--bg); border-bottom: 1px solid var(--border); } - .brand { font-size: 18px; font-weight: 700; margin-right: 4px; white-space: nowrap; } + .brand { display: inline-flex; align-items: center; font-size: 18px; font-weight: 700; margin-right: 4px; white-space: nowrap; } .brand small { font-weight: 400; color: var(--text-2); font-size: 13px; margin-left: 6px; } + .brand .brand-version { font-size: 11px; opacity: 0.8; } .spacer { flex: 1 1 auto; } .count { font-size: 13px; color: var(--text-2); white-space: nowrap; } /* Group related controls so the bar reads as: brand · search · filters …… count · actions */ @@ -137,6 +138,13 @@ .feed-title-icon svg { width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .feed-title-text h2 { margin: 0; font-size: 24px; line-height: 1.15; } .feed-title-text p { margin: 4px 0 0; color: var(--text-2); font-size: 13px; line-height: 1.35; } + body:not(.sidebar-collapsed) .feed-title-text.nav-mirrored-title { align-self: center; } + body:not(.sidebar-collapsed) .feed-title-text.nav-mirrored-title h2 { display: none; } + body:not(.sidebar-collapsed) .feed-title-text.nav-mirrored-title p { + margin-top: 0; + font-size: 15px; + line-height: 1.45; + } .feed-title-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 10px; min-height: 32px; } .feed-title-actions .count, .feed-title-actions .subs-count, .feed-title-actions .playlists-count, .feed-title-actions .history-count { white-space: nowrap; } .status-row { display: contents; } @@ -1530,7 +1538,7 @@ } .icon-btn:hover { background: var(--chip); } .icon-btn span { display: block; width: 18px; height: 2px; background: var(--text); border-radius: 2px; } - .brand-logo { color: var(--accent); margin-right: 2px; } + .brand-logo { width: 26px; height: 26px; margin-right: 6px; object-fit: contain; } .mast-search { min-width: 0; display: flex; justify-content: center; } .mast-search input[type="search"] { width: 100%; max-width: 560px; } .mast-right { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; } @@ -1632,7 +1640,7 @@
- YT re:Watch + YT re:Watch
-