diff --git a/build.sh b/build.sh index 9c772f4..2854540 100755 --- a/build.sh +++ b/build.sh @@ -42,6 +42,7 @@ copy_common_files() { "$PROJECT_ROOT/src/content-info.js" \ "$PROJECT_ROOT/src/content-thumbnails.js" \ "$PROJECT_ROOT/src/content-ai-labels.js" \ + "$PROJECT_ROOT/src/content-announcement.js" \ "$PROJECT_ROOT/src/content-ai-labels-page.js" \ "$PROJECT_ROOT/src/content-messages.js" \ "$PROJECT_ROOT/src/popup.html" \ diff --git a/src/_locales/bg/messages.json b/src/_locales/bg/messages.json index 1b7340c..4134aee 100644 --- a/src/_locales/bg/messages.json +++ b/src/_locales/bg/messages.json @@ -137,5 +137,9 @@ "settings_ai_labeled_video_dim": { "message": "Затъмни", "description": "AI setting option" }, "settings_ai_labeled_video_hide": { "message": "Скрий", "description": "AI setting option" }, "settings_ai_labeled_video_help": { "message": "Проверява само карти в отворени страници на YouTube. Изгледите на re:Watch показват кеширани етикети и не стартират проверки.", "description": "AI setting help" }, - "settings_ai_labeled_video_info": { "message": "Проверките се извършват само за карти в отворени страници на YouTube. Изгледите на re:Watch не стартират проверки; те показват само кеширани резултати и запазват локалните видеа видими. Функцията използва етикета Made with AI на YouTube, не общо AI разпознаване, и може да пропусне AI видеа.", "description": "AI setting information" } + "settings_ai_labeled_video_info": { "message": "Проверките се извършват само за карти в отворени страници на YouTube. Изгледите на re:Watch не стартират проверки; те показват само кеширани резултати и запазват локалните видеа видими. Функцията използва етикета Made with AI на YouTube, не общо AI разпознаване, и може да пропусне AI видеа.", "description": "AI setting information" }, + "ai_label_announcement_title": { "message": "Нови контроли за AI етикети", "description": "Заглавие на еднократното съобщение за функцията за AI етикети" }, + "ai_label_announcement_body": { "message": "Изберете дали видеата с AI етикет да се показват, затъмняват или скриват в re:Watch.", "description": "Текст на еднократното съобщение за функцията за AI етикети" }, + "announcement_dismiss": { "message": "Отказ", "description": "Скриване на съобщение за функция на разширението" }, + "ai_label_announcement_settings": { "message": "Виж в настройките", "description": "Отваряне на настройките за Feed от съобщението" } } diff --git a/src/_locales/de/messages.json b/src/_locales/de/messages.json index a390287..c4e4813 100644 --- a/src/_locales/de/messages.json +++ b/src/_locales/de/messages.json @@ -137,5 +137,9 @@ "settings_ai_labeled_video_dim": { "message": "Abdunkeln", "description": "AI setting option" }, "settings_ai_labeled_video_hide": { "message": "Ausblenden", "description": "AI setting option" }, "settings_ai_labeled_video_help": { "message": "Prüft nur Karten auf geöffneten YouTube-Seiten. re:Watch-Ansichten zeigen zwischengespeicherte Kennzeichnungen und starten keine Prüfungen.", "description": "AI setting help" }, - "settings_ai_labeled_video_info": { "message": "Prüfungen laufen nur für Karten auf geöffneten YouTube-Seiten. re:Watch-Ansichten starten keine Prüfungen; sie zeigen nur zwischengespeicherte Ergebnisse und lassen lokale Videos sichtbar. Die Funktion nutzt YouTubes Made-with-AI-Hinweis, keine allgemeine KI-Erkennung, und kann KI-Videos übersehen.", "description": "AI setting information" } + "settings_ai_labeled_video_info": { "message": "Prüfungen laufen nur für Karten auf geöffneten YouTube-Seiten. re:Watch-Ansichten starten keine Prüfungen; sie zeigen nur zwischengespeicherte Ergebnisse und lassen lokale Videos sichtbar. Die Funktion nutzt YouTubes Made-with-AI-Hinweis, keine allgemeine KI-Erkennung, und kann KI-Videos übersehen.", "description": "AI setting information" }, + "ai_label_announcement_title": { "message": "Neue Steuerelemente für KI-Kennzeichnungen", "description": "Titel der einmaligen Ankündigung der KI-Kennzeichnungsfunktion" }, + "ai_label_announcement_body": { "message": "Lege fest, ob KI-gekennzeichnete Videos in re:Watch angezeigt, abgeblendet oder ausgeblendet werden.", "description": "Text der einmaligen Ankündigung der KI-Kennzeichnungsfunktion" }, + "announcement_dismiss": { "message": "Schließen", "description": "Eine Funktionsankündigung schließen" }, + "ai_label_announcement_settings": { "message": "In Einstellungen ansehen", "description": "Feed-Einstellungen über die Ankündigung öffnen" } } diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 1586986..f16736e 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -146,5 +146,9 @@ "settings_ai_labeled_video_info": { "message": "Checks run only for cards on open YouTube pages. re:Watch views never start checks; they only show cached results and keep local videos visible. The feature uses YouTube's Made with AI disclosure, not general AI detection, and can miss AI-made videos.", "description": "Privacy and reliability information for AI-label handling" - } + }, + "ai_label_announcement_title": { "message": "New AI-label controls", "description": "Title of the one-time AI-label feature announcement" }, + "ai_label_announcement_body": { "message": "Choose whether AI-labeled videos are shown, dimmed, or hidden in re:Watch.", "description": "Body of the one-time AI-label feature announcement" }, + "announcement_dismiss": { "message": "Dismiss", "description": "Dismiss an extension feature announcement" }, + "ai_label_announcement_settings": { "message": "See it in Settings", "description": "Open Feed settings from the AI-label feature announcement" } } diff --git a/src/_locales/es/messages.json b/src/_locales/es/messages.json index 253e36d..5ce8728 100644 --- a/src/_locales/es/messages.json +++ b/src/_locales/es/messages.json @@ -137,5 +137,9 @@ "settings_ai_labeled_video_dim": { "message": "Atenuar", "description": "AI setting option" }, "settings_ai_labeled_video_hide": { "message": "Ocultar", "description": "AI setting option" }, "settings_ai_labeled_video_help": { "message": "Solo comprueba tarjetas de páginas de YouTube abiertas. Las vistas de re:Watch muestran etiquetas almacenadas y nunca inician comprobaciones.", "description": "AI setting help" }, - "settings_ai_labeled_video_info": { "message": "Las comprobaciones solo se ejecutan para tarjetas de páginas de YouTube abiertas. Las vistas de re:Watch no inician comprobaciones; solo muestran resultados almacenados y mantienen visibles los vídeos locales. La función usa la divulgación Made with AI de YouTube, no una detección general de IA, y puede omitir vídeos creados con IA.", "description": "AI setting information" } + "settings_ai_labeled_video_info": { "message": "Las comprobaciones solo se ejecutan para tarjetas de páginas de YouTube abiertas. Las vistas de re:Watch no inician comprobaciones; solo muestran resultados almacenados y mantienen visibles los vídeos locales. La función usa la divulgación Made with AI de YouTube, no una detección general de IA, y puede omitir vídeos creados con IA.", "description": "AI setting information" }, + "ai_label_announcement_title": { "message": "Nuevos controles para etiquetas de IA", "description": "Título del anuncio único de la función de etiquetas de IA" }, + "ai_label_announcement_body": { "message": "Elige si los vídeos con etiqueta de IA se muestran, atenúan u ocultan en re:Watch.", "description": "Texto del anuncio único de la función de etiquetas de IA" }, + "announcement_dismiss": { "message": "Descartar", "description": "Descartar un anuncio de función de la extensión" }, + "ai_label_announcement_settings": { "message": "Ver en Configuración", "description": "Abrir la configuración del Feed desde el anuncio" } } diff --git a/src/_locales/fr/messages.json b/src/_locales/fr/messages.json index cd726dd..5bf8c33 100644 --- a/src/_locales/fr/messages.json +++ b/src/_locales/fr/messages.json @@ -137,5 +137,9 @@ "settings_ai_labeled_video_dim": { "message": "Atténuer", "description": "AI setting option" }, "settings_ai_labeled_video_hide": { "message": "Masquer", "description": "AI setting option" }, "settings_ai_labeled_video_help": { "message": "Ne vérifie que les cartes des pages YouTube ouvertes. Les vues re:Watch affichent les étiquettes en cache et ne lancent jamais de vérification.", "description": "AI setting help" }, - "settings_ai_labeled_video_info": { "message": "Les vérifications ne concernent que les cartes des pages YouTube ouvertes. Les vues re:Watch ne lancent aucune vérification ; elles affichent uniquement les résultats en cache et gardent les vidéos locales visibles. La fonction utilise la divulgation Made with AI de YouTube, pas une détection générale d’IA, et peut manquer des vidéos créées avec IA.", "description": "AI setting information" } + "settings_ai_labeled_video_info": { "message": "Les vérifications ne concernent que les cartes des pages YouTube ouvertes. Les vues re:Watch ne lancent aucune vérification ; elles affichent uniquement les résultats en cache et gardent les vidéos locales visibles. La fonction utilise la divulgation Made with AI de YouTube, pas une détection générale d’IA, et peut manquer des vidéos créées avec IA.", "description": "AI setting information" }, + "ai_label_announcement_title": { "message": "Nouveaux contrôles des libellés IA", "description": "Titre de l’annonce unique de la fonctionnalité de libellés IA" }, + "ai_label_announcement_body": { "message": "Choisissez si les vidéos libellées IA sont affichées, atténuées ou masquées dans re:Watch.", "description": "Texte de l’annonce unique de la fonctionnalité de libellés IA" }, + "announcement_dismiss": { "message": "Fermer", "description": "Fermer une annonce de fonctionnalité de l’extension" }, + "ai_label_announcement_settings": { "message": "Voir dans les paramètres", "description": "Ouvrir les paramètres du Feed depuis l’annonce" } } diff --git a/src/background.js b/src/background.js index 80e371b..ae5d840 100644 --- a/src/background.js +++ b/src/background.js @@ -41,6 +41,54 @@ const stateManager = { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { (async () => { console.log('Background script received message:', message.type, 'from sender:', sender.tab ? 'content' : 'popup'); + + if (message.type === 'getAnnouncements') { + if (!isLocalSubscriptionSender(sender) || !Number.isInteger(sender.tab?.id)) { + sendResponse({ announcements: [] }); + return; + } + sendResponse({ announcements: Object.values(ANNOUNCEMENTS) }); + return; + } + + if (message.type === 'claimAnnouncement') { + if (!isLocalSubscriptionSender(sender) || !Number.isInteger(sender.tab?.id)) { + sendResponse({ show: false }); + return; + } + if (!ANNOUNCEMENTS[message.announcementId]) { + sendResponse({ show: false }); + return; + } + try { + sendResponse({ show: await claimAnnouncement(message.announcementId, sender.tab.id) }); + } catch (error) { + console.warn('[Announcements] could not claim an announcement', error); + sendResponse({ show: false }); + } + return; + } + + if (message.type === 'releaseAnnouncement' || message.type === 'runAnnouncementAction') { + if (!isLocalSubscriptionSender(sender)) { + sendResponse({ ok: false }); + return; + } + const announcement = ANNOUNCEMENTS[message.announcementId]; + if (!announcement) { + sendResponse({ ok: false }); + return; + } + try { + if (message.type === 'runAnnouncementAction') await runAnnouncementAction(announcement); + await releaseAnnouncement(message.announcementId); + sendResponse({ ok: true }); + } catch (error) { + console.warn('[Announcements] could not complete an announcement', error); + sendResponse({ ok: false }); + } + return; + } if (message.type === 'openPopup') { const popupId = await stateManager.get('activePopupWindowId'); @@ -224,6 +272,18 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; // Indicates async response }); +chrome.tabs?.onRemoved?.addListener((tabId) => { + stateManager.get(ANNOUNCEMENT_OWNERS_STATE_KEY).then((owners) => { + owners = owners || {}; + const remainingOwners = Object.fromEntries( + Object.entries(owners).filter(([, ownerTabId]) => ownerTabId !== tabId) + ); + if (Object.keys(remainingOwners).length !== Object.keys(owners).length) { + return stateManager.set({ [ANNOUNCEMENT_OWNERS_STATE_KEY]: remainingOwners }); + } + }).catch(() => {}); +}); + function isLocalSubscriptionSender(sender) { const senderUrl = sender && sender.url || ''; // The loopback origin is injected only by the Firefox E2E build to replay @@ -302,7 +362,82 @@ function createContextMenus() { // MV3 service workers are torn down and restarted; re-create the menu on both // install/update and browser startup so it's always present. -chrome.runtime.onInstalled.addListener(createContextMenus); +const ANNOUNCEMENT_OWNERS_STATE_KEY = 'announcementOwners'; +let announcementClaimQueue = Promise.resolve(); +const ANNOUNCEMENTS = { + 'ai-label-controls-v1': { + id: 'ai-label-controls-v1', + titleKey: 'ai_label_announcement_title', + title: 'New AI-label controls', + bodyKey: 'ai_label_announcement_body', + body: 'Choose whether AI-labeled videos are shown, dimmed, or hidden in re:Watch.', + actionLabelKey: 'ai_label_announcement_settings', + actionLabel: 'See it in Settings', + storageKey: '__rwui_notice_7', + action: { type: 'open-extension-page', path: 'feed.html#settings' } + } +}; + +async function announcementOwnerIsLive(tabId) { + if (!Number.isInteger(tabId) || !chrome.tabs?.get) return false; + try { + await chrome.tabs.get(tabId); + return true; + } catch (_) { + return false; + } +} + +function claimAnnouncement(announcementId, tabId) { + const claim = announcementClaimQueue.then(() => claimAnnouncementUnlocked(announcementId, tabId)); + // Keep the queue usable after a storage/API failure while returning the + // failure to this caller for normal message handling. + announcementClaimQueue = claim.catch(() => {}); + return claim; +} + +async function claimAnnouncementUnlocked(announcementId, tabId) { + const owners = (await stateManager.get(ANNOUNCEMENT_OWNERS_STATE_KEY)) || {}; + const ownerTabId = owners[announcementId]; + if (ownerTabId !== tabId && await announcementOwnerIsLive(ownerTabId)) return false; + await stateManager.set({ + [ANNOUNCEMENT_OWNERS_STATE_KEY]: { ...owners, [announcementId]: tabId } + }); + return true; +} + +async function releaseAnnouncement(announcementId) { + const owners = (await stateManager.get(ANNOUNCEMENT_OWNERS_STATE_KEY)) || {}; + const nextOwners = { ...owners }; + delete nextOwners[announcementId]; + await stateManager.set({ [ANNOUNCEMENT_OWNERS_STATE_KEY]: nextOwners }); +} + +async function runAnnouncementAction(announcement) { + if (announcement.action?.type === 'open-extension-page') { + await chrome.tabs.create({ url: chrome.runtime.getURL(announcement.action.path) }); + return; + } + throw new Error(`Unsupported announcement action: ${announcement.action?.type || 'none'}`); +} + +async function injectAnnouncementsIntoOpenYouTubeTabs() { + if (!chrome.tabs?.query || !chrome.scripting?.executeScript) return; + const tabs = await chrome.tabs.query({ url: ['https://www.youtube.com/*'] }); + await Promise.all(tabs.filter((tab) => Number.isInteger(tab.id)).map((tab) => + chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['content-announcement.js'] + }).catch(() => {}) + )); +} + +chrome.runtime.onInstalled.addListener((details) => { + createContextMenus(); + Promise.resolve(details.reason === 'install' || details.reason === 'update') + .then((shouldInject) => shouldInject && injectAnnouncementsIntoOpenYouTubeTabs()) + .catch((error) => console.warn('[Announcements] setup failed', error)); +}); if (chrome.runtime.onStartup) chrome.runtime.onStartup.addListener(createContextMenus); // Extract a YouTube video id (and shorts flag) from any link/page URL. diff --git a/src/content-ai-labels.js b/src/content-ai-labels.js index c60a26e..c19eb18 100644 --- a/src/content-ai-labels.js +++ b/src/content-ai-labels.js @@ -239,6 +239,15 @@ cardsById.get(id).add(card); if (resolvedStatuses.has(id)) apply(id, resolvedStatuses.get(id)); observer.observe(card); + // Firefox can omit the initial IntersectionObserver callback for + // an already-visible card during document startup. Queue such a + // card directly; later cards still use observer re-entry. + const bounds = card.getBoundingClientRect(); + if (bounds.bottom > 0 && bounds.top < window.innerHeight && + bounds.right > 0 && bounds.left < window.innerWidth) { + queuedIds.add(id); + drain(); + } } function scan(root = document) { diff --git a/src/content-announcement.js b/src/content-announcement.js new file mode 100644 index 0000000..f463cc8 --- /dev/null +++ b/src/content-announcement.js @@ -0,0 +1,113 @@ +(function () { + 'use strict'; + + const ROOT_ID = 'ytvht-announcement'; + + function message(key, fallback) { + try { + return chrome.i18n?.getMessage(key) || fallback; + } catch (_) { + return fallback; + } + } + + function sendMessage(type, payload = {}) { + return new Promise((resolve) => { + try { + chrome.runtime.sendMessage({ type, ...payload }, (response) => { + if (chrome.runtime.lastError) return resolve(null); + resolve(response || null); + }); + } catch (_) { + resolve(null); + } + }); + } + + function isDarkTheme() { + return document.documentElement.hasAttribute('dark') + || !!document.querySelector('ytd-app[dark]'); + } + + function isAcknowledged(announcement) { + try { + // Any stored value is terminal. Treat malformed site storage as + // acknowledged so a storage problem cannot turn into a nag. + return window.localStorage.getItem(announcement.storageKey) !== null; + } catch (_) { + return true; + } + } + + function acknowledge(announcement) { + try { + window.localStorage.setItem(announcement.storageKey, '1'); + return true; + } catch (_) { + return false; + } + } + + function mount(announcement) { + if (document.getElementById(ROOT_ID)) return; + const host = document.createElement('section'); + host.id = ROOT_ID; + host.setAttribute('role', 'status'); + host.setAttribute('aria-label', message(announcement.titleKey, announcement.title)); + host.dataset.theme = isDarkTheme() ? 'dark' : 'light'; + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = ` + +
+

+
+
`; + shadow.querySelector('h2').textContent = message(announcement.titleKey, announcement.title); + shadow.querySelector('p').textContent = message(announcement.bodyKey, announcement.body); + shadow.querySelector('.dismiss').textContent = message('announcement_dismiss', 'Dismiss'); + shadow.querySelector('.action').textContent = message(announcement.actionLabelKey, announcement.actionLabel); + + const remove = () => host.remove(); + shadow.querySelector('.dismiss').addEventListener('click', async () => { + if (!acknowledge(announcement)) return; + await sendMessage('releaseAnnouncement', { announcementId: announcement.id }); + remove(); + }); + shadow.querySelector('.action').addEventListener('click', async () => { + if (!(await sendMessage('runAnnouncementAction', { announcementId: announcement.id }))?.ok) return; + if (!acknowledge(announcement)) return; + remove(); + }); + document.body.appendChild(host); + + new MutationObserver(() => { + host.dataset.theme = isDarkTheme() ? 'dark' : 'light'; + }).observe(document.documentElement, { attributes: true, attributeFilter: ['dark'] }); + } + + async function showNextAnnouncement() { + if (document.getElementById(ROOT_ID)) return; + if (location.hostname !== 'www.youtube.com') return; + const result = await sendMessage('getAnnouncements'); + const announcement = (result?.announcements || []).find((candidate) => !isAcknowledged(candidate)); + if (!announcement) return; + const claim = await sendMessage('claimAnnouncement', { announcementId: announcement.id }); + if (claim?.show && document.body) mount(announcement); + } + + if (document.body) showNextAnnouncement(); + else document.addEventListener('DOMContentLoaded', showNextAnnouncement, { once: true }); +})(); diff --git a/src/manifest.chrome.json b/src/manifest.chrome.json index 1df2daf..2f0ca5e 100644 --- a/src/manifest.chrome.json +++ b/src/manifest.chrome.json @@ -33,6 +33,7 @@ "content-info.js", "content-thumbnails.js", "content-ai-labels.js", + "content-announcement.js", "content-messages.js", "content.js", "content-subscriptions.js" diff --git a/src/manifest.firefox.json b/src/manifest.firefox.json index 63f1ad2..6a6086b 100644 --- a/src/manifest.firefox.json +++ b/src/manifest.firefox.json @@ -46,6 +46,7 @@ "content-info.js", "content-thumbnails.js", "content-ai-labels.js", + "content-announcement.js", "content-messages.js", "content.js", "content-subscriptions.js" diff --git a/tests/e2e/ai-labeled-videos.spec.js b/tests/e2e/ai-labeled-videos.spec.js index b8432d8..7f3b39d 100644 --- a/tests/e2e/ai-labeled-videos.spec.js +++ b/tests/e2e/ai-labeled-videos.spec.js @@ -36,7 +36,7 @@ async function configure(page, context, mode) { await page.route('https://www.youtube.com/ai-label-static', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: fixtureHtml(), })); - await page.route('https://www.youtube.com/youtubei/v1/next?**', async (route) => { + await page.route('**/youtubei/v1/next**', async (route) => { lookupCount += 1; const payload = route.request().postDataJSON(); await route.fulfill({ contentType: 'application/json', body: JSON.stringify(nextResponse(payload.videoId === AI_VIDEO_ID)) }); diff --git a/tests/e2e/static-release-feed.spec.js b/tests/e2e/static-release-feed.spec.js index 290ad4b..ff96416 100644 --- a/tests/e2e/static-release-feed.spec.js +++ b/tests/e2e/static-release-feed.spec.js @@ -247,15 +247,8 @@ test('feed video menus unsubscribe from Home and follow again from History', asy }); test('real IndexedDB v5 data survives the v7 tombstone and AI-cache store upgrades', async ({ context }) => { - const { page, pageErrors } = await openFeed(context); - // Chromium may initialize the MV3 worker's same-origin database before this migration setup. const worker = await getServiceWorker(context); await worker.evaluate(async () => { - const openConnection = await ytIndexedDBStorage._getDB(); - openConnection.close(); - ytIndexedDBStorage._dbPromise = null; - }); - const preserved = await page.evaluate(async () => { const databaseName = 'YTLH_HybridDB'; const channelId = 'UCv5upgradefixture00000001'; const videoId = 'V5Upgrade01'; @@ -291,6 +284,14 @@ test('real IndexedDB v5 data survives the v7 tombstone and AI-cache store upgrad }; request.onerror = () => reject(request.error); }); + }); + + // Seed before opening feed.html: opening it first can race the worker's v7 + // initialization and make Chromium reject the intentional v5 open. + const { page, pageErrors } = await openFeed(context); + const preserved = await page.evaluate(async () => { + const channelId = 'UCv5upgradefixture00000001'; + const videoId = 'V5Upgrade01'; const [history, subscription, feedVideo, syncState] = await Promise.all([ ytIndexedDBStorage.getVideo('LegacyHistory01'), diff --git a/tests/fixtures/youtube-pages/captures/controlled-channel-header/metadata.json b/tests/fixtures/youtube-pages/captures/controlled-channel-header/metadata.json index 6c9581b..2516c9e 100644 --- a/tests/fixtures/youtube-pages/captures/controlled-channel-header/metadata.json +++ b/tests/fixtures/youtube-pages/captures/controlled-channel-header/metadata.json @@ -2,7 +2,7 @@ "name": "controlled-channel-header", "url": "https://www.youtube.com/@NerdroticDaily", "purpose": "Handle-based channel-header DOM fixture for the re:Watch local-subscribe companion attachment next to YouTube's own Subscribe control.", - "capturedAt": "2026-08-16T03:36:07.484Z", + "capturedAt": "2026-08-16T05:50:48.732Z", "readySelector": "#subscribe-button, ytd-c4-tabbed-header-renderer #buttons", "viewport": { "width": 1440, diff --git a/tests/fixtures/youtube-pages/captures/controlled-channel-header/page.html b/tests/fixtures/youtube-pages/captures/controlled-channel-header/page.html index 992e453..3dae7cd 100644 --- a/tests/fixtures/youtube-pages/captures/controlled-channel-header/page.html +++ b/tests/fixtures/youtube-pages/captures/controlled-channel-header/page.html @@ -1,4 +1,4 @@ - + @@ -28,7 +28,7 @@ @@ -147,7 +147,7 @@
-
2x
If playback doesn't begin shortly, try restarting your device.
•
0:02 / 1:49
•Watch full video
+
If playback doesn't begin shortly, try restarting your device.
•
0:02 / 1:49
•Watch full video
@@ -178,7 +178,7 @@ - + @@ -190,7 +190,7 @@
-
+
LYRICS: I’m Black Snape @@ -244,7 +244,7 @@ -
+
2x
If playback doesn't begin shortly, try restarting your device.
•
/
•Watch full video
@@ -690,7 +690,7 @@

-