diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a450f..d2fbcf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to YT re:Watch will be documented in this file. +## [5.0.1] - 2026-08-11 + +### Fixed + +- Rebuilt the compact Continue Watching popup from an unfinished-only, timestamp-sorted projection. Live updates can no longer apply an unfiltered history index to a different visible row, so titles, thumbnails, progress, ordering, and completion removal remain consistent. +- Deferred regular-video saves briefly during YouTube single-page navigation until the route, watch page, and player agree on the active video ID. This prevents a new record from inheriting a previous video's title or channel metadata. +- Unified the legacy `overlayColor` and v5 `accentColor` settings. Existing color choices are retained, and popup/feed/content-script updates now use one synchronized value. +- Applied the saved accent when the full feed opens and made the canonical settings color override stale popup-only cache entries. +- Kept the YouTube-page dynamic overlay stylesheet after its reinjected base rules, so saved accent colors now apply to Viewed badges and progress lines instead of falling back to blue. +- Restored lazy page navigation in the popup's Continue Watching list. Its page count now derives from the same unfinished-only, timestamp-sorted records it displays. +- Pending-new-video notices now exclude discoveries removed by feed retention or no longer eligible through an active local subscription, so **Show** never promises inventory that cannot be displayed. +- Watch Later saves now recover title and channel metadata through YouTube oEmbed when the current page has not mounted a matching video card. Existing incomplete Watch Later records are repaired when the list opens. + +### Added + +- Added Watch Later to the full re:Watch page, with newest-first records, Open and Remove actions, live updates, and localized empty/count states. + ## [5.0.0] - 2026-08-09 Version 5 turns YT re:Watch from a popup-centered history tracker into a full local YouTube companion while preserving its local-first privacy boundary. The release adds explicit local channel subscriptions, an RSS-backed feed, channel and history management, richer analytics, durable import/backup contracts, and matching Chromium/Firefox coverage. diff --git a/README.md b/README.md index 98e72bb..7e86c34 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The full feed page contains: | --- | --- | | **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 | +| **Watch Later** | Locally saved videos, newest first | | **Shorts** | Known short-form videos and watched Shorts | | **Playlists** | Saved YouTube playlist references that open on YouTube | | **History** | Locally recorded videos, progress, duration, and watched time | @@ -47,6 +48,8 @@ The full feed page contains: 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. +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. + ## Local subscriptions and feed re:Watch subscriptions are independent from YouTube account subscriptions. @@ -85,6 +88,7 @@ Direct YouTube requests used by the local feed: - public channel RSS checks; - public channel or handle pages when identity or presentation metadata is needed; - YouTube-hosted thumbnails, avatars, and banners displayed by the UI. +- YouTube oEmbed metadata only when a locally saved Watch Later item is missing its title or channel. Feed metadata requests omit browser credentials. Local search does not contact YouTube. The extension does not subscribe, unsubscribe, like, comment, or modify a YouTube account. diff --git a/build.sh b/build.sh index 4eaa512..175bc9e 100755 --- a/build.sh +++ b/build.sh @@ -77,6 +77,7 @@ copy_common_files() { "$PROJECT_ROOT/src/feed-playlist-import.js" \ "$PROJECT_ROOT/src/feed-playlists-view.js" \ "$PROJECT_ROOT/src/feed-history-view.js" \ + "$PROJECT_ROOT/src/feed-watchlater-view.js" \ "$PROJECT_ROOT/src/feed-settings.js" \ "$PROJECT_ROOT/src/feed-localization.js" \ "$PROJECT_ROOT/src/feed-backup.js" \ diff --git a/docs/assets/guide/feed-watch-later.png b/docs/assets/guide/feed-watch-later.png new file mode 100644 index 0000000..a3aa424 Binary files /dev/null and b/docs/assets/guide/feed-watch-later.png differ diff --git a/docs/changelog.md b/docs/changelog.md index 0fccfff..6291915 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,12 @@ # Changelog +## v5.0.1 — 11 August 2026 + +- Fixed Continue Watching to use one unfinished-only, timestamp-sorted projection for its rows and popup pagination; live updates no longer mix visible rows with unfiltered history indexes. +- Restored the saved appearance color across the popup, full re:Watch page, and existing YouTube tabs, including Viewed badges and progress bars. +- Added the full-page **Watch Later** view, including newest-first ordering, open/remove actions, localization, and automatic repair of incomplete saved title/channel metadata. +- Made **Show** advertise only subscription discoveries that remain in local inventory after retention and active-subscription filtering. + ## v5.0.0 — 9 August 2026 v5 turns re:Watch from a local progress tracker into a complete local browsing companion while keeping its account-independent storage model. diff --git a/docs/detailed_guide.md b/docs/detailed_guide.md index 08551c7..5015491 100644 --- a/docs/detailed_guide.md +++ b/docs/detailed_guide.md @@ -27,6 +27,7 @@ The toolbar popup is a compact resume surface. The full-page interface contains | Home | Locally arranged videos from followed channels | | Subscriptions | Complete chronological cached subscription inventory | | Shorts | Locally watched YouTube Shorts | +| Watch Later | Videos explicitly saved for later in this browser profile | | Playlists | References that open the original YouTube playlist | | History | Ordinary watched videos and progress | | Channels | Local follows and, when present, ignored import records | @@ -35,10 +36,9 @@ The toolbar popup is a compact resume surface. The full-page interface contains ### Popup { #popup } -
- ![The re:Watch popup showing unfinished videos with progress bars and an Open Feed button.](assets/guide/popup-continue-watching.png){ width="600" height="480" } -
Resume unfinished videos or open the full re:Watch interface.
-
+![The re:Watch popup showing unfinished videos with progress bars and an Open Feed button.](assets/guide/popup-continue-watching.png) + +*Resume unfinished videos or open the full re:Watch interface.* The popup also reports subscription-import results. If an import skipped previously removed channels, its action opens **Channels → Ignored** so you can review them. @@ -46,10 +46,9 @@ The popup also reports subscription-import results. If an import skipped previou ### Home { #home } -
- ![The re:Watch Home feed displaying locally personalized video cards.](assets/guide/feed-home.png){ width="1440" height="960" loading="lazy" } -
Home arranges cached uploads using local viewing and feedback signals.
-
+![The re:Watch Home feed displaying locally personalized video cards.](assets/guide/feed-home.png) + +*Home arranges cached uploads using local viewing and feedback signals.* Home is not a copy of the YouTube recommendation service. It ranks locally cached uploads from channels followed in re:Watch. Opening Home regenerates that local view and does not start a Home-owned network request. @@ -57,10 +56,9 @@ Home uses stable 50-card pages. Moving between pages does not duplicate cards or ### Subscriptions { #subscriptions } -
- ![The re:Watch Subscriptions tab showing recent cached uploads from locally followed channels.](assets/guide/feed-subscriptions.png){ width="1440" height="960" loading="lazy" } -
Subscriptions lists cached uploads in chronological order.
-
+![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 is the complete chronological inventory of regular cached uploads. It also uses stable 50-card pages. @@ -84,10 +82,9 @@ Video menus provide local actions such as more or less from a channel, hide, sub ### History { #history } -
- ![The re:Watch History tab listing locally stored viewing activity and progress.](assets/guide/feed-history.png){ width="1440" height="960" loading="lazy" } -
History keeps ordinary viewing activity and resume progress local.
-
+![The re:Watch History tab listing locally stored viewing activity and progress.](assets/guide/feed-history.png) + +*History keeps ordinary viewing activity and resume progress local.* History cards show the available channel, progress, and video duration. When metadata or progress changes, re:Watch updates the video-specific portion of the card while preserving its **Remove** control and three-dot menu. @@ -95,19 +92,17 @@ Removing a history record also creates a temporary deletion marker so an older a ### Shorts { #shorts } -
- ![The re:Watch Shorts tab showing watched short-form videos.](assets/guide/feed-shorts.png){ width="1440" height="960" loading="lazy" } -
Shorts separates locally watched short-form videos from ordinary history.
-
+![The re:Watch Shorts tab showing watched short-form videos.](assets/guide/feed-shorts.png) + +*Shorts separates locally watched short-form videos from ordinary history.* re:Watch follows Shorts navigation inside YouTube's single-page interface and records the active Short's video and channel identity. The Shorts tab updates from local history without requiring a feed reload. ## Playlist references { #playlists } -
- ![The re:Watch Playlists tab showing saved references that open their playlists on YouTube.](assets/guide/feed-playlists.png){ width="1440" height="960" loading="lazy" } -
A saved reference opens its original playlist on YouTube.
-
+![The re:Watch Playlists tab showing saved references that open their playlists on YouTube.](assets/guide/feed-playlists.png) + +*A saved reference opens its original playlist on YouTube.* v5 stores YouTube playlists as references with available title, artwork, and source link. It does not fetch the playlist's member videos in the background. @@ -115,12 +110,21 @@ The per-playlist **Ignore videos** toggle prevents history saves while viewing t Creating and managing extension-owned local playlists is planned separately and is not part of stable v5. +## Watch Later { #watch-later } + +![The re:Watch Watch Later tab showing locally saved videos.](assets/guide/feed-watch-later.png) + +*Watch Later keeps explicitly saved videos in a local, newest-first list.* + +Watch Later is a small local list, separate from YouTube's account-based Watch Later playlist. Save a recognized video or Short by right-clicking it on YouTube and choosing **Save to Watch Later (local)**, or choose **Save to Watch Later** from a re:Watch card menu. + +The **Watch Later** sidebar view orders saved videos newest first. Each row opens the original YouTube video and has a **Remove** action. re:Watch stores the video ID, link, title, and channel locally; if a page did not expose title or channel data when it was saved, the list repairs that metadata when it is next opened. + ## Channels { #channels } -
- ![The re:Watch Channels tab showing channels followed locally by the extension.](assets/guide/feed-channels.png){ width="1440" height="960" loading="lazy" } -
Channels manages follows owned by re:Watch, not by the YouTube account.
-
+![The re:Watch Channels tab showing channels followed locally by the extension.](assets/guide/feed-channels.png) + +*Channels manages follows owned by re:Watch, not by the YouTube account.* Follow a channel from a supported YouTube channel/watch surface, a re:Watch video menu, or the Channels controls. You can also import subscriptions in Settings. re:Watch canonicalizes channel IDs and avoids duplicate follows. @@ -137,10 +141,9 @@ When no tombstones exist, the Ignored tab is hidden instead of showing an empty ## Analytics { #analytics } -
- ![The re:Watch Analytics tab summarizing locally calculated viewing activity.](assets/guide/feed-analytics.png){ width="1440" height="960" loading="lazy" } -
Analytics derives viewing patterns from local extension data.
-
+![The re:Watch Analytics tab summarizing locally calculated viewing activity.](assets/guide/feed-analytics.png) + +*Analytics derives viewing patterns from local extension data.* Analytics includes total watch time, videos and Shorts watched, average duration, completion rate, saved playlist references, weekly activity, and watch time by hour. Hourly bars share a consistent baseline so selected or low-activity hours remain visually comparable. @@ -148,10 +151,9 @@ Additional insights include unfinished videos and skipped, watched, and completi ## Settings { #settings } -
- ![The re:Watch Settings tab showing appearance, feed, import, backup, and data controls.](assets/guide/feed-settings.png){ width="1440" height="960" loading="lazy" } -
Settings contains appearance, import, backup, and data-management controls.
-
+![The re:Watch Settings tab showing appearance, feed, import, backup, and data controls.](assets/guide/feed-settings.png) + +*Settings contains appearance, import, backup, and data-management controls.* ### Appearance and overlays diff --git a/docs/index.md b/docs/index.md index b217d5a..70911a8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,7 @@ YT re:Watch keeps a private, account-independent YouTube history in your browser | Home | Builds a mixed local feed from followed channels | | Subscriptions | Shows a chronological inventory of cached subscription videos | | 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 | | Analytics | Summarizes local activity, watch time, completion, and channel patterns | | Playlist references | Saves links to YouTube playlists without importing their members | @@ -26,7 +27,7 @@ The feed's Home and Subscriptions views use stable 50-card pages. Search is loca 2. Watch a video. re:Watch records meaningful playback and updates the local resume point. 3. Follow channels from supported YouTube surfaces, add them in **Channels**, or import a subscriptions file in **Settings**. -Click the extension button for quick actions. Open the full re:Watch page for Home, Subscriptions, Shorts, playlist references, History, Channels, Analytics, and Settings. +Click the extension button for quick actions. Open the full re:Watch page for Home, Subscriptions, Shorts, Watch Later, playlist references, History, Channels, Analytics, and Settings. ## Feed refresh controls @@ -48,6 +49,10 @@ When an imported channel was previously removed, re:Watch keeps a tombstone so l v5 saves YouTube playlists as outbound references: title, available metadata, and the original YouTube link. It does not hydrate playlist members in the background and does not yet provide extension-managed local playlists. +## Watch Later + +Right-click a recognized YouTube video or Short and choose **Save to Watch Later (local)**, or use **Save to Watch Later** from a re:Watch video menu. The **Watch Later** sidebar view lists these local saves newest first and provides Open and Remove actions. This list is independent of YouTube's account-based Watch Later playlist. + ## Privacy boundaries History, progress, settings, local subscriptions, feed state, and analytics remain in extension storage in the current browser profile. re:Watch has no account system or cloud-sync service. diff --git a/helpers/chrome-marketplace-v5.txt b/helpers/chrome-marketplace-v5.txt index 883883e..cffeca0 100644 --- a/helpers/chrome-marketplace-v5.txt +++ b/helpers/chrome-marketplace-v5.txt @@ -58,6 +58,14 @@ Version 5 introduces the full local feed experience: local channel subscriptions It also improves SPA resume and Shorts identity tracking, keeps History card actions stable during metadata updates, shows available video durations, and makes feed scan, reload, and chronological-inventory actions explicit. +PROMOTIONAL ASSET + +chrome_small_promo_tile.png +Small promo tile: 440 x 280 px. + +chrome_marquee_promo_tile.png +Marquee promo tile: 1400 x 560 px. + SCREENSHOT UPLOAD ORDER AND CAPTIONS 1. chrome_home.png @@ -83,6 +91,8 @@ Data handling: Extension feature data is stored locally in the browser profile. Host access: Required for YouTube content-script integration and public channel feed/metadata retrieval. Direct feed and metadata requests use credentials omitted. +Context menus: Required to add user-invoked re:Watch actions to YouTube's right-click menu. A user can explicitly save the selected YouTube video to the extension's local Watch Later list or locally follow or unfollow the selected channel. These actions run only when the user chooses the corresponding re:Watch menu item. + Remote code: None. Authentication: No OAuth or re:Watch account in version 5. diff --git a/package-lock.json b/package-lock.json index 91ec600..64d0c54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "youtube-local-history", - "version": "5.0.0", + "version": "5.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "youtube-local-history", - "version": "5.0.0", + "version": "5.0.1", "license": "MIT", "devDependencies": { "@babel/core": "^7.24.0", diff --git a/package.json b/package.json index b5b3c43..9056cb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "youtube-local-history", - "version": "5.0.0", + "version": "5.0.1", "description": "Private local YouTube history, progress tracking, channel subscriptions, analytics, and an in-browser feed.", "main": "src/content.js", "scripts": { diff --git a/scripts/docs-screenshot-catalog.json b/scripts/docs-screenshot-catalog.json index ab33f24..3a29c96 100644 --- a/scripts/docs-screenshot-catalog.json +++ b/scripts/docs-screenshot-catalog.json @@ -92,6 +92,20 @@ "alt": "The re:Watch History tab listing locally stored viewing activity and progress.", "caption": "History provides a dedicated view of locally recorded viewing activity." }, + { + "id": "feed-watch-later", + "page": "feed.html", + "view": "feed-watch-later", + "output": "docs/assets/guide/feed-watch-later.png", + "guidePage": "detailed_guide.md", + "guideAnchor": "watch-later", + "viewport": { "width": 1440, "height": 960 }, + "capture": { "type": "viewport" }, + "readySelector": "#watchLaterList .history-row", + "display": { "width": 1440, "height": 960 }, + "alt": "The re:Watch Watch Later tab showing locally saved videos.", + "caption": "Watch Later keeps explicitly saved videos in a local, newest-first list." + }, { "id": "feed-channels", "page": "feed.html", diff --git a/scripts/generate-docs-screenshots.js b/scripts/generate-docs-screenshots.js index 9feb21d..9e195c4 100644 --- a/scripts/generate-docs-screenshots.js +++ b/scripts/generate-docs-screenshots.js @@ -269,6 +269,7 @@ async function activateView(page, entry) { 'feed-shorts': '#navShorts', 'feed-playlists': '#navPlaylists', 'feed-history': '#navHistory', + 'feed-watch-later': '#navWatchLater', 'feed-channels': '#manage', 'feed-analytics': '#analyticsToggle', 'feed-settings': '#navSettings', diff --git a/scripts/generate-marketplace-screenshots.js b/scripts/generate-marketplace-screenshots.js index 95f5608..6e17e62 100644 --- a/scripts/generate-marketplace-screenshots.js +++ b/scripts/generate-marketplace-screenshots.js @@ -134,15 +134,18 @@ async function seedInExtensionPage(pageEvaluate, fixture) { }); } -function assertPng(file) { +function assertPng(file, expectedWidth = WIDTH, expectedHeight = HEIGHT, requireOpaque = false) { const data = fs.readFileSync(file); if (data.length < 24 || data.toString('ascii', 1, 4) !== 'PNG') { throw new Error(`Screenshot is not a PNG: ${file}`); } const width = data.readUInt32BE(16); const height = data.readUInt32BE(20); - if (width !== WIDTH || height !== HEIGHT) { - throw new Error(`${path.basename(file)} is ${width}x${height}; expected ${WIDTH}x${HEIGHT}`); + if (width !== expectedWidth || height !== expectedHeight) { + throw new Error(`${path.basename(file)} is ${width}x${height}; expected ${expectedWidth}x${expectedHeight}`); + } + if (requireOpaque && (data.readUInt8(24) !== 8 || data.readUInt8(25) !== 2)) { + throw new Error(`${path.basename(file)} must be an opaque 24-bit PNG`); } } @@ -159,6 +162,110 @@ async function waitForChromeImages(page) { .every((image) => image.complete && image.naturalWidth > 0), null, { timeout: 10000 }); } +function promoDestination(name) { + const file = path.join(OUTPUT_DIR, `chrome_${name}.png`); + const expectedRoot = `${OUTPUT_DIR}${path.sep}`; + if (!file.startsWith(expectedRoot)) throw new Error('Unsafe Chrome promotional asset destination'); + return file; +} + +function promoDocument(body, styles) { + return ` + + + + + +${body} +`; +} + +async function renderChromePromo(context, { file, width, height, document }) { + const page = await context.newPage(); + try { + await page.setViewportSize({ width, height }); + await page.setContent(document, { waitUntil: 'load' }); + await page.evaluate(async () => document.fonts.ready); + await waitForChromeImages(page); + await page.screenshot({ path: file, animations: 'disabled', caret: 'hide', scale: 'css' }); + assertPng(file, width, height, true); + console.log(`Generated ${path.relative(ROOT, file)}`); + } finally { + await page.close(); + } +} + +async function generateChromePromoAssets(context) { + const smallFile = promoDestination('small_promo_tile'); + const smallDocument = promoDocument(` +
+
+
YT re:Watch
+
+

Your YouTube history.
Kept locally.

+

History  •  Progress  •  Local feed

+
+ +
`, ` + .small-tile { position: relative; width: 440px; height: 280px; padding: 30px 36px; background: linear-gradient(135deg, #0f0f0f 0%, #142433 100%); } + .orb { position: absolute; border-radius: 999px; background: #3ea6ff; } + .orb-one { width: 224px; height: 224px; right: -77px; top: -124px; opacity: .10; } + .orb-two { width: 188px; height: 188px; left: -64px; bottom: -137px; opacity: .10; } + header { position: relative; display: flex; align-items: center; gap: 8px; font-size: 23px; } + .play { width: 0; height: 0; border-top: 11px solid transparent; border-bottom: 11px solid transparent; border-left: 20px solid #3ea6ff; } + .small-message { position: relative; margin-top: 35px; padding: 20px 24px 18px 29px; border: 1px solid #313131; border-radius: 18px; background: #181818; box-shadow: 0 8px 24px rgba(0, 0, 0, .32); } + .small-message::before { content: ''; position: absolute; left: -1px; top: 0; width: 7px; height: 100%; border-radius: 4px; background: linear-gradient(#2563eb, #3ea6ff); } + h1 { margin: 0; font-size: 25px; line-height: 1.32; letter-spacing: -.4px; } + .small-message p { margin: 13px 0 0; color: #a9b3bd; font-size: 14px; } + footer { position: relative; display: flex; align-items: center; gap: 9px; margin-top: 20px; color: #c8d1da; font-size: 13px; } + footer span { width: 10px; height: 10px; border-radius: 50%; background: #3ea6ff; } + `); + await renderChromePromo(context, { file: smallFile, width: 440, height: 280, document: smallDocument }); + + const homeFile = destination('chrome', 'home'); + const homeScreenshot = `data:image/png;base64,${fs.readFileSync(homeFile).toString('base64')}`; + const marqueeFile = promoDestination('marquee_promo_tile'); + const marqueeDocument = promoDocument(` +
+
+
+
YT re:Watch
+

Your YouTube history.
Kept locally.

+

Follow channels, resume videos, and browse your own local feed—independent of your YouTube account.

+
Local historyWatch progressPrivate feed
+
+
+
YT re:Watch
+ YT re:Watch Home feed +
+
`, ` + .marquee { position: relative; width: 1400px; height: 560px; overflow: hidden; background: linear-gradient(125deg, #0b0f13 0%, #101b25 56%, #142b3d 100%); } + .glow { position: absolute; border-radius: 50%; filter: blur(2px); } + .glow-blue { width: 560px; height: 560px; left: -310px; bottom: -365px; background: rgba(62, 166, 255, .15); } + .glow-green { width: 460px; height: 460px; right: -190px; top: -275px; background: rgba(48, 190, 143, .12); } + .pitch { position: absolute; z-index: 2; left: 70px; top: 54px; width: 535px; } + header { display: flex; align-items: center; gap: 13px; font-size: 31px; } + .play { width: 0; height: 0; border-top: 15px solid transparent; border-bottom: 15px solid transparent; border-left: 27px solid #3ea6ff; } + h1 { margin: 65px 0 23px; font-size: 52px; line-height: 1.08; letter-spacing: -1.7px; } + h1 em { color: #55b3ff; font-style: normal; } + .pitch > p { width: 505px; margin: 0; color: #c3ccd5; font-size: 20px; line-height: 1.45; } + .features { display: flex; gap: 10px; margin-top: 31px; } + .features span { padding: 9px 13px; border: 1px solid #38516a; border-radius: 999px; background: rgba(31, 55, 75, .72); color: #d9e7f3; font-size: 14px; font-weight: 700; } + .product-frame { position: absolute; z-index: 1; left: 650px; top: 52px; width: 820px; height: 512px; overflow: hidden; border: 1px solid #3b4c59; border-radius: 24px 0 0 0; background: #0f0f0f; box-shadow: 0 28px 70px rgba(0, 0, 0, .5); transform: rotate(-1deg); transform-origin: center; } + .frame-bar { display: flex; align-items: center; gap: 8px; height: 35px; padding: 0 15px; background: #20262c; border-bottom: 1px solid #303942; } + .frame-bar i { width: 9px; height: 9px; border-radius: 50%; background: #56616b; } + .frame-bar i:first-child { background: #3ea6ff; } + .frame-bar b { margin-left: 9px; color: #aeb8c1; font-size: 12px; font-weight: 400; } + .product-frame img { display: block; width: 820px; height: 512px; object-fit: cover; object-position: left top; } + `); + await renderChromePromo(context, { file: marqueeFile, width: 1400, height: 560, document: marqueeDocument }); +} + async function getChromeExtensionOrigin(context) { const worker = context.serviceWorkers().find((candidate) => candidate.url().includes('background.js')) || await context.waitForEvent('serviceworker', { @@ -247,6 +354,7 @@ async function generateChrome(fixture, headed) { await page.close(); console.log(`Generated ${path.relative(ROOT, file)}`); } + await generateChromePromoAssets(context); if (unexpectedRequests.length) { throw new Error(`Unexpected Chrome requests were blocked:\n${[...new Set(unexpectedRequests)].join('\n')}`); } diff --git a/src/_locales/bg/feed-dynamic.json b/src/_locales/bg/feed-dynamic.json index 06f0706..a8409b0 100644 --- a/src/_locales/bg/feed-dynamic.json +++ b/src/_locales/bg/feed-dynamic.json @@ -141,5 +141,8 @@ "feed_views_other": { "message": "$1 гледания" }, "feed_watched_on": { "message": "Гледано на $1" }, "feed_watched_on_percent": { "message": "Гледано на $1 · $2 изгледано" }, - "feed_watched_relative": { "message": "Гледано $1" } + "feed_watched_relative": { "message": "Гледано $1" }, + "feed_watch_later_added": { "message": "Запазено $1" }, + "feed_watch_later_entries_one": { "message": "$1 запазено видео" }, + "feed_watch_later_entries_other": { "message": "$1 запазени видеа" } } diff --git a/src/_locales/bg/feed.json b/src/_locales/bg/feed.json index 63a997f..ca0e026 100644 --- a/src/_locales/bg/feed.json +++ b/src/_locales/bg/feed.json @@ -16,6 +16,9 @@ "feed_nav_playlists_title": { "message": "Преглед на запазените плейлисти" }, "feed_nav_history": { "message": "История" }, "feed_nav_history_title": { "message": "Преглед на локалната история на гледане" }, + "feed_nav_watch_later": { "message": "За гледане по-късно" }, + "feed_nav_watch_later_title": { "message": "Преглед на видеата, запазени за гледане по-късно" }, + "feed_watch_later_empty": { "message": "Няма видеа, запазени за гледане по-късно." }, "feed_nav_channels": { "message": "Канали" }, "feed_nav_channels_title": { "message": "Управление на локално следваните канали" }, "feed_nav_analytics_title": { "message": "Преглед на анализа на гледането" }, diff --git a/src/_locales/de/feed-dynamic.json b/src/_locales/de/feed-dynamic.json index aa8976f..9b54cd4 100644 --- a/src/_locales/de/feed-dynamic.json +++ b/src/_locales/de/feed-dynamic.json @@ -141,5 +141,8 @@ "feed_views_other": { "message": "$1 Aufrufe" }, "feed_watched_on": { "message": "Angesehen am $1" }, "feed_watched_on_percent": { "message": "Angesehen am $1 · $2 angesehen" }, - "feed_watched_relative": { "message": "Angesehen $1" } + "feed_watched_relative": { "message": "Angesehen $1" }, + "feed_watch_later_added": { "message": "Gespeichert $1" }, + "feed_watch_later_entries_one": { "message": "$1 gespeichertes Video" }, + "feed_watch_later_entries_other": { "message": "$1 gespeicherte Videos" } } diff --git a/src/_locales/de/feed.json b/src/_locales/de/feed.json index 3a971af..5bafe91 100644 --- a/src/_locales/de/feed.json +++ b/src/_locales/de/feed.json @@ -67,6 +67,18 @@ "message": "Deinen lokalen Wiedergabeverlauf anzeigen", "description": "History navigation tooltip" }, + "feed_nav_watch_later": { + "message": "Später ansehen", + "description": "Watch Later navigation label" + }, + "feed_nav_watch_later_title": { + "message": "Für später gespeicherte Videos anzeigen", + "description": "Watch Later navigation tooltip" + }, + "feed_watch_later_empty": { + "message": "Keine Videos für später gespeichert.", + "description": "Empty Watch Later state" + }, "feed_nav_channels": { "message": "Kanäle", "description": "Channels navigation label" diff --git a/src/_locales/en/feed-dynamic.json b/src/_locales/en/feed-dynamic.json index e4417cd..b32a5b9 100644 --- a/src/_locales/en/feed-dynamic.json +++ b/src/_locales/en/feed-dynamic.json @@ -141,5 +141,8 @@ "feed_views_other": { "message": "$1 views", "description": "Plural view count" }, "feed_watched_on": { "message": "Watched $1", "description": "History watched date" }, "feed_watched_on_percent": { "message": "Watched $1 · $2 watched", "description": "History watched date and percentage" }, - "feed_watched_relative": { "message": "Watched $1", "description": "Relative watched time" } + "feed_watched_relative": { "message": "Watched $1", "description": "Relative watched time" }, + "feed_watch_later_added": { "message": "Saved $1", "description": "Watch Later saved date" }, + "feed_watch_later_entries_one": { "message": "$1 saved video", "description": "Singular Watch Later count" }, + "feed_watch_later_entries_other": { "message": "$1 saved videos", "description": "Plural Watch Later count" } } diff --git a/src/_locales/en/feed.json b/src/_locales/en/feed.json index e68c246..8f661b4 100644 --- a/src/_locales/en/feed.json +++ b/src/_locales/en/feed.json @@ -67,6 +67,18 @@ "message": "View your local watch history", "description": "History navigation tooltip" }, + "feed_nav_watch_later": { + "message": "Watch Later", + "description": "Watch Later navigation label" + }, + "feed_nav_watch_later_title": { + "message": "View videos saved to Watch Later", + "description": "Watch Later navigation tooltip" + }, + "feed_watch_later_empty": { + "message": "No videos saved to Watch Later.", + "description": "Empty Watch Later state" + }, "feed_nav_channels": { "message": "Channels", "description": "Channels navigation label" diff --git a/src/_locales/es/feed-dynamic.json b/src/_locales/es/feed-dynamic.json index 57544a1..e3ec1f7 100644 --- a/src/_locales/es/feed-dynamic.json +++ b/src/_locales/es/feed-dynamic.json @@ -141,5 +141,8 @@ "feed_views_other": { "message": "$1 visualizaciones" }, "feed_watched_on": { "message": "Visto el $1" }, "feed_watched_on_percent": { "message": "Visto el $1 · $2 visto" }, - "feed_watched_relative": { "message": "Visto $1" } + "feed_watched_relative": { "message": "Visto $1" }, + "feed_watch_later_added": { "message": "Guardado $1" }, + "feed_watch_later_entries_one": { "message": "$1 vídeo guardado" }, + "feed_watch_later_entries_other": { "message": "$1 vídeos guardados" } } diff --git a/src/_locales/es/feed.json b/src/_locales/es/feed.json index 4acf46c..ce1d666 100644 --- a/src/_locales/es/feed.json +++ b/src/_locales/es/feed.json @@ -67,6 +67,18 @@ "message": "Ver tu historial local", "description": "History navigation tooltip" }, + "feed_nav_watch_later": { + "message": "Ver más tarde", + "description": "Watch Later navigation label" + }, + "feed_nav_watch_later_title": { + "message": "Ver vídeos guardados para ver más tarde", + "description": "Watch Later navigation tooltip" + }, + "feed_watch_later_empty": { + "message": "No hay vídeos guardados para ver más tarde.", + "description": "Empty Watch Later state" + }, "feed_nav_channels": { "message": "Canales", "description": "Channels navigation label" diff --git a/src/_locales/fr/feed-dynamic.json b/src/_locales/fr/feed-dynamic.json index ee16f74..399e24b 100644 --- a/src/_locales/fr/feed-dynamic.json +++ b/src/_locales/fr/feed-dynamic.json @@ -141,5 +141,8 @@ "feed_views_other": { "message": "$1 vues" }, "feed_watched_on": { "message": "Regardée le $1" }, "feed_watched_on_percent": { "message": "Regardée le $1 · $2 regardé" }, - "feed_watched_relative": { "message": "Regardée $1" } + "feed_watched_relative": { "message": "Regardée $1" }, + "feed_watch_later_added": { "message": "Enregistrée $1" }, + "feed_watch_later_entries_one": { "message": "$1 vidéo enregistrée" }, + "feed_watch_later_entries_other": { "message": "$1 vidéos enregistrées" } } diff --git a/src/_locales/fr/feed.json b/src/_locales/fr/feed.json index b6795e2..a79cc6c 100644 --- a/src/_locales/fr/feed.json +++ b/src/_locales/fr/feed.json @@ -67,6 +67,18 @@ "message": "Voir votre historique local", "description": "History navigation tooltip" }, + "feed_nav_watch_later": { + "message": "À regarder plus tard", + "description": "Watch Later navigation label" + }, + "feed_nav_watch_later_title": { + "message": "Voir les vidéos enregistrées à regarder plus tard", + "description": "Watch Later navigation tooltip" + }, + "feed_watch_later_empty": { + "message": "Aucune vidéo enregistrée à regarder plus tard.", + "description": "Empty Watch Later state" + }, "feed_nav_channels": { "message": "Chaînes", "description": "Channels navigation label" diff --git a/src/background.js b/src/background.js index 5c07525..0e51242 100644 --- a/src/background.js +++ b/src/background.js @@ -322,6 +322,26 @@ function ytvhtExtractVideoMeta(videoId) { return { title: '', channelName: '' }; } +// The card DOM is not guaranteed to be mounted when a context-menu item is +// clicked (and YouTube changes those card layouts regularly). oEmbed gives us +// the two bits of display metadata we need without depending on that DOM. +async function ytvhtFetchWatchLaterMetadata(url) { + if (!url) return { title: '', channelName: '' }; + try { + const response = await fetch( + 'https://www.youtube.com/oembed?url=' + encodeURIComponent(url) + '&format=json' + ); + if (!response.ok) return { title: '', channelName: '' }; + const data = await response.json(); + return { + title: typeof data.title === 'string' ? data.title.trim() : '', + channelName: typeof data.author_name === 'string' ? data.author_name.trim() : '' + }; + } catch (_) { + return { title: '', channelName: '' }; + } +} + // Brief icon-badge feedback so the user knows the save worked (no extra // "notifications" permission needed). function flashBadge(tabId, text, color) { @@ -356,6 +376,14 @@ async function handleAddWatchLater(info, tab) { } } catch (_) { /* ignore — save with what we have */ } + if (!meta.title || !meta.channelName) { + const fallback = await ytvhtFetchWatchLaterMetadata(parsed.url); + meta = { + title: meta.title || fallback.title, + channelName: meta.channelName || fallback.channelName + }; + } + const record = { videoId: parsed.videoId, url: parsed.url, diff --git a/src/content-css.js b/src/content-css.js index 883dac6..46c308e 100644 --- a/src/content-css.js +++ b/src/content-css.js @@ -289,6 +289,10 @@ background: ${color} !important; } `; + // injectCSS recreates the base stylesheet when settings change. Keep + // this dynamic stylesheet last so its chosen accent wins over that + // base stylesheet's blue fallback rules. + document.head.appendChild(styleElement); } window.YTVHTContentCss = { diff --git a/src/content.js b/src/content.js index c1e1f61..a22621b 100644 --- a/src/content.js +++ b/src/content.js @@ -120,7 +120,7 @@ const videoEventListeners = new WeakMap(); function getAccentOverlayColor(settings = currentSettings) { - const colorName = settings?.accentColor || settings?.overlayColor || 'blue'; + const colorName = settings?.overlayColor || settings?.accentColor || 'blue'; return OVERLAY_COLORS[colorName] || OVERLAY_COLORS.blue; } @@ -546,6 +546,31 @@ } } + // During YouTube SPA navigation the URL can change before the previous + // player and title nodes are replaced. Saving in that interval creates a + // record keyed by the new URL with the preceding video's metadata. + function isActiveWatchVideoIdentitySettled(expectedVideoId) { + if (!expectedVideoId) return false; + + const watchPage = document.querySelector('ytd-watch-flexy[video-id]'); + const renderedVideoId = watchPage?.getAttribute('video-id'); + if (renderedVideoId && renderedVideoId !== expectedVideoId) return false; + + const player = document.querySelector('#movie_player'); + try { + const playerVideoId = player?.getVideoData?.()?.video_id; + if (playerVideoId && playerVideoId !== expectedVideoId) return false; + } catch (_) { + // The player API is not available in every browser/page state; + // the watch-page identity check above remains useful on its own. + } + + // Give YouTube one short rendering turn after a route change before + // reading title and channel nodes. The regular five-second save loop + // will capture the new video immediately afterwards. + return Date.now() - lastSpaNavigationTime >= 1000; + } + // Save the current video timestamp (regular videos) async function saveTimestamp() { if (window.location.pathname.startsWith('/shorts/')) { @@ -583,6 +608,10 @@ const duration = video.duration; const videoId = getVideoId(); if (!videoId) return; + if (!isActiveWatchVideoIdentitySettled(videoId)) { + log('[SAVE] Waiting for the active watch-page identity to settle', { videoId }); + return; + } // Do not update record if timestamp is 0 or duration is not available if (!currentTime || currentTime === 0 || !duration || duration === 0) return; @@ -1941,6 +1970,37 @@ chrome.runtime.onMessage.addListener(messageListener); + // A YouTube document can remain alive while the user opens re:Watch and + // returns to it. Refresh from the canonical store on that return, rather + // 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 refreshOverlaySettingsFromStorage() { + if (overlaySettingsRefresh) return overlaySettingsRefresh; + overlaySettingsRefresh = (async () => { + 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?.(); + })().catch((error) => { + log('[Overlay] Could not refresh settings:', error); + }).finally(() => { + overlaySettingsRefresh = null; + }); + return overlaySettingsRefresh; + } + + chrome.storage?.onChanged?.addListener((changes, area) => { + if (area === 'local' && changes.settings) refreshOverlaySettingsFromStorage(); + }); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) refreshOverlaySettingsFromStorage(); + }); + window.addEventListener('focus', refreshOverlaySettingsFromStorage); + const { showExtensionInfo } = window.YTVHTContentInfo.create({ log, storage diff --git a/src/feed-analytics.js b/src/feed-analytics.js index 73954c8..f7e5d46 100644 --- a/src/feed-analytics.js +++ b/src/feed-analytics.js @@ -5,6 +5,7 @@ let activePlaylistDetailId = null; let playlistDetailRenderToken = 0; let historyActive = false; let settingsActive = false; +let watchLaterActive = false; let historyVisibleLimit = 30; let analyticsChannelSort = 'watchTime'; let analyticsHistorySnapshot = []; diff --git a/src/feed-channel-view.js b/src/feed-channel-view.js index 6dced8a..7dbb645 100644 --- a/src/feed-channel-view.js +++ b/src/feed-channel-view.js @@ -140,9 +140,10 @@ function showChannelPage(info) { playlistsActive = false; historyActive = false; settingsActive = false; + watchLaterActive = false; ['localHeading', 'grid', 'localSearchResults', 'empty', 'analyticsSection', 'subscriptionsSection', 'playlistsSection', - 'historySection', 'settingsSection'].forEach((id) => { + 'historySection', 'settingsSection', 'watchLaterSection'].forEach((id) => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); diff --git a/src/feed-history-view.js b/src/feed-history-view.js index d41beb1..806c111 100644 --- a/src/feed-history-view.js +++ b/src/feed-history-view.js @@ -93,8 +93,9 @@ function showHistory() { historyActive = true; settingsActive = false; channelActive = false; + watchLaterActive = false; ['localHeading', 'grid', 'localSearchResults', 'empty', 'ytSection', - 'analyticsSection', 'subscriptionsSection', 'playlistsSection', 'settingsSection', 'channelSection'].forEach((id) => { + 'analyticsSection', 'subscriptionsSection', 'playlistsSection', 'settingsSection', 'channelSection', 'watchLaterSection'].forEach((id) => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); diff --git a/src/feed-playlists-view.js b/src/feed-playlists-view.js index 35722e0..0d48da3 100644 --- a/src/feed-playlists-view.js +++ b/src/feed-playlists-view.js @@ -788,6 +788,7 @@ function showPlaylists() { historyActive = false; settingsActive = false; channelActive = false; + watchLaterActive = false; initPlaylistsToolbar(); ['localHeading', 'grid', 'localSearchResults', 'empty', 'ytSection', 'channelSection'].forEach((id) => { const el = document.getElementById(id); @@ -801,6 +802,8 @@ function showPlaylists() { if (settings) settings.style.display = 'none'; const history = document.getElementById('historySection'); if (history) history.style.display = 'none'; + const watchLater = document.getElementById('watchLaterSection'); + if (watchLater) watchLater.style.display = 'none'; const chips = document.querySelector('.chips'); if (chips) chips.style.display = 'none'; const playlists = document.getElementById('playlistsSection'); diff --git a/src/feed-refresh.js b/src/feed-refresh.js index 8c6ca51..3f32f00 100644 --- a/src/feed-refresh.js +++ b/src/feed-refresh.js @@ -279,6 +279,28 @@ async function showPendingFeedVideos(onReady) { setStatus(message, false); scrollToShownFeedVideos(videoIds); } catch (error) { + // Pending discoveries created before a retention pass can include IDs + // that were legitimately removed before the user clicks Show. Recover + // any remaining canonical IDs in-place rather than presenting a + // guaranteed failure. A fully unavailable inventory still stays + // retryable below. + if (error && error.code === 'pending_inventory_incomplete') { + try { + await loadData({ requireCanonicalInventory: true }); + const available = new Set((allVideos || []).map((video) => video.videoId)); + const retainedVideoIds = videoIds.filter((videoId) => available.has(videoId)); + if (retainedVideoIds.length) { + await replacePendingFeedDiscovery({ + videoIds: retainedVideoIds, + discoveredAt: pendingFeedDiscovery.discoveredAt || Date.now() + }); + pendingFeedNoticeState = { busy: false, error: '' }; + return showPendingFeedVideos(onReady); + } + } catch (recoveryError) { + console.warn('[feed] could not recover retained discoveries', recoveryError && recoveryError.message); + } + } pendingFeedNoticeState = { busy: false, error: String(error && error.message || tFeed('message_unknown_error', 'error')) diff --git a/src/feed-settings.js b/src/feed-settings.js index 913a7c1..004d2f2 100644 --- a/src/feed-settings.js +++ b/src/feed-settings.js @@ -37,6 +37,16 @@ function applyAccentColor(color) { ); } +// The main feed opens directly to Home, so its appearance must be loaded +// independently of opening the Settings section. +async function initializeFeedAppearance() { + const stored = (await ytStorage.getSettings()) || {}; + const settings = { ...FEED_SETTINGS_DEFAULTS, ...stored }; + applyFeedTheme(settings.themePreference); + applyAccentColor(settings.overlayColor || settings.accentColor || 'blue'); + return settings; +} + async function loadFeedSettingsForm() { const stored = (await ytStorage.getSettings()) || {}; if (stored.autoCleanPeriod === 90 || stored.autoCleanPeriod === '90') { @@ -46,7 +56,7 @@ async function loadFeedSettingsForm() { const settings = { ...FEED_SETTINGS_DEFAULTS, ...stored }; document.getElementById('feedSettingTheme').value = settings.themePreference; document.getElementById('feedSettingAccent').value = - settings.accentColor || settings.overlayColor || 'blue'; + settings.overlayColor || settings.accentColor || 'blue'; const defaultPageSelect = document.getElementById('feedSettingDefaultPage'); if (defaultPageSelect) defaultPageSelect.value = settings.defaultFeedPage || 'last'; const refreshSelect = document.getElementById('feedSettingRefresh'); @@ -56,7 +66,7 @@ async function loadFeedSettingsForm() { : '60'; document.getElementById('feedSettingAutoClean').value = String(settings.autoCleanPeriod); applyFeedTheme(settings.themePreference); - applyAccentColor(settings.accentColor); + applyAccentColor(settings.overlayColor || settings.accentColor || 'blue'); } function notifySettingsChanged(settings) { @@ -88,14 +98,14 @@ async function saveFeedSettings() { feedRefreshMinutes: cleanRefresh, autoCleanPeriod: autoCleanValue === 'forever' ? 'forever' : Number(autoCleanValue) }; - await ytStorage.setSettings(settings); - await chrome.storage.local.set({ popupAccentColor: color }); - localStorage.setItem('ytvhtThemePreference', settings.themePreference); - localStorage.setItem('ytvhtAccentColor', color); + const normalized = await ytStorage.setSettings(settings); + await chrome.storage.local.set({ popupAccentColor: normalized.overlayColor || normalized.accentColor || color }); + localStorage.setItem('ytvhtThemePreference', normalized.themePreference); + localStorage.setItem('ytvhtAccentColor', normalized.overlayColor || normalized.accentColor || color); overlayTitle = tFeed('feed_viewed', 'Viewed'); - applyFeedTheme(settings.themePreference); - applyAccentColor(settings.accentColor); - notifySettingsChanged(settings); + applyFeedTheme(normalized.themePreference); + applyAccentColor(normalized.overlayColor || normalized.accentColor || 'blue'); + notifySettingsChanged(normalized); const message = document.getElementById('feedSettingsMessage'); message.textContent = ''; } @@ -117,11 +127,12 @@ function showSettings() { historyActive = false; settingsActive = true; channelActive = false; + watchLaterActive = false; ['localHeading', 'grid', 'localSearchResults', 'empty', 'ytSection', 'channelSection'].forEach((id) => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); - ['analyticsSection', 'subscriptionsSection', 'playlistsSection', 'historySection'].forEach((id) => { + ['analyticsSection', 'subscriptionsSection', 'playlistsSection', 'historySection', 'watchLaterSection'].forEach((id) => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); diff --git a/src/feed-subscriptions-view.js b/src/feed-subscriptions-view.js index 016d6fa..457c1d8 100644 --- a/src/feed-subscriptions-view.js +++ b/src/feed-subscriptions-view.js @@ -72,6 +72,7 @@ function showAnalytics() { playlistsActive = false; historyActive = false; settingsActive = false; + watchLaterActive = false; channelActive = false; ['localHeading', 'grid', 'localSearchResults', 'empty', 'channelSection'].forEach((id) => { const el = document.getElementById(id); @@ -83,6 +84,8 @@ function showAnalytics() { if (playlists) playlists.style.display = 'none'; const history = document.getElementById('historySection'); if (history) history.style.display = 'none'; + const watchLater = document.getElementById('watchLaterSection'); + if (watchLater) watchLater.style.display = 'none'; const settings = document.getElementById('settingsSection'); if (settings) settings.style.display = 'none'; const chips = document.querySelector('.chips'); @@ -107,6 +110,7 @@ function showFeed() { playlistsActive = false; historyActive = false; settingsActive = false; + watchLaterActive = false; channelActive = false; const sec = document.getElementById('analyticsSection'); if (sec) sec.style.display = 'none'; @@ -116,6 +120,8 @@ function showFeed() { if (playlists) playlists.style.display = 'none'; const history = document.getElementById('historySection'); if (history) history.style.display = 'none'; + const watchLater = document.getElementById('watchLaterSection'); + if (watchLater) watchLater.style.display = 'none'; const settings = document.getElementById('settingsSection'); if (settings) settings.style.display = 'none'; const channel = document.getElementById('channelSection'); @@ -489,6 +495,7 @@ function showSubscriptions(requestedTab) { historyActive = false; settingsActive = false; channelActive = false; + watchLaterActive = false; ['localHeading', 'grid', 'localSearchResults', 'empty', 'ytSection', 'channelSection'].forEach((id) => { const el = document.getElementById(id); if (el) el.style.display = 'none'; @@ -499,6 +506,8 @@ function showSubscriptions(requestedTab) { if (playlists) playlists.style.display = 'none'; const history = document.getElementById('historySection'); if (history) history.style.display = 'none'; + const watchLater = document.getElementById('watchLaterSection'); + if (watchLater) watchLater.style.display = 'none'; const settings = document.getElementById('settingsSection'); if (settings) settings.style.display = 'none'; const chips = document.querySelector('.chips'); diff --git a/src/feed-watchlater-view.js b/src/feed-watchlater-view.js new file mode 100644 index 0000000..230681e --- /dev/null +++ b/src/feed-watchlater-view.js @@ -0,0 +1,125 @@ +async function renderWatchLater() { + const list = document.getElementById('watchLaterList'); + const empty = document.getElementById('watchLaterEmpty'); + const count = document.getElementById('watchLaterCount'); + if (!list || !empty || !count) return; + + let records = []; + try { + const items = await ytStorage.getAllWatchLater(); + records = Object.values(items || {}) + .filter((item) => item && item.videoId) + .sort((a, b) => Number(b.addedAt || 0) - Number(a.addedAt || 0)); + records = await Promise.all(records.map(enrichWatchLaterRecord)); + } catch (error) { + console.error('[watch-later] could not load saved videos', error); + } + + list.textContent = ''; + records.forEach((record) => { + const video = { + ...record, + url: record.url || `https://www.youtube.com/watch?v=${record.videoId}` + }; + const row = buildResultRow(video, { + metaText: watchLaterAddedText(record), + overlayRecord: watchedMap[record.videoId] + }); + row.classList.add('history-row'); + + const remove = document.createElement('button'); + remove.className = 'btn history-remove'; + remove.type = 'button'; + remove.textContent = tFeed('feed_remove', 'Remove'); + remove.addEventListener('click', async () => { + remove.disabled = true; + try { + await ytStorage.removeWatchLater(record.videoId); + await renderWatchLater(); + } catch (error) { + console.error('[watch-later] could not remove saved video', error); + remove.disabled = false; + } + }); + const actions = row.querySelector('.yt-row-actions'); + if (actions) actions.insertBefore(remove, actions.firstChild); + else row.appendChild(remove); + list.appendChild(row); + }); + + count.textContent = feedPlural( + 'feed_watch_later_entries', + records.length, + '$1 saved video', + '$1 saved videos' + ); + empty.style.display = records.length ? 'none' : 'block'; +} + +// Older right-click saves can lack metadata if YouTube had not mounted the +// matching card. Repair those records on first display, rather than making a +// user remove and save each video again. +async function enrichWatchLaterRecord(record) { + if (record.title && record.channelName) return record; + const url = record.url || `https://www.youtube.com/watch?v=${record.videoId}`; + try { + const response = await fetch( + 'https://www.youtube.com/oembed?url=' + encodeURIComponent(url) + '&format=json' + ); + if (!response.ok) return { ...record, url }; + const metadata = await response.json(); + const repaired = { + ...record, + url, + title: record.title || (typeof metadata.title === 'string' ? metadata.title.trim() : ''), + channelName: record.channelName || (typeof metadata.author_name === 'string' ? metadata.author_name.trim() : '') + }; + if ((repaired.title && !record.title) || (repaired.channelName && !record.channelName)) { + await ytStorage.setWatchLater(record.videoId, repaired); + } + return repaired; + } catch (_) { + return { ...record, url }; + } +} + +function watchLaterAddedText(record) { + const addedAt = Number(record.addedAt || 0); + if (!addedAt) return tFeed('feed_unknown_date', 'Unknown date'); + return tFeed('feed_watch_later_added', 'Saved $1', [new Date(addedAt).toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' + })]); +} + +function showWatchLater() { + rememberView('watchlater'); + document.body.classList.remove('shorts-mode'); + setRefreshVisible(false); + setCreatePlaylistVisible(false); + setSaveSettingsVisible(false); + setClearSubscriptionsVisible(false); + setClearHistoryVisible(false); + setFeedOptionsVisible(false); + showFeedStatus(false); + leaveSearchPage(); + analyticsActive = false; + subscriptionsActive = false; + playlistsActive = false; + historyActive = false; + settingsActive = false; + channelActive = false; + watchLaterActive = true; + + ['localHeading', 'grid', 'localSearchResults', 'empty', 'ytSection', + 'analyticsSection', 'subscriptionsSection', 'playlistsSection', + 'historySection', 'settingsSection', 'channelSection'].forEach((id) => { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + }); + const chips = document.querySelector('.chips'); + if (chips) chips.style.display = 'none'; + const section = document.getElementById('watchLaterSection'); + if (section) section.style.display = 'block'; + setActiveNav('navWatchLater'); + renderWatchLater(); +} diff --git a/src/feed.html b/src/feed.html index f11cf28..729c871 100644 --- a/src/feed.html +++ b/src/feed.html @@ -1665,6 +1665,9 @@ + @@ -1787,6 +1790,18 @@

History

+ + +