From 45533fe5ec014af89f6875fa349a4708ff82fc8a Mon Sep 17 00:00:00 2001 From: sizwinz Date: Fri, 31 Jul 2026 00:32:25 +0530 Subject: [PATCH 01/10] feat(extension): expand permissions and improve media detection Switch host permissions to and add generic content script for broader site compatibility. Add configurable state options for URL display, action wording, and RPC whitelist. Enhance YouTube metadata extraction with additional DOM selectors and introduce channel avatar support. Implement Discord rate limit handling and improve text truncation. --- manifest.json | 16 +- src/extension/background.js | 265 +++++++++++++++++++++++++++---- src/extension/content-generic.js | 182 +++++++++++++++++++++ src/extension/content-youtube.js | 123 ++++++++++---- src/extension/popup.html | 33 +++- src/extension/popup.js | 110 ++++++++++++- 6 files changed, 663 insertions(+), 66 deletions(-) create mode 100644 src/extension/content-generic.js diff --git a/manifest.json b/manifest.json index 5173039..0adf684 100644 --- a/manifest.json +++ b/manifest.json @@ -5,12 +5,7 @@ "description": "Shows the current YouTube or SoundCloud playback in Rich Presence.", "permissions": ["identity", "storage"], "host_permissions": [ - "*://*.youtube.com/*", - "*://youtu.be/*", - "*://soundcloud.com/*", - "*://*.soundcloud.com/*", - "https://discord.com/api/*", - "https://discord.com/oauth2/*" + "" ], "icons": { "16": "icons/16.png", @@ -20,7 +15,7 @@ "128": "icons/128.png" }, "background": { - "service_worker": "src/extension/background.js" + "scripts": ["src/extension/config.js", "src/extension/background.js"] }, "content_scripts": [ { @@ -32,6 +27,13 @@ "matches": ["*://soundcloud.com/*", "*://*.soundcloud.com/*"], "js": ["src/extension/content-soundcloud.js"], "run_at": "document_idle" + }, + { + "matches": [""], + "js": ["src/extension/content-generic.js"], + "all_frames": true, + "match_about_blank": true, + "run_at": "document_idle" } ], "action": { diff --git a/src/extension/background.js b/src/extension/background.js index b2f878d..ae54830 100644 --- a/src/extension/background.js +++ b/src/extension/background.js @@ -10,10 +10,15 @@ const DEFAULT_STATE = { authenticated: false, connected: false, enabled: true, + showUrl: true, + showYoutubeChannel: true, + actionWording: "Auto", siteEnabled: { youtube: true, soundcloud: true }, + rpcWhitelist: ["youtube.com", "soundcloud.com"], + mediaOverrides: {}, lastError: "", lastVideo: null, lastMediaPage: null, @@ -97,6 +102,12 @@ async function requestDiscord(path, options = {}) { if (response.status === 204) return null; const body = await response.json().catch(() => ({})); + if (response.status === 429) { + const retryAfter = body.retry_after || 5; + state = { ...state, lastError: `Rate limited by Discord. Retrying in ${Math.ceil(retryAfter)}s...` }; + saveState(); + return null; + } if (!response.ok) { throw new Error(body.message || `Discord API error ${response.status}`); } @@ -219,7 +230,18 @@ async function completeDiscordLogin(redirectUrl) { function truncate(text, maxLength) { if (!text) return ""; - return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}...`; + const cleaned = String(text).replace(/[\r\n\t]+/g, " ").trim(); + return cleaned.length <= maxLength ? cleaned : `${cleaned.slice(0, maxLength - 1)}...`; +} + +function isValidHttpUrl(string) { + if (!string) return false; + try { + const url = new URL(string); + return url.protocol === "http:" || url.protocol === "https:"; + } catch (_) { + return false; + } } function makeActivity(video) { @@ -228,35 +250,92 @@ function makeActivity(video) { const remainingMs = Math.max(video.timeLeft || 0, 0) * 1000; const playing = !video.paused && !video.ended && video.duration > 0; const platform = video.platform || "Media"; - const action = video.action || (platform === "SoundCloud" ? "Listen" : "Watch"); - const activityType = platform === "SoundCloud" ? 2 : 3; - const largeImage = + const selectedWording = video.actionWording || state.actionWording || "Auto"; + + let activityType = 3; + let wordingPrefix = ""; + if (selectedWording === "Watching") { + activityType = 3; + wordingPrefix = "Watching"; + } else if (selectedWording === "Listening") { + activityType = 2; + wordingPrefix = "Listening to"; + } else if (selectedWording === "Browsing" || selectedWording === "Idling") { + activityType = 0; + wordingPrefix = selectedWording === "Browsing" ? "Browsing" : "Idling on"; + } else if (selectedWording === "None") { + activityType = 0; + wordingPrefix = ""; + } else { + activityType = platform === "SoundCloud" || video.action === "Listen" ? 2 : 3; + wordingPrefix = activityType === 2 ? "Listening to" : "Watching"; + } + + const isDomainName = (str) => Boolean(str && (str.includes(".") || str === video.domain)); + const showUrl = state.showUrl !== false; + const allowChannel = video.source !== "youtube" || state.showYoutubeChannel !== false; + const rawAuthor = allowChannel ? video.author : ""; + const displayAuthor = !showUrl && isDomainName(rawAuthor) ? "" : rawAuthor; + + const speedText = video.playbackRate && video.playbackRate !== 1 ? ` (${video.playbackRate}x speed)` : ""; + + let stateText = ""; + if (playing) { + if (displayAuthor) { + stateText = `by ${displayAuthor}${speedText}`; + } else { + if (wordingPrefix) { + stateText = showUrl ? `${wordingPrefix} on ${platform}${speedText}` : `${wordingPrefix}${speedText}`; + } else { + stateText = showUrl ? `${platform}${speedText}` : speedText.trim(); + } + } + } else { + stateText = showUrl ? `Paused on ${platform}` : "Paused"; + } + + const rawLargeImage = video.largeImage || (platform === "YouTube" && video.videoId ? `youtube:${video.videoId}` : undefined); - const stateText = video.author ? `by ${video.author}` : platform; + const validLargeImage = rawLargeImage && (rawLargeImage.startsWith("youtube:") || isValidHttpUrl(rawLargeImage)) ? rawLargeImage : undefined; + const validSmallImage = isValidHttpUrl(video.channelAvatar) ? video.channelAvatar : undefined; + const includeUrl = showUrl && isValidHttpUrl(video.url); + + const assets = { + large_image: validLargeImage, + large_text: truncate(video.title || platform, 128) + }; + + if (validSmallImage) { + assets.small_image = validSmallImage; + assets.small_text = truncate(displayAuthor || platform, 128); + } + + const buttonLabel = wordingPrefix ? `${wordingPrefix} on ${platform}` : platform; + const activity = { application_id: getClientId(), platform: "desktop", supported_platforms: ["desktop"], type: activityType, name: platform, - details: truncate(video.title || `${action}ing ${platform}`, 128), - state: truncate(playing ? stateText : `${stateText} - Paused`, 128), - assets: { - large_image: largeImage, - large_text: truncate(video.title || platform, 128), - large_url: video.url - }, - buttons: [ + details: truncate(video.title || `${wordingPrefix} ${platform}`.trim(), 128), + state: truncate(stateText, 128), + assets + }; + + if (includeUrl) { + activity.assets.large_url = video.url; + activity.buttons = [ { - label: `${action} on ${platform}`, + label: truncate(buttonLabel, 32), url: video.url } - ], - metadata: { + ]; + activity.metadata = { button_urls: [video.url] - } - }; + }; + } if (playing) { activity.timestamps = { @@ -268,13 +347,27 @@ function makeActivity(video) { return activity; } +function normalizeUrl(urlStr) { + if (!urlStr) return ""; + let clean = urlStr.trim(); + if (clean && !/^https?:\/\//i.test(clean)) { + clean = `https://${clean}`; + } + return isValidHttpUrl(clean) ? clean : urlStr; +} + function presenceKey(video) { return JSON.stringify({ platform: video.platform || "YouTube", id: video.mediaId || video.videoId, title: video.title, author: video.author, + url: video.url, paused: video.paused, + showUrl: state.showUrl !== false, + showYoutubeChannel: state.showYoutubeChannel !== false, + actionWording: state.actionWording || "Auto", + playbackRate: video.playbackRate || 1, bucket: Math.floor((video.currentTime || 0) / 10) }); } @@ -376,17 +469,24 @@ async function updatePresence(video, force = false) { }; if (headlessToken) body.token = headlessToken; - const result = await requestDiscord("/users/@me/headless-sessions", { - method: "POST", - body: JSON.stringify(body) - }); + let result = null; + try { + result = await requestDiscord("/users/@me/headless-sessions", { + method: "POST", + body: JSON.stringify(body) + }); + } catch (error) { + state = { ...state, connected: false, lastError: error.message }; + saveState(); + return; + } if (generation !== presenceGeneration || !state.enabled || isSuppressed(video)) { await clearPresence({ waitForWrites: false }).catch(() => {}); return; } - rememberHeadlessToken(result.token || headlessToken); + rememberHeadlessToken(result?.token || headlessToken); lastPresenceKey = key; lastPresenceSentAt = now; state = { ...state, authenticated: true, connected: true, lastError: "" }; @@ -448,16 +548,53 @@ async function logoutDiscord() { saveState(); } +function applyMediaOverrides(video) { + if (!video) return video; + const domain = video.domain || video.source; + const overrides = state.mediaOverrides?.[domain]; + if (!overrides) return video; + + return { + ...video, + platform: overrides.platform || video.platform, + title: overrides.title || video.title, + thumbnail: overrides.image || video.thumbnail, + largeImage: overrides.image || video.largeImage, + url: overrides.url ? normalizeUrl(overrides.url) : video.url + }; +} + +function isGenericTitleString(str) { + if (!str) return true; + const s = str.trim().toLowerCase(); + return /^(player|video|watch|stream|play|html5 player|jw player|video player|\w+\.php|\w+\.html)$/i.test(s) || s.endsWith(".php") || s.endsWith(".html"); +} + function handleVideoUpdate(video) { const source = normalizeSource(video.source || video.platform || "youtube"); const previous = mediaBySource[source]; + const pageTitle = state.lastMediaPage?.title; + + let resolvedTitle = video.title; + if (isGenericTitleString(resolvedTitle) && pageTitle && !isGenericTitleString(pageTitle)) { + resolvedTitle = pageTitle; + } + + const isSameMedia = previous && ( + (video.videoId && previous.videoId === video.videoId) || + (video.mediaId && previous.mediaId === video.mediaId) || + (video.url && previous.url === video.url) + ); + const baseMedia = isSameMedia ? previous : {}; + const becamePlaying = previous ? previous.paused && !video.paused : !video.paused; - const media = { - ...previous, + const media = applyMediaOverrides({ + ...baseMedia, ...video, + title: resolvedTitle, source, - lastStartedAt: video.lastStartedAt || (becamePlaying ? Date.now() : previous?.lastStartedAt || video.updatedAt || Date.now()) - }; + lastStartedAt: video.lastStartedAt || (becamePlaying ? Date.now() : baseMedia?.lastStartedAt || video.updatedAt || Date.now()) + }); const suppressed = suppressedBySource[source]; if ( !media.paused && @@ -552,6 +689,45 @@ browserApi.runtime.onMessage.addListener((message, _sender, sendResponse) => { return true; } + if (message.type === "set-show-url") { + state = { ...state, showUrl: Boolean(message.showUrl), lastError: "" }; + saveState(); + syncActivePresence(true) + .then(() => sendResponse(state)) + .catch((error) => { + state = { ...state, connected: false, lastError: error.message }; + saveState(); + sendResponse(state); + }); + return true; + } + + if (message.type === "set-show-youtube-channel") { + state = { ...state, showYoutubeChannel: Boolean(message.showYoutubeChannel), lastError: "" }; + saveState(); + syncActivePresence(true) + .then(() => sendResponse(state)) + .catch((error) => { + state = { ...state, connected: false, lastError: error.message }; + saveState(); + sendResponse(state); + }); + return true; + } + + if (message.type === "set-action-wording") { + state = { ...state, actionWording: message.wording || "Auto", lastError: "" }; + saveState(); + syncActivePresence(true) + .then(() => sendResponse(state)) + .catch((error) => { + state = { ...state, connected: false, lastError: error.message }; + saveState(); + sendResponse(state); + }); + return true; + } + if (message.type === "set-site-enabled") { const source = normalizeSource(message.source); state = { @@ -630,12 +806,40 @@ browserApi.runtime.onMessage.addListener((message, _sender, sendResponse) => { return true; } + if (message.type === "toggle-domain-whitelist") { + const domain = message.domain; + const whitelist = state.rpcWhitelist || ["youtube.com", "soundcloud.com"]; + const exists = whitelist.includes(domain); + const updatedWhitelist = exists ? whitelist.filter((d) => d !== domain) : [...whitelist, domain]; + state = { ...state, rpcWhitelist: updatedWhitelist }; + browserApi.storage.local.set({ rpcWhitelist: updatedWhitelist }); + sendResponse({ ok: true, isWhitelisted: !exists, whitelist: updatedWhitelist }); + return true; + } + + if (message.type === "set-domain-override") { + const { domain, title, image, url, platform } = message; + const overrides = { ...(state.mediaOverrides || {}) }; + if (!title && !image && !url && !platform) { + delete overrides[domain]; + } else { + overrides[domain] = { title, image, url, platform }; + } + state = { ...state, mediaOverrides: overrides }; + browserApi.storage.local.set({ mediaOverrides: overrides }); + syncActivePresence(true); + sendResponse({ ok: true, overrides }); + return true; + } + return false; }); browserApi.runtime.onInstalled.addListener(() => { - browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession"], (result) => { + browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession", "rpcWhitelist", "mediaOverrides"], (result) => { state = { ...DEFAULT_STATE, ...(result.rpcState || {}) }; + state.rpcWhitelist = result.rpcWhitelist || ["youtube.com", "soundcloud.com"]; + state.mediaOverrides = result.mediaOverrides || {}; state.siteEnabled = { ...DEFAULT_STATE.siteEnabled, ...(state.siteEnabled || {}) }; tokens = result.discordTokens || null; headlessToken = result.rpcSession?.headlessToken || ""; @@ -646,8 +850,10 @@ browserApi.runtime.onInstalled.addListener(() => { }); }); -browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession"], (result) => { +browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession", "rpcWhitelist", "mediaOverrides"], (result) => { state = { ...DEFAULT_STATE, ...(result.rpcState || {}) }; + state.rpcWhitelist = result.rpcWhitelist || ["youtube.com", "soundcloud.com"]; + state.mediaOverrides = result.mediaOverrides || {}; state.siteEnabled = { ...DEFAULT_STATE.siteEnabled, ...(state.siteEnabled || {}) }; tokens = result.discordTokens || null; headlessToken = result.rpcSession?.headlessToken || ""; @@ -656,3 +862,4 @@ browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession"], (resul state = { ...state, authenticated: Boolean(tokens?.accessToken), connected: Boolean(tokens?.accessToken) }; saveState(); }); + diff --git a/src/extension/content-generic.js b/src/extension/content-generic.js new file mode 100644 index 0000000..b8f3952 --- /dev/null +++ b/src/extension/content-generic.js @@ -0,0 +1,182 @@ +(function () { + const browserApi = typeof chrome !== "undefined" ? chrome : browser; + const SEND_INTERVAL_MS = 1000; + const FORCE_INTERVAL_MS = 15000; + let lastPayloadKey = ""; + let lastForcedAt = 0; + let lastStartedAt = 0; + let isWhitelisted = false; + + function cleanTitle(rawTitle) { + if (!rawTitle) return ""; + return rawTitle + .replace(/\s*[\-\|]\s*(Watch|Free|Online|HD|1080p|Full Movie|Streaming).*$/i, "") + .replace(/\s*\((Watch|Free|Online|HD|1080p)\)/gi, "") + .trim(); + } + + function getMediaTitle() { + const ogTitle = document.querySelector('meta[property="og:title"]')?.getAttribute("content"); + const rawTitle = ogTitle || document.title || "Video"; + return cleanTitle(rawTitle); + } + + function toAbsoluteUrl(url) { + if (!url) return ""; + try { + return new URL(url, window.location.origin).href; + } catch (_) { + return ""; + } + } + + function getMediaPoster() { + const ogImage = document.querySelector('meta[property="og:image"]')?.getAttribute("content"); + const twitterImage = document.querySelector('meta[name="twitter:image"]')?.getAttribute("content"); + const raw = ogImage || twitterImage || "/favicon.ico"; + return toAbsoluteUrl(raw); + } + + function getActiveMediaElement() { + const elements = Array.from(document.querySelectorAll("video, audio")); + return elements.find((el) => !el.paused && Number.isFinite(el.duration) && el.duration > 0) || elements[0] || null; + } + + function getEffectiveDomain() { + let host = window.location.hostname; + if (window.self !== window.top && document.referrer) { + try { + const refUrl = new URL(document.referrer); + if (refUrl.hostname) host = refUrl.hostname; + } catch (_) {} + } + return host.replace(/^www\./, ""); + } + + function getEffectiveUrl() { + if (window.self !== window.top && document.referrer) { + return document.referrer; + } + return window.location.href; + } + + function getPayload() { + const media = getActiveMediaElement(); + if (!media) return null; + + const duration = Number.isFinite(media.duration) ? media.duration : 0; + const currentTime = Number.isFinite(media.currentTime) ? media.currentTime : 0; + const domain = getEffectiveDomain(); + + return { + platform: domain, + source: "generic", + domain: domain, + action: media.tagName === "AUDIO" ? "Listen" : "Watch", + url: getEffectiveUrl(), + mediaId: window.location.pathname, + title: getMediaTitle(), + author: domain, + thumbnail: getMediaPoster(), + largeImage: getMediaPoster(), + currentTime, + duration, + timeLeft: Math.max(duration - currentTime, 0), + paused: media.paused, + ended: media.ended, + playbackRate: media.playbackRate || 1, + lastStartedAt, + updatedAt: Date.now() + }; + } + + function sendUpdate(force = false) { + if (!isWhitelisted) return; + const payload = getPayload(); + if (!payload || !payload.title) { + if (lastPayloadKey) { + lastPayloadKey = ""; + browserApi.runtime.sendMessage({ type: "media-clear", source: "generic" }, () => { + void browserApi.runtime.lastError; + }); + } + return; + } + + const payloadKey = JSON.stringify({ + domain: payload.domain, + title: payload.title, + paused: payload.paused, + ended: payload.ended, + second: Math.floor(payload.currentTime) + }); + + if (!force && payloadKey === lastPayloadKey) return; + lastPayloadKey = payloadKey; + + browserApi.runtime.sendMessage({ type: "media-update", media: payload }, () => { + void browserApi.runtime.lastError; + }); + } + + function announceSeen() { + const domain = getEffectiveDomain(); + const mediaTitle = getMediaTitle(); + browserApi.runtime.sendMessage( + { + type: "media-seen", + source: "generic", + platform: domain, + url: getEffectiveUrl(), + hasMediaElement: Boolean(getActiveMediaElement()), + title: mediaTitle, + updatedAt: Date.now() + }, + () => { + void browserApi.runtime.lastError; + } + ); + } + + function checkWhitelist() { + browserApi.storage.local.get(["rpcWhitelist"], (result) => { + const whitelist = result.rpcWhitelist || ["youtube.com", "soundcloud.com"]; + const currentDomain = getEffectiveDomain(); + const isAllowed = whitelist.some((d) => currentDomain.endsWith(d)); + if (isAllowed !== isWhitelisted) { + isWhitelisted = isAllowed; + if (isWhitelisted) { + sendUpdate(true); + announceSeen(); + } + } + }); + } + + function bindEvents() { + const media = getActiveMediaElement(); + if (!media || media.dataset.discordRpcGenericBound === "true") return; + + media.dataset.discordRpcGenericBound = "true"; + ["play", "pause", "seeked", "ratechange", "ended", "loadedmetadata"].forEach((evt) => { + media.addEventListener(evt, () => { + if (evt === "play") lastStartedAt = Date.now(); + sendUpdate(true); + }); + }); + } + + checkWhitelist(); + announceSeen(); + + setInterval(() => { + checkWhitelist(); + if (isWhitelisted) { + bindEvents(); + const shouldForce = Date.now() - lastForcedAt > FORCE_INTERVAL_MS; + if (shouldForce) lastForcedAt = Date.now(); + sendUpdate(shouldForce); + announceSeen(); + } + }, SEND_INTERVAL_MS); +})(); diff --git a/src/extension/content-youtube.js b/src/extension/content-youtube.js index 88244bf..7d65325 100644 --- a/src/extension/content-youtube.js +++ b/src/extension/content-youtube.js @@ -27,28 +27,79 @@ function getVideoId() { function getAuthor() { return ( + text("ytd-watch-metadata #owner #channel-name a") || text("ytd-watch-metadata ytd-channel-name a") || text("#owner #channel-name a") || + text("#owner #channel-name yt-formatted-string") || + text("ytd-reel-player-header-renderer #channel-name a") || text(".slim-owner-byline") || attr('link[itemprop="name"]', "content") || - attr('span[itemprop="author"] link[itemprop="name"]', "content") + attr('span[itemprop="author"] link[itemprop="name"]', "content") || + text(".ytp-title-channel-name") ); } function getTitle() { - return ( + const title = ( + text("ytd-watch-metadata #title yt-formatted-string") || text("ytd-watch-metadata h1 yt-formatted-string") || + text("ytd-watch-metadata h1") || + text("h1.ytd-watch-metadata") || text("h1.title") || + text("#title h1 yt-formatted-string") || + text("ytd-reel-player-header-renderer .title") || + text("h1.ytd-video-primary-info-renderer") || + text(".ytp-title-link") || attr('meta[property="og:title"]', "content") || - attr('meta[name="title"]', "content") || - document.title.replace(/\s+-\s+YouTube$/, "") + attr('meta[name="title"]', "content") ); + + if (title && title.toLowerCase() !== "youtube") return title; + + return document.title + .replace(/^\(\d+\)\s*/, "") + .replace(/\s+-\s+YouTube$/i, "") + .trim(); } function getThumbnail(videoId) { return videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : ""; } +function getChannelAvatar() { + const selectors = [ + "ytd-watch-metadata #owner img#img", + "ytd-watch-metadata #owner #avatar img", + "ytd-watch-metadata #owner img", + "ytd-video-owner-renderer #avatar img", + "ytd-video-owner-renderer img", + "#owner #avatar img", + "ytd-reel-player-header-renderer img", + ".ytp-title-channel-logo img" + ]; + for (const s of selectors) { + const img = document.querySelector(s); + if (img) { + const src = img.src || img.getAttribute("src") || img.getAttribute("data-src") || ""; + if (src && !src.startsWith("data:")) { + return src.startsWith("//") ? `https:${src}` : src; + } + } + } + return ""; +} + +function getCategoryInfo() { + const url = window.location.href; + if (url.includes("music.youtube.com")) { + return { platform: "YouTube Music", action: "Listen" }; + } + if (url.includes("/shorts/")) { + return { platform: "YouTube Short", action: "Watch" }; + } + return { platform: "YouTube", action: "Watch" }; +} + function getVideoPayload() { const video = document.querySelector("video"); const videoId = getVideoId(); @@ -56,16 +107,18 @@ function getVideoPayload() { const duration = Number.isFinite(video.duration) ? video.duration : 0; const currentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; + const category = getCategoryInfo(); return { - platform: "YouTube", + platform: category.platform, source: "youtube", - action: "Watch", + action: category.action, url: window.location.href, videoId, mediaId: videoId, title: getTitle(), author: getAuthor(), + channelAvatar: getChannelAvatar(), thumbnail: getThumbnail(videoId), currentTime, duration, @@ -124,20 +177,45 @@ function announceSeen() { ); } +let lastUrl = location.href; +let lastVideoId = ""; + function bindVideoEvents() { const video = document.querySelector("video"); - if (!video || video.dataset.discordRpcBound === "true") return; + const currentVideoId = getVideoId(); + if (!video) return; + + if (video.dataset.discordRpcBoundId !== currentVideoId) { + video.dataset.discordRpcBoundId = currentVideoId; + ["play", "pause", "seeked", "ratechange", "ended", "loadedmetadata"].forEach((eventName) => { + video.addEventListener(eventName, () => { + if (eventName === "play") lastStartedAt = Date.now(); + sendUpdate(true); + }); + }); + } +} - video.dataset.discordRpcBound = "true"; - ["play", "pause", "seeked", "ratechange", "ended", "loadedmetadata"].forEach((eventName) => { - video.addEventListener(eventName, () => { - if (eventName === "play") lastStartedAt = Date.now(); - sendUpdate(true); +function handleNavigation() { + const currentVideoId = getVideoId(); + if (location.href !== lastUrl || (currentVideoId && currentVideoId !== lastVideoId)) { + lastUrl = location.href; + lastVideoId = currentVideoId; + lastPayloadKey = ""; + lastStartedAt = Date.now(); + + [100, 400, 1000, 2000, 3500].forEach((delay) => { + setTimeout(() => { + bindVideoEvents(); + announceSeen(); + sendUpdate(true); + }, delay); }); - }); + } } setInterval(() => { + handleNavigation(); bindVideoEvents(); const shouldForce = Date.now() - lastForcedAt > FORCE_INTERVAL_MS; if (shouldForce) lastForcedAt = Date.now(); @@ -145,13 +223,8 @@ setInterval(() => { announceSeen(); }, SEND_INTERVAL_MS); -let lastUrl = location.href; new MutationObserver(() => { - if (location.href !== lastUrl) { - lastUrl = location.href; - lastPayloadKey = ""; - setTimeout(() => sendUpdate(true), 700); - } + handleNavigation(); bindVideoEvents(); }).observe(document.documentElement, { childList: true, subtree: true }); @@ -159,13 +232,7 @@ bindVideoEvents(); announceSeen(); sendUpdate(true); -["yt-navigate-finish", "yt-page-data-updated", "yt-player-updated"].forEach((eventName) => { - window.addEventListener(eventName, () => { - lastPayloadKey = ""; - setTimeout(() => { - bindVideoEvents(); - announceSeen(); - sendUpdate(true); - }, 250); - }); +["yt-navigate-start", "yt-navigate-finish", "yt-page-data-updated", "yt-player-updated", "spfdone"].forEach((eventName) => { + window.addEventListener(eventName, handleNavigation); + document.addEventListener(eventName, handleNavigation); }); diff --git a/src/extension/popup.html b/src/extension/popup.html index 4549c33..cec9b1e 100644 --- a/src/extension/popup.html +++ b/src/extension/popup.html @@ -38,8 +38,39 @@

No media detected

SoundCloud + + + +
+ +

+
+
+

Customize Media Info

+ + + + + +
+ + +
-
+
diff --git a/src/extension/popup.js b/src/extension/popup.js index 3696015..261c90e 100644 --- a/src/extension/popup.js +++ b/src/extension/popup.js @@ -13,8 +13,23 @@ const logout = document.getElementById("logout"); const clearPresence = document.getElementById("clearPresence"); const youtubeEnabled = document.getElementById("youtubeEnabled"); const soundcloudEnabled = document.getElementById("soundcloudEnabled"); +const showUrlEnabled = document.getElementById("showUrlEnabled"); +const showYoutubeChannelEnabled = document.getElementById("showYoutubeChannelEnabled"); const callbackForm = document.getElementById("callbackForm"); const callbackUrl = document.getElementById("callbackUrl"); + +const toggleWhitelist = document.getElementById("toggleWhitelist"); +const domainStatus = document.getElementById("domainStatus"); +const actionWordingSelect = document.getElementById("actionWordingSelect"); +const overrideTitle = document.getElementById("overrideTitle"); +const overrideImage = document.getElementById("overrideImage"); +const overrideUrl = document.getElementById("overrideUrl"); +const overridePlatform = document.getElementById("overridePlatform"); +const saveOverride = document.getElementById("saveOverride"); +const resetOverride = document.getElementById("resetOverride"); + +let currentDomain = ""; + logout.classList.add("secondary"); function formatTime(seconds) { @@ -25,10 +40,35 @@ function formatTime(seconds) { return `${minutes}:${rest}`; } +function updateDomainUI(state) { + if (!currentDomain) return; + const whitelist = state.rpcWhitelist || ["youtube.com", "soundcloud.com"]; + const isWhitelisted = whitelist.some((d) => currentDomain.endsWith(d)); + + if (isWhitelisted) { + toggleWhitelist.textContent = `Disable RPC on ${currentDomain}`; + domainStatus.textContent = `RPC Enabled for ${currentDomain}`; + } else { + toggleWhitelist.textContent = `Enable RPC on ${currentDomain}`; + domainStatus.textContent = `RPC Disabled for ${currentDomain}`; + } + + const override = state.mediaOverrides?.[currentDomain]; + if (override && document.activeElement !== overrideTitle && document.activeElement !== overrideImage && document.activeElement !== overrideUrl && document.activeElement !== overridePlatform) { + overrideTitle.value = override.title || ""; + overrideImage.value = override.image || ""; + overrideUrl.value = override.url || ""; + overridePlatform.value = override.platform || ""; + } +} + function render(state) { enabled.checked = state.enabled !== false; youtubeEnabled.checked = state.siteEnabled?.youtube !== false; soundcloudEnabled.checked = state.siteEnabled?.soundcloud !== false; + showUrlEnabled.checked = state.showUrl !== false; + showYoutubeChannelEnabled.checked = state.showYoutubeChannel !== false; + actionWordingSelect.value = state.actionWording || "Auto"; dot.classList.toggle("connected", Boolean(state.connected)); statusText.textContent = state.authenticated ? `Connected${state.user?.username ? ` as ${state.user.username}` : ""}` @@ -60,6 +100,8 @@ function render(state) { error.textContent = state.lastError || ""; callbackForm.classList.toggle("visible", Boolean(state.lastError && state.lastError.includes("allizom.org"))); + + updateDomainUI(state); } function setSiteEnabled(source, checked) { @@ -83,6 +125,27 @@ enabled.addEventListener("change", () => { }); }); +showUrlEnabled.addEventListener("change", () => { + browserApi.runtime.sendMessage({ type: "set-show-url", showUrl: showUrlEnabled.checked }, (state) => { + if (browserApi.runtime.lastError || !state) return refresh(); + render(state); + }); +}); + +showYoutubeChannelEnabled.addEventListener("change", () => { + browserApi.runtime.sendMessage({ type: "set-show-youtube-channel", showYoutubeChannel: showYoutubeChannelEnabled.checked }, (state) => { + if (browserApi.runtime.lastError || !state) return refresh(); + render(state); + }); +}); + +actionWordingSelect.addEventListener("change", () => { + browserApi.runtime.sendMessage({ type: "set-action-wording", wording: actionWordingSelect.value }, (state) => { + if (browserApi.runtime.lastError || !state) return refresh(); + render(state); + }); +}); + login.addEventListener("click", () => { login.disabled = true; browserApi.runtime.sendMessage({ type: "login-discord" }, (state) => { @@ -113,6 +176,41 @@ soundcloudEnabled.addEventListener("change", () => { setSiteEnabled("soundcloud", soundcloudEnabled.checked); }); +toggleWhitelist.addEventListener("click", () => { + if (!currentDomain) return; + browserApi.runtime.sendMessage({ type: "toggle-domain-whitelist", domain: currentDomain }, () => { + refresh(); + }); +}); + +saveOverride.addEventListener("click", () => { + if (!currentDomain) return; + const newTitle = overrideTitle.value.trim(); + const newImage = overrideImage.value.trim(); + const newUrl = overrideUrl.value.trim(); + const newPlatform = overridePlatform.value.trim(); + browserApi.runtime.sendMessage( + { type: "set-domain-override", domain: currentDomain, title: newTitle, image: newImage, url: newUrl, platform: newPlatform }, + () => { + refresh(); + } + ); +}); + +resetOverride.addEventListener("click", () => { + if (!currentDomain) return; + overrideTitle.value = ""; + overrideImage.value = ""; + overrideUrl.value = ""; + overridePlatform.value = ""; + browserApi.runtime.sendMessage( + { type: "set-domain-override", domain: currentDomain, title: "", image: "", url: "", platform: "" }, + () => { + refresh(); + } + ); +}); + callbackForm.addEventListener("submit", (event) => { event.preventDefault(); const url = callbackUrl.value.trim(); @@ -125,5 +223,15 @@ callbackForm.addEventListener("submit", (event) => { }); }); -refresh(); +browserApi.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs && tabs[0] && tabs[0].url) { + try { + const url = new URL(tabs[0].url); + currentDomain = url.hostname.replace(/^www\./, ""); + } catch (_e) {} + } + refresh(); +}); + setInterval(refresh, 1000); + From 12eecc616ad471cbfa559a2e7d71617a1fd08fed Mon Sep 17 00:00:00 2001 From: sizwinz Date: Fri, 31 Jul 2026 00:50:11 +0530 Subject: [PATCH 02/10] docs(privacy): revise privacy policy to clarify OAuth scopes, data usage, and user controls Update last modified date to July 31, 2026. Correct the extension description to reflect support for all user-whitelisted streaming sites, not just YouTube and SoundCloud. Restructure the previously unstructured Discord OAuth scope note into a clear explanation of why the sdk.social_layer_presence scope is required given Discord's locked activity-writing permissions. Explicitly list all playback metadata the extension collects and confirm it is only used to update Discord Rich Presence via authorized API calls. Add documentation for all user-facing privacy control options, and clarify that all extension settings are stored locally with no external data sharing. --- PRIVACY.md | 75 +++++++++++++++++------------------- README.md | 75 ++++++++++++++++++++++++++++++++---- assets/discord-rpc-1.png | Bin 0 -> 34022 bytes assets/discord-rpc-2.png | Bin 0 -> 25224 bytes assets/discord-rpc-3.png | Bin 0 -> 13883 bytes assets/popup-screenshot.png | Bin 0 -> 38759 bytes 6 files changed, 102 insertions(+), 48 deletions(-) create mode 100644 assets/discord-rpc-1.png create mode 100644 assets/discord-rpc-2.png create mode 100644 assets/discord-rpc-3.png create mode 100644 assets/popup-screenshot.png diff --git a/PRIVACY.md b/PRIVACY.md index 2a00436..fd73687 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,72 +1,67 @@ # Privacy Policy -Last updated: May 27, 2026 +Last updated: July 31, 2026 ## Overview -Neurobox Media Discord RPC is a browser extension that shows your currently playing YouTube or SoundCloud media as Discord Rich Presence. +Neurobox Media Discord RPC is a browser extension that displays your active media playback (from YouTube, SoundCloud, or any whitelisted video/audio streaming website) as Discord Rich Presence. -The extension does not sell personal data, does not run ads, and does not use analytics. +The extension does not sell personal data, does not serve advertisements, and does not collect tracking telemetry or analytics. -## Quick Note (Important) +--- -The Discord OAuth scope of `sdk.social_layer_presence` requests a plethora of other unrelated permissions which many users would consider to be potentially harmful. That, however, is not the case. The scopes that allow the app to set your activity (`rpc.activities.write` and `activities.write`) are currently LOCKED by Discord. They do not give out access to those via applications or support requests, and only a select few Developers are whitelisted to use it. But, `sdk.social_layer_presence` (which is not locked) does implicitly grant developers access to said scope alongside many others that are not available individually. So, unfortunately, in order to set the user's activity, the extension needs to request a lot of unrelated permissions. I apologize for this in advance and can only hope that Discord allows developers to use these scopes without any restrictions in the coming future. +## Important Note Regarding Discord OAuth Scopes -## Data the Extension Reads +The Discord OAuth scope `sdk.social_layer_presence` requests permissions that sound broad. Discord's individual activity-writing scopes (`rpc.activities.write` and `activities.write`) are locked by Discord and reserved for whitelisted developer applications. The `sdk.social_layer_presence` scope allows the extension to create and update your Discord activity via headless sessions without running a local RPC desktop server. -When enabled, the extension reads limited playback information from supported media sites: +--- -- Site name, such as YouTube or SoundCloud -- Media title -- Channel, uploader, or artist name -- Current playback time and duration -- Playback state, such as playing or paused -- Media URL -- Artwork or thumbnail URL, when available +## Data the Extension Reads -The extension reads this information only from supported pages that match its declared permissions. +When media tracking is enabled and a website is whitelisted by the user, the extension reads basic playback metadata: -## How Data Is Used +- Platform or site domain name (e.g. YouTube, SoundCloud, or custom site) +- Media title and uploader/artist name +- Playback position, total duration, and playback state (playing, paused, speed) +- Page URL and poster artwork URL (when available) -Playback information is used to create or update your Discord Rich Presence activity. +The extension only injects media listeners on sites authorized by the user via the domain whitelist. -To do this, the extension sends the current activity data to Discord using Discord's OAuth-authorized API. Discord may display this activity to other users according to your Discord privacy and activity settings. +--- -## Discord Authentication +## User Privacy & Control Options -The extension uses Discord OAuth to request permission for presence-related functionality. OAuth tokens are stored locally in your browser extension storage so the extension can keep your Rich Presence updated while you use it. +You have full control over what data is sent to Discord: -The extension does not receive or store your Discord password. +- **Show URL Toggle**: When disabled, page URLs and domain names are stripped from Discord activity text and buttons. +- **Custom Target URL**: Allows you to replace the destination link with a custom link of your choice. +- **Custom Official Platform Name**: Allows you to rename third-party streaming sites to an official service name (e.g. Netflix, Prime Video). +- **Status Wording Controls**: Customize or disable action wording prefixes (*Watching*, *Listening to*, *Browsing*, *Idling on*, or *None*). +- **YouTube Channel Toggle**: Toggle whether the uploader's channel name appears in your status. -## Data Storage +--- -The extension stores the following data locally in your browser: +## Local Data Storage -- Discord OAuth tokens -- Extension settings, such as whether YouTube or SoundCloud presence is enabled -- Temporary session data used to update or clear Discord Rich Presence +All settings are stored strictly on your local device inside your browser's extension storage (`chrome.storage.local` / `browser.storage.local`): -This data stays on your device unless it is sent to Discord as part of the Rich Presence functionality. +- Discord OAuth authentication tokens +- User domain whitelist (`rpcWhitelist`) +- Domain overrides (`mediaOverrides`) +- Extension preferences (`showUrl`, `showYoutubeChannel`, `actionWording`) -## Data Sharing +No data is sent to external developer servers, analytics services, or third parties other than Discord. -The extension sends playback activity data to Discord only when you connect Discord and enable presence updates. +--- -The extension does not share data with the developer, advertisers, analytics providers, or any third-party service other than Discord for the purpose of updating Rich Presence. - -## User Controls +## Data Sharing -You can: +Playback information is transmitted directly to Discord's official OAuth API (`https://discord.com/api/v10`) solely to display your Rich Presence activity. Discord displays this activity according to your Discord account privacy settings. -- Enable or disable all presence updates from the extension popup -- Enable or disable supported sites individually -- Clear your current Discord presence from the extension popup -- Disconnect Discord from the extension popup -- Revoke the extension's Discord authorization from your Discord account settings -- Remove locally stored extension data by uninstalling the extension or clearing extension storage +--- ## Contact -For questions or issues: +For questions or security inquiries: me@yaw.cx diff --git a/README.md b/README.md index 96d0979..ac8ac81 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,76 @@ # Neurobox - Discord RPC Extension -Shows the currently playing YouTube video or SoundCloud track in Discord Rich Presence from Chrome, Edge, or Firefox. +A privacy-first Web Extension that automatically displays your media playback in Discord Rich Presence across Chrome, Firefox, Edge, and Brave—without requiring native desktop apps or local servers. -This uses Discord OAuth with `openid sdk.social_layer_presence` and updates a Discord headless session from the extension. No native host required. +## Screenshots -## Quick Note (Important) - Read PRIVACY.md for more information +| Extension Control Panel | Discord Presence of Youtube Video | +|---|---| +| ![Extension Control Panel](assets/popup-screenshot.png) | ![Discord Presence of Youtube Video](assets/discord-rpc-1.png) | -The Discord OAuth scope of `sdk.social_layer_presence` requests a plethora of other unrelated permissions which many users would consider to be potentially harmful. That, however, is not the case. The scopes that allow the app to set your activity (`rpc.activities.write` and `activities.write`) are currently LOCKED by Discord. They do not give out access to those via applications or support requests, and only a select few Developers are whitelisted to use it. But, `sdk.social_layer_presence` (which is not locked) does implicitly grant developers access to said scope alongside many others that are not available individually. So, unfortunately, in order to set the user's activity, the extension needs to request a lot of unrelated permissions. I apologize for this in advance and can only hope that Discord allows developers to use these scopes without any restrictions in the coming future. +| Discord Presence of Universal Web Media | Discord Presence of Youtube Music | +|---|---| +| ![Discord Presence of Universal Web Media](assets/discord-rpc-2.png) | ![Discord Presence of Youtube Music](assets/discord-rpc-3.png) | -### Copyright (C) 2026 Sheathed +--- -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +## Key Features -This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +### Universal Web Media Detection +* **Any Video or Audio Site**: Enable Discord Rich Presence on any website (e.g., Netflix clones, movie streaming sites, custom video portals) using domain whitelisting in the popup. +* **Embedded Iframe Support**: Tracks video players embedded inside cross-origin `iframe` elements. +* **Automatic Title Cleaning**: Strips site junk, age ratings (`U/A 13+`), runtimes (`2h 12m`), and plot synopses to produce clean movie titles. +* **Poster Extraction**: Automatically extracts high-resolution movie poster images (`og:image` / `twitter:image`). -You should have received a copy of the GNU Affero General Public License along with this program. If not, see . \ No newline at end of file +### YouTube & YouTube Music Enhancements +* **Smart Category Detection**: Distinguishes between **YouTube Videos**, **YouTube Shorts**, and **YouTube Music**. +* **Uploader Avatar Badge**: Displays the uploader's channel avatar as a small badge on the Discord status card. +* **Playback Speed Indicator**: Appends live playback speed indicators (e.g. `1.5x speed`) when watching at non-standard speeds. +* **Instant SPA Navigation**: Instantly updates status when switching between videos, Shorts, or recommendations without page reloads. +* **YT Channel Toggle**: Toggle whether the channel name appears in your status. + +### Privacy & Customization Controls +* **Show URL Toggle**: Hide website domain names and link buttons completely for total privacy. +* **Custom Status Wording**: Select between **Auto**, **Watching**, **Listening to**, **Browsing**, **Idling on**, or **None**. +* **Custom Official Platform Name**: Make third-party streaming sites display as official services like `Netflix`, `Prime Video`, `Disney+`, or `IMDb`. +* **Custom Target URL**: Override button links to point to any custom URL. +* **Manual Title & Poster Overrides**: Override movie titles or poster images per website. +* **Discord Rate Limit Cooldown**: Graceful handling of Discord's HTTP 429 rate limit window. + +--- + +## Installation & Setup + +### Firefox (Manifest V3) +1. Open Firefox and navigate to `about:debugging#/runtime/this-firefox`. +2. Click **Load Temporary Add-on...** +3. Select `manifest.json` from the Neurobox directory. + +### Chrome / Edge / Brave +1. Open your browser and navigate to `chrome://extensions`. +2. Enable **Developer mode** in the top-right corner. +3. Click **Load unpacked** and select the Neurobox project folder. + +### Connecting Discord +1. Click the Neurobox extension icon in your toolbar. +2. Click **Connect Discord** to sign in with your Discord account. +3. Once connected, your active video/audio playback will automatically show on your Discord profile! + +--- + +## Privacy Notice (Important) + +This extension uses Discord OAuth with the `openid sdk.social_layer_presence` scope to set your Discord Rich Presence via headless sessions. + +> **Why does Discord request so many permissions?** +> The Discord OAuth scope `sdk.social_layer_presence` requests permissions that sound broad. This is because Discord's individual activity-writing scopes (`rpc.activities.write` and `activities.write`) are locked to developer-whitelisted apps. The `sdk.social_layer_presence` scope grants access to set user presence without running a local RPC server. Your data is kept strictly local in your browser and is never stored on external tracking servers. + +--- + +## License + +Copyright (C) 2026 + +This program is free software: you can redistribute it and/or modify it under the terms of the **GNU Affero General Public License** as published by the Free Software Foundation, version 3. + +See the [LICENSE](LICENSE) file for more details. \ No newline at end of file diff --git a/assets/discord-rpc-1.png b/assets/discord-rpc-1.png new file mode 100644 index 0000000000000000000000000000000000000000..4acdbba0185d99b0cd0ef6694d4f10a710a44332 GIT binary patch literal 34022 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

D?0Z0l+zx&fv(PwsJFkn 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 literal 0 HcmV?d00001 diff --git a/assets/popup-screenshot.png b/assets/popup-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..dd7d1b32934cc2ccc46b5a59a822d923dd946bfb GIT binary patch literal 38759 zcmbTdbyOVR)+S6s2rj{$5E38|+?@bH5;VBGLvW`dxI=K4K(K~jjavr~K{^C?Z!EYp zO+)we@4hp$zFFT|GxxrK^r}g-*6Kl|Bnn(9h~c(iyJ7#M^q$_gJbFdn_d zz<3;l^AvprfX!l$K0NaJs3eC`H%WhhK6zsQRt?>wF$MqL5(|Bf>#l6*g@Hjd@Xznj zh+CBn21eGsio#nRf3v@<0VX=v$dU8;M|PIxPu^O}3cq!i_ZlF`Hu2)AF<$Di_4%;e z!rztYYM0=c@8f0!{&>pOGQ`!!@7f0-#=;Vj6@GWaFnO9nL!zjuDeEl_8HtFQ6tkZU zhKLEA!b8A&<8TgUW@e7hKsiefTsYQ{P89_XbL6WAeq@=Zfr;FwkgrclNcu|+9#sL~ zhrDfsOEO2cZ|X*s%wR;3Wc{3>rWRQKb0ui+=-S@jiPWo9(jvbcs*42zGsvk@^V8*_i=b~6FVl*%?Ryt=Q%T6> zOr8Z{V~5N4J8W+|OV_BBzbu3rtZXXc%j3RfR*0avzsEiO^L@%Gn6Mnk!H0d5=6@Io zuYH=1zEe=y!X0}lCTc!suE&`zaFzTmLN?T>#S?R5r9(BDwZLNJ1n_q;v}*VwX?K5< znlzb#uXyD-TDaIifm`@=qcaRRf(&_jQplU!@Oy)HELVb*3i4-H#{GWB@zPNLxc(ui z4yfpGd~zcFLHBEk@+TliWK}*|K9TF*4j}FddQv+{0Sd_62{gfMa9}m5Dj)GB$2(e} z+7Sw>kB_mZ`#AYh%jlz(bd4G9S60!ywk)Y(HTCkrTI$j11Rb50rN1N6w%e|6*(>$< zccwHnoyWW9D$Yhb8=Li$L7ul|!KFY!zXQbyv%c-sg6c)kM>(T$%B;{U#u|0Ktm>v( zI{E?nSe|5I%>o+vtHa$l{xD-Czqvu|*#1(F{j3T} zs0?QPWI=RNOAFj8TgObRj}x>f9GJxW>Oc?y`zu&%!jt4)Sb{Cx_x4G2Bwye!QpPK7 zr;ri=CN2){3*Mg=H*ei=37)hOzuVcpq+b7@T?JEaHE9nLp0SUX{kYT{_cyc;P zw#Dg&ES;PD>~;`aqO&{3V*_JDkJCtxq!79Cq!gZ;l zccLw+m6b9R+S5f_)qddJdQ}SVx2pbky1~wax1s1tktdHTrBnD2mh!tuU__V}54Y8JF}5fv*;p#3 zgyF*?D6v{kBKLaRl4xYG$!BxD5oomdMvp_{1yQ!*Kr3LLz)UCa< zd{xtn%F{n77|Rl*pdfyXpw^7FSsPR?pkP-w7@i&|-r7DDChKvV~a}pLY8*J##Irfxequ z`*~q#ePHLVXiw-+$h#t#XE+8F;o}q&u(*l1a;wDtPE_bj^1zfcQ0j-R!%(&Ki+&-L z|FYM-KjN4?0|0_M7}FXV6Te^!c3MdeE9P$ub-waFa53gh01zH{ykRe;maP$SbvA8H z)wVFH2L**%2yTXyd>JU(rvr|$)a z#mB|O#Z0vtgq?8gPD2wiao<(>uIAh$ua@$M9-w^a*~ni-aPcTTo8!wMYU}MQ&gJO* zArWAgb!zLB+H(G*(Siju{Dfp*CTEY6arZ#$?LLL_{ifrc-@a_j1_CbJi_0>@q_re3 zt*8YmuSfK?M7shWX@~MjSbF1bVpcZZo_t#1koA-_p6ML;#p}fStYXPwt8u6b2H&V- zmlYo^p1&C!aC!LGp2*@taOb^N_}(lIm_>2cV|LRfH4=HvjxCTR(S|ofQJEo9BVSOM z4%$e==Z7Y&26Au+*uOL+Wn!&T3wngUro>b7GAn22?>LN1CroXvA)uLqLFH`}r zr(pzP;oUR^-n}Q)oi%8+4f?4UcC(smQ1&`!pvaBGSuMtp_flV7zsiD2;S@nmV6A4W zzBGwoViJyCtbR9se=SJC(H=u=oADTLET@G%k6u^lc6FWawQ5Ql*~DIbXb>E<73N18 zdM`SnQDAXhIMZFNn{xm>11v3hNzsM{dg4D*#u3x6z;>;1>SdY!&)EF-UZ5QkKcK`i}ZiLFQ#zW zt^Aax+lJ}`G0`eTSKgiyY8`gz?nJc7w3VbYdN}=FUnRhQf8{VrU3dR*EeH^adr(sqbaBCETfr^%$XW`KHnd2+9B0pF!ZK7V;JHxu=DOuw%JS}=>9 z#{Ss*I2>mU1UyW1@U5Iur55~r5>|&{vqN2@pzbO`Y8&`zX8W0)z01+rG9Rf{Up0#s zF%8g3p6m{Lli*9Lf_D-dHBMJosBWdxOprXzJiBJrU@DJ}-myp)%_&Y1jrV3}k6|HZ z5=uk7{{@B4i-4oWUm-u4g|D#)8J_(jDzfJkYFzek`pig@E<9G*^SJ3 z>`%h9y#nqc9ITm4e3(MVIi^R`Uvr8y-KN8X8$H>IAFQ7PlnBboLg+BYV2qUpSoew` z2NaR-Nz(4vrdh@e+q00+;#bR_njSAa@0?oS82?JUmI!eD%ohJqb^K7@KzDndd8nDO z{qu3YaMQtFVX$u(Q~YDG<&YgNikV;h%hc4@=f1++roa5a^_#u!oaFoq7oi|6ExvBi zbUx<5jPRZ}K@Vzn4{kg|z~mo|0=WTkpj~Kbv9W_>pB35Qe&wNPpEs^CQ~pp>#cdX(wa<&A!Fj_z6WHyceE>)OUV2vib#mz4evuBTB=dsyMga zk*(E(;XUzyjZfK=S;X_{I`wWT3Y4n|a{Mw4ZJX`L%?jlg@{+$ZcS$a<$rZttlDOIc zc+%y~XE&0tFvk7Q1rZ4Wcc=K(Rg%>o4ebiC!vHVh&OE}y6W1$XLMi%e?I9}8U zEz*@f3y}z>xPL*jw2EnZ!>8vaT_rXxHC?q@x;0(!Vu&d>utX zsqxf^R1Sy6QyYi5&P^?_u!Kae+50~U6aFuK=Ny=CzZA2rdlOqaud4?yOGSp>1s>{U@e7H+CfjakK(&WV1)cj%YLVv^JA;P2I; z;6FprSIq7AJgsjaHHO#mNGdB$m4pm5z|ZRup)4=1jVmM(d@>LB zg!R(rOvyB#phb)uu;1(Zx!|l&-O2n5+62_EA0VglPb;X=E&~0KEpya;&Oi&HdYy{x zB^k;J4spJPY_EGLoPK~X{)p__x9=)UL5aoe)_^z5M)p8pSLW>~<1vlfM{vU0JWN<-V!+nI| zl18c7`UpoBcSx7(PRA3!(A^zVvha6- z)DRSSwx;;r*YpUjdkXpt;oyRaPMe>u()qU12UbTZ9>2tL@>wP-uq7wLg_ztmk}8~! zFMJ`}t(y_ld@j#fq6ux>XL!v_>OY$35zJ**eIEu)daY;kp0)T}EDQA%Pzl*9`jkBU{DOLX z8l<@87syk?Gp&mBQKFAr9ezTrt;QeB!4&+!4ZCH(?|{AZW`GQA4hInVjqf3Zn0SZD zYlk{=|DxQ9WbAyc5AR3z-KVbkvl~2!&rh^e^A{MDhQjJ#y>De?%7Nm`mg72%jLXm7 zJ&G}AJzp4P-+m=Sq;-EBhiLCOyklY>Z1J3B#yQ*hy8TAwJ=@TXpgI@FCRpV_GGPDq z)j&ru7+-c4Aasii{?0XH3MqqU1H9<^>HMYoZA zYYVXx?QL-1RSrAFSDv@cPVZf0p00+7_$AWBQ{iDMtq$x#f&+hmO6aoqBQq&wMPV=E#G^@9s)KgXmx47zkBI5hYHtR_Ayjyx1n zV^;X^irK$K^66z;b(_ZrWBd0IhOO+~Vvv(t9U2sx$38(AEBBoa>j`eNk z5LGW7L&p<`u8hZ(dk`PQPhs<0ycvT~>)JEgF0Rbz6LRM>oN5^TWad-DU?EuKV-R%0xpD}iP%sR|=yKg8Te0P_9dg%3;56hnA52!1s z8;(bW5Yo3cmBa6_81wJQwJRnaGBCPd^kGsz)h_?jU)N&K(h$|svM%?q&#`IGPpA}=}5-wA0d}jx-sOwS}+U^J~-;@OPYRqup7(CK5x`m6s;!X#uJTFw;Jfw z2&na3*;P-^X>(zbARcT&!A=c$UElNy-4recW`_=2w{ft?dzh;qZiI)EF|d8I*se^` zJ%-Va*gSk(Ln`?vX`S_-=8wdyNi2q1u+^-iBwBswd6?^zlIH0rCFE2+s5cqK6d*K1 z-%th6uvbF)YSP5qmb3T(4{jb-HWqA2N%yALteai02?B2PbwIwEjWmYuRg#HZ;-?l5 zKGRQD?7RC#SUEF@({0RfuoC*m%jhz(@|aGHE8Ay;6Oe*@sFy_Fo?B89(vAL2=K$Eb z*=(-%?NhLGvp?n1ES0y7Z&chn~cdm(T>}#E@;l!-o$Gi2B}k9oH11_lYAf-sTN#J^dXk)b~bjV&#Y9Z0b)o zDBp3Z-MNFETJoUEDDzzpv%=M}%6usDXZ1&}FzbqQpt&iRAcIPc5-A#HK+IQu1**4a zyI6R}5P7#>dm(@6_wgi7;jwSqooh0&WoXyrtTa+-rJ7Q=+)@LD6Zv?O-fEp#UHo|N z{hnybs}$#m`h84j9gD74oW{A@(907k)hqq$`+}r@4+M*M+I~XQWtb1 zJdri5Fe6KZZuFEX1I?LHiM?&8P zS7)7R)$<#j@6MhB-1VNH$Bah${gsU|Ubpb8ZoN~)_b~lO(}ZPy)t+jUE%{nsAf_ks zIsm->pVKa>jAb+gi8OoRj;(A}Rl?FwRsyw3a|S`M-GP$+xfOby=X>MkE?AGJuoa)`Ge%Ht9&X@n9XcS)=u1IEILroy5_^O?{k_vXH;`YHs9? zjCw-@GAi>?%h_kbTlM`_q}sZL$!QnB3>k;ea4RE2(hez~+U#E%#Mh@o{7>QyIac#XaIa%d=>q}cx*lgWVm zhlTUiY*o~VLA0-cY{=6CEa(T*FhzE2l|V^ckMoy>Za+cXJQ93Gk?l`NYr;V}rPE0W zTDfNOB*uRbp0ztP!ozjKs6Ja!{HhY8!K-h>SnOE6zvo((XpyakE3mxjLn4Z`-e&)d znSfC^rwSM3@kpQSBjIbxR{n^m>&v=z<@Ys|!Kpa)Cz7}LhlR#9Aj#o$e zI4q#ns4ebvjn;T%lubK8#b!5_x#C*-2PaeT_=^7e4w+EImX#(S$(4bv+N!MP(cy@0 z?2$29GX19Cm*AC+5Ix?*jBM@wBReuf%;i(xbd8y|SC~-sNw<)=N4n6!3YYy>8S; zRUG`|USrL+qBxx2jUh-t7pE(IcTCzAxx%w>HApQUP5a!IqU58=(+ve!7Zo3Bu}dqX z&7ebyZnln&7;$JoaLZ&l_3Ns$Bxn&2>MCmXN8XBJg*T4 z3r4oD398{do)TaK-MVWfL03z`G80>viw?`))-$Vqe6=8fS1QK-s8kvSbDzhL-&p9PU z;#=g1W}eQ0t<|g?1m*N(8z3)^z`-=|KxVqFc+KPReVI>>1| z_xR8W7Jr?WO}1-g&H?LkI`Lio!_(S^Mz@4OU;bK~ zUPf`-@2yHNAI-a)sh=w@NWE7(@Fo|IF{YEbUuoBPYatBOCV3c1vNS*tv@!|aOyGI2 zI7EK1srzjHQobulGuVZ~m`G@1WEAa@Jac{juYkGbN$pn%$&0^f20d{19;cE`OeM0a z!TBKgT`k(;4f3QZS2~mu>lRZh&a_*&Xv3N;jgJAb*~ax>HD0POm&R2~pL>pc(+*Xn zdA|VW&Koqc!}sXv6h_C=BwPyVLNQS!TY=C2y`VImzit6JgW?_D|o6CYQ;4 zx1G;x`TL&dX}lQ7m#b^O)^>i*Ah5eR2hNjH3$)CWiO$cxJ@?G2HR!|US&s<1cCx%( zm4w4Rv+4|nDoy3a(#hJJ!^D}|XhhR3he8{?0R(xy z(*}3&b&rqayelL38b!7xP@FL^DtBcAk%Q_D5HQ1;N^4mliChoZ2CZubL3Rpzhg|&K zp2p}|FhalEf6rKzcCSR0)A!D_CV|}6gjaV!8NCBVdt;MWP)`*^F^C_L#Iv&U8 z6f&X%+*(bwl*PWV8TyqKc}F0*su)tnQ>}iu6;LqlQE8eXSJg$f^kdnO$yNNI=`W-B z@{xzPW1c4tGp+d;(c29dk4E^*hN*4MQuQ{uQ%RLrFeX1!NCLO*fj81&i3R?j% z{Sj%BNCk90TdbrL@i`bsPvE2tIZ;G!+vZ!6?w@xo1s9!B^C(MkFM6Q_iLQOI##1`k zD1L`+1j%qrOYx^45sNK(&y;vwwN zO`EORx*A4;p8aQdjWVatVWrvluXcoqCy`eYd)#?!kq)DE;!k$l2GFXth=yCx34BN$ zSQ;G-Iw<^mM(Drnk^V!%Gx%Zw+QOkh1OMYsCC?*C^iP5L18RIbtkl@Nfu9cQUDjH& z_g$9_&PTl_Bx9#myOGo4YwdgeC2HB+)2h9gJa^}HL;nE z<`7ReqCb5F1q!VWm2#%vs4{f$@OB@bTDl6<0vTNFg08q83W@E(gXv7jlqc(5 zw>F!SpY+IOo#~6FgN5`}qVLH|9b0~r{7nq4A*)3o@@v;GBs`pv19Yc=;U-K+omrM6 z+QW-h!*SGwZlGSY?e$38gcWjyZC?!V$h_8IC>!70=8I!S=Pw^=*zS9@8ux!dUDawK zI=7iv9O+;!9-8H|&WqPz;((Bue3w?*!JUIyr-aNy4kqIBy9>CgCWra_-$u(PFnyv^ z_?FZ@k5MB@DIfZYR9P#3Eearl(KitvksN67jRiP zqqFa4*_e&C*sdl>WY@yjPboO{6XHxJ}DkyyAS3sP~ z4!MrBsFHdZnQPghJ|}+1pNjzTuTt5A(ZCf%kpR{Dm^sc=tdSi~G^}i7!%UO|qj8Wc zZzuy>B$a+9ldmQE3$ZvI*01qOku0Xz4}LGd!TdpuNkQi;7gE9E+n)Arbeg@i195pp zXl3H(5_p1n3cdC-->u$!s@;D3dwY|+89mphS-~0Gy>m$l+r*b6V2G7Pz75est(;iK zLg2J*4l(=J6%Xf}*Qz|O5sTu+QbO^Q>X{mjKj9uwbU_zc$fp>!xiE&5H@8(GQ8v z!*rP2yrjwjkE8~v9+~jH$d#vB0ySK{EixsQ&&Tyz=nf*D3er1+s+HGwnvJEi{X0lE}qY_{>ghI`i*9SBuZK zHSno*gbcLEIuT?ciDrE|mtO_#x%W~~y|J04Ia!Mzu7tPO>*@5*59nYUpE{=x%RcyB zZpXk%OztLY#YS>s&ch?PZ8v z1m*wAP0}o&?eJU@e*J!}iuQhA8rkCgO#4&MxP`J02eaR?k?(iIvAQUT!1;)QQ54~~ zm=$WHSjz8F(HpkWD^D7dEW8yM^F{7eYXLH5`2r(?-gc$~ri>f)o=Q)z5OjEWE5fml z3NdLGPI0%lgWN1jz{#L|#|#NE3310Ml{?(2EwmNuzWS1nL4LA576$?CToMO7NnOmU{Rn#c40 z5#7ar6LjaEZGNc~P6=jAnI1ZN(nT3O)M>!QfW4wtHG=EQQ&mpuFPTl>>;Q05KiS-h z{b-Fd@v?PAEIQ7QV7o=;&-sz~m&sXs^izJzq6yEY@tl^d1a_^sXLI^8^htR>VRno$ z{Mvs981d+YQ!W)PKrWW%dXD{w3a{-p#)zkX>EQdIT@5ybzx&nCP9x{!&|Vm3mkZ*2 z-?^vm>}3K;X-D%oN%zW^{A|zBP3|x(2{D7(v}Y)?j;e+Y3^ocv`>kGn>E55P?yEcf zm(4+m*Ih8`ddy%ioI6vd%bhU^C$|&UkU@$3tKmDVF9^8?lydOF7bNhhP4a*{@6@zW`jc>ODI&{8M#=rs)}@Qw|r)mdu0K ze}U*sA#ym5)S)H9y$7BX7zq+y2e6eGT+jnH(id{@l zI`u?=m6|TfrVHseEqub3#q_51p|JkE)lqfpYjMb!gder?!!aEtlg-!^x7=7C%fJT^+KGql}+dgF^?J4A`QjF0tJ5p}5R>8VQ7N zZwp1F5ri!;)wKiT@o1Qx`~G{%Es?J>b@S4wC!M+lceN%T&20gNyk5UI}Hp>r8elJjn=VWC2E-)C1NABm-;6 zLMIspjw%8mpSZrARvirUeTgEOsKAPO-r&SBX3fdI1S70>`)*6}Eii7|rHJJO37zKA zGx1<%uZg~Vg0nw9c))20A2BQS`@S{VDiqUQgX|U%>kwV+Ft;lbQTIJrxxZ)JrshEV z;-|(&Wz*t1T%3^(j9d~=!azepTl;u_E%ZH!j^*sP9Q4LIw=XB&grbti2>b=Z=k2BC zQZ8lAp#s*if2Wd$Dmm)}Q)^W0c1>3gmMiK06{?i6h$I2vTj}C*v8=OO@(p}0w^n3q zOG(=a9iaoJT8V70D!8D1IGkG{h~y7!c@oL3Lj{soVqLzksHPlR+FLJpi&ysZxSlZ37rsLCun9}2oIKpU zg^O3J&=*~g|4y*sG7s7c`S=PN<19IRqeK%k+AAiL?eO1<;l7A$CsiwCn4mH(i17T}@5$glg!{rphN8L3_MoDbf`@L@hOK#?_yz<<-plpK^`|(r3-#qdD^(^AT{Rxu6zG--&*xARdVH1 zb~Rf_{rbgJKi7`X?%15tnVR=aBO8w+=SWH}q5nSFdkH_};o46lLL+0Pzel1*M#h9j z*j$92Uu0|EI@f#^TsIiL^q=a0n05WW`MiWb~2QU))5vCiX2} zdO#;~2EW`SXB2#Rbzp+~sSnMlym`#(S3>|9h7Nsqo3tNxHRUrXW_it(>FtaO7*^d& zpC2w0ymlNMqdCW@m$`hSk*MP&RwkB3qoHo4T#|grb_3c|;7ib#oO>B)YyAN(1mdeN}G z%y-KN4QE5JBeo-kU(b(jrI9Vyy0lZ`iJ^&113&UH zU#qQ*j(W+SF5en|W_aEB&O7IdoNR>P`NDx@m(?T&3Eu^yoa^*LuayAXqxsa7%fBoz zfsh%D@YVOC!TT>?3H>tcP`6KO)z~@EpKG`o`M!@xdeL2onTPrwwTx7(x}*`-$m=gC zo;w@RlDQ*xhykx{1@z+^=s-UY>~5nqOi%XW~Tr5WW1}6MHH=G8fz< ztVIyI#aFQ=RJ8ffWnoIIaX%{gJRY$gQWSdS$x=CODtQCLB{(>EcQ^I$ZFkNDE23y~ zmk+t{(#;C`fU+9Za5m`LV*mbB@fqXau+^-V=78Ww+KO0dcbDpaK2K_|+YYY>S%6N6 zw-7RuAEr+#W3nksPXgkfF~jdE-H9(5X(HOv!z|O8Dzc9>TvE~ir0O0YkCrkerEg>wowERmw|eBVQMv^yU)T> zq%v$=py zE)nqA_+fct8;WBlJ#6hcFWQOl-(18iHOq$_g&uwQ!nHv@xDc~$y=HByMcx*eef>fV zzVq1Z8EPk00%7HV*(KwjRml{T`f7>;Sk~wgEj(kCaPtYc&GGU{ek6dZ$gn3**LjW_ zB^}f>oPT{A{rY~pPerm_ILvC6LSE^4D4lr-C=mjc9-K~*`lW;r&?*ey7XV&ZX%W6s zlG$b=R^QxY)ARZ^9{qa_wynL*LN;XDL$Lp+Ea0H`wdD2Bm(t>2Vm|C4S5%IfxFOv5 zIk2h9ar3K3+XQ&!Y}Si76|qwmGSB1HF;BL_U@;_4Wnc3AF&dPd3E?!jb`$s8^y^KT z5fUl$Ilp!udHa2gHN7N91>b8Sk#oi03_AYU&+Oor|5}D1{A30*f~22|Yri`>fT#)0 zhu4_l@1#95)s@A#AXfA`n|Db22;?zs`;#+tzdd;_dB1cB#Lz%(>$0whCGSah6mJ&( zeI08&ERPnbdQ{^=zLXFtHS)W|6RRcjdOpP;06xcOKK?;&hO+rE(Yws$#&_EF@XdjM zp!n)>ad2-m>R~-b(sOyp+(u@??1S7_ym#ksg6_HIfNk#fm?S8tFYj&RD>E|b zHLz#J@H@CPHW19*@r4y z=^FO5U*{fl^SP2*FH3y!apAhObNjwT86+Z~|B0(z7M_(l?!5@pAq+tV?JxSX4adhk^i3y z!m6dZRBtS10Jk?20KVJH)i{$)5zb(`&zj%s{kbkunNx_>I`XwCNeSoXZfOkx&&ZG; zt>)@R9t4{i^A^ub2WN*)JY=|~m;~$5Y}~qS=eqCZyQu0;H4~LY=wDy6HhIpl4=2)( z?x(WA5!1z<3c6wIv1*T6)Yyp?bc}oVyrEo(uzE9am*NC0SPQpgXzjaGnwF(>oLxG% zt+DD0I8cfqFuCSt6lOJ^Zw?BESE>^A+(xQKoGNB(%W;1RwBW1f;XNLJpI+3%%|Xvv z*23($uckzDVCO-Syu3*}GGN=q8V%m-HgX+0o_qQ6Y~lH{ZH_|?xrWyU9aDP;2a*@# zpkeUqZC;bR8pfa=%AYKokni-W0`8$X^Y2vS0ej94yBf5Bxf#5v}wk6$W3`)UepyzH}ZCPka zYQN+#W4N1MWR3e4Y(Bh#ddagc^kbpk|8+ol28M4k%fU(q^iN~1xa0%cIJaffNN~hY z`bm$`^(>XJ? zcHG7IfoT_m-}Vd2 ztVBm|KkIUIfkRWoI47Lgz}>^$v$z@l@6N{sAQ%3{!sPVEV@jtCyb@uaVqNgta>(t0 z0$-73>WYNZ%ZLG!QJH|RG+>tl#pLXT4X^3K6`7w!JKBUjqK;&zq219eKpC>4`_HH# zICtPe#rg8#tDM5q-USVC(_VMHJ|y(w&rYh4hEcFYc8YLu#Wx?~lh9kkwRWC(+2vrX z`TKmxP3}Cj^gz3w0Jf~#c*2Rw10p)PZo?3&KNJWC3)3eXcXC9OD zzI>m2`b4G{g+OL%=QXzLVrjC;US}oMka~5q+ZB2Y+-sRQtu~CucU>MKN1rA|wD4v^ zyJ&}7?8&1`+c}Nh1Q_SDzP;wj%0hG4n*G&i!vCO49RL4hHs@9nm*1HrayXWMW)`Ph zm<{xtUcK_bK|5md1K|h6Ym1b!MqU#0>XF zl_lg24QD5yd8(iX7j+zFqf)!?xOix;p0zFtv4zJxn55+NA5>!gCz$8|v+sMv&9ScT zZalU*?Bas|zcS2DPC)p#vCc#Edd42*3Zik>e>ZshPXfLFxbHD-qy;sZIXq*CQ>4-H z!7rHai21Hndz{MY>xs=8l!bl`jzw(vT2QZ=g(+{S8(qMI`fJJ_4>w}Ty?tRjNOA9lX)|G~`EpInl!iZ{R(( zqD(MT{hbzQrA-6+HEtL_5NN(hc}@GP%0vG=IQ^5nl7Q2_X6L-KP+9KvE%oEo_sTN# z11It|npzW7q+5B;!z772-ys!bi$=`$%a2(Jai0G`37eUP=DirIgRt{fPJcj&Rvbre8^P(OCE-Bn2gke7%_G;fOK1)q+TEsX~8 zUs(eP_HB1*5tAcWIXanXpg}r%%&kz3`WXx{r`-42@4Bxn*)8TQtju{4n>8dA&Hl(x zmbnb@afxt5&D0c7;j%atzjSx(pQaqu}A zH;$ZaaaTwnk$pG3dviK&ep)e(M%|3--Euyl{lNL7Xq4$Kya#)5Ev}X=z6em|x zRDKyI{@IGOH^2Xj{X|Ms^-yBR6bup01UIS7S}+~-!mayV09)DgZo7?1ipp~q~`lTaZ830LDtHwR>@m==t# zwtlXx1ov5qZ!IS7e%eJInrQYf^5H z#Bb({N7D78YFC|tc)Fio{0Uw7(W*^MGP_7qZo%_`wRZ$~+6&FJpG(#JY8Bg_BCT6| zq0lNVkT8UT;T>6lv_>r=-YcI{4s{k1r$Zsy;Jgg7)O%5^HM5wshNyaCyRx*wXhOc7Dn@U5VOm>n(Wm63BH$l0STaria|%e1NX;Zq z_Cx*JTZN#|&fXS$IyN@%p?4UfT`^igubnxhn+p(jfQfk!YZF`t3z$U7M!KlCZBT1- z^!1JrpE*zeGHRpI+Y_U3Z76uM9ML-7eRg7H#jpD~M%8j|o{4t2i*7*)_W4314!wL@(261hnHM5o!NfG{TzYP zK<+0CA!ojL@kx}{WnGk^-H@ySj-|SR-0SheE649&ngPsXHN_vPZGM<)J$DzpToWh~ zDnda{L^(@Zt}UAUoXN4?dEgtzZ2@oofXsw`ydQdJ z2u(lKT(@RcxOD0zxxeRCdB@SIUX~+LwS?Z!hTYeT!B8-g8WTACrP&wYgwk{=8rnZ$ zz}XMwN$I^L4Bd6+Bgh-Xe{hnZbGiGnxL*zY3D1>I6bqn_7I#*@w%&-+s31Gglk4{b zJdaNi{rzY1uFgN5*u1)!$k~hImh4AYP?{ zfOs)f0?(h9ECoIs1VZ+Li!zddb1R{cCwDV6ECXkKZEv zue%;z7R1_KHet)pt!Yqp%F?UueHo9 zd5NwemXRP>Ph)u#fKbz`_L+V1D67!{;i#Fg*k(yt$z#NyN5vSB!WC!Kxh9ej;AL+6 zQAA3`SQ;87#&LM6A#hYP@IQ!q=jck}c1t+jv6GHEW``YgY}+`>EW-G=l7VyJZE`lJ@ zYstLuB}iEYxqoz6@#k9jAyLb~!gUXS-?;t!`%oDP2USp4m%ztoGbfT`@mTlwcvVqjOGzgSG=A~-d?e2ghP8H#9Sd)TfbD=1 zyVOQz^P4+*+e6eNsZ@Xm>Js2dBk2&c8r67+F#Bj3+lLT_rc6sd;Hg|+WoRm8EF=5i zY}WZW4ASLXu;ll@LvY4{J&em@JCoPbl1+1w`-X&3p0Lz)p{0)Pbo1eCcKU{&@di?R zAqjnv(##*k_r~$-*5$^;$H%woe19Vg*Tkyg z`KQhYOU#1)9-WSoXH7NGWRL3_>&ipb`+_pHi2MP!2kPx?cHuyYp`q?!;;B0mdWc6& z`p5|bYhPl#bE#MXPMeWog6tcy0#f^NdaTS&7a2}$W-GdhQVX{UwaMy{eHJH?YWny* zw`3`)U3lzAts`za^A_bz(p_^!tIlJ`(~@tSo0)Wnz#gg&CG;+{E)?x+ugzt)86aN2 zCR|0&Ym{X!Lt^xYa5FqsJ$>4JA){SZsPv?=(v0HyV3py1CXq<^UX1L|&CT8R{;=A1 zJHauI=TA)shVkQg*U|u7a!a+XTBpj0XO)Q5QD>kUCjRNt7xTdb{6yn4d8^v5(a#SG z25xWF1=4Mk2|Iw6M*BgYsgTX+RP3@p*Ky7Z{oO9<>=%>~Rn{7?%zZkIPQ~PATAyFR zEON3*xX7XxzstYN^BFRq8b55GJ$8eEgVVcffVUf0>{9RZBZ0XZ(2=nYp*~pGd^`|D zs?cO!NE_~HkA)C0e{DtF2qvY{{d~H?9C6NR%!Tqfs+Nc~IIC+pud$>fK}hlhuW#zm zY?Bs%9orrr_zX&_thS)n2=ivmiHxz^wT-{?52`GkoJl?PIE5M9GRrAN9E7Zm%DhV# z{vIBlw<}OFF|ntoCq-~QLy(7y_0+sPl8!$g?z=;=<9Wc((9oVXx~q%V$a=J=`VlU& zUnggPUjJ+NI_ZsTKZb3&!vVh;Q)8(P!oD*+{B#FR%*P$0UKbH-A zstH_c6P(=|yrgpY{*b7)fyKh)v9NA^!F5guc5r3Ua%GZz6Fr@jr!5AxjszRXklt!z z!G#|!%5ahL2Jky4FCb7*>&uh6zQ@_HqkrdMRXLqZ8{XKB^oMVo0-r*vPsqKI==0FC z?G+an|GCo_LPAQa48F~A*`|Q@3lst_D<=nNMM;$y2I?|}kvfqLp$7UiE|#$=6;F?O zgh$4Y4+N(C2;V{*g!<|o?lSvw0;jD&?o!8c4|(A}jmodVS{8`x=#5mKj=hvOt@$b!x>y{d+jxn3aX-%Jg8|q2~w|8 zyS+jUWQIt^ON1|@*dPezKZ6vYcOUnRE08ZIfIK& z^6M9yM^_X-5gJSpszMr~>oQ_IP)`$24_ew3GpDc^dRbg9n(4gqS7t{SrL?MS2j^--N9Cw9t_U??MB$amq^L z#p*6kUpdY%b;hQ6OIvtyZ87(d6z};mILYCRXM18XRGiMR1+3-dAQ(|623^-4pqu_gDUPn5q$l6{~<>S_u@^4-1warb<1sdbO=quzpJInp!9_It(? z87lMn5MMc*P_1G4$0av2i6WAT*#TDpPBBcgP;5)>_jZg);NqUqpSCp|DRZHptv;ya zrDL^Sb%$ z`CxReH(c+#4AfSn{&gQ2XW$nNu9;I-4$eYspJpb%_Lv`Ifj9S{FglX-59ZQEmg~Tm z*fbD(8l_B(bkc>ht*%S$CyC|FI~~BeXRC2^35~|a#TjrHFEg#C=Ua!I4eNNn8*-~L zHnN?vG4Fl9Tw@FgKyw7ILZTKKjKqDWb}M||6Q2ES?wj*NO1q|TrQV~!=t!+Lr4$KW zlK%?ucke*#yi>KJ=V4O%vq5)bZ%77$t8nyU%eivpBYpmB5Y7LGLA1^YwmUQwh7l{` z*#J=C=>GGyu9!^6-rGBSUS1af}Dx!tp~ zvC8G@w(Q5@&u7B(78nwstn5p>r?coP7>A_z5{gi^?N{0%ga$vQ8OQR) zDvxCD_=t;7_??(4WGjd4xWS+UrRRzYo~|9`mkY~`g)J|gnN^p!c{h zXiLKtu+zi;7kWsQh}-HB9?mF`4^8GqRCYdpiKtlSpPFBft(l>>tDQ)l{K6j`wr7-H zSXKw8@>uX5uT~q7l^aIo+6<78zsCl6z}nK3^3OgZR(p9hA|R_eDQ+r)ir852u*)@T zKvI;=174h_D_Pl7i3X$qfA{;JS#9G^>AJFmRwSf-$ubo`&yPeZxpA)=YJGRU|9rgM zmmQhECsaSZQ%yvZlegk7{LKo%V4+JFSwFXMyW?f-Y29Y2J<}9!k!# zPQF-qXwx=XM#e7I0_|TEMo8!X)cF7`k8z<~y*>q8R(7rqgQTV2N?_vI{vh_#)Qgav zJCE}O{!0I4&<-2WRcMm2=AS3|G7+Cbt8IP$5}8wqH8@<*^21V%dALx($p=MW+IRIS9ps@dXbU?MNr`Mzu#A(AJ>Z;%b=l8O{xd#N&KCMwtVRj7`6(W6Wk-AP31(zxuH!ydHIoC;nn0AZi*wG4 z)3>2%tUGOoljX3Y3__RFQuOw$oU|TaqFo(ZHpMt?7flGorD^sdSeCmdA&34&EhY*M_q(+^am!kDz*BF)7EZ0s|BWlz00;r&s68=_V&Hhw8>3%nlKDewYK{ZPH9TR5*$L} z+2VRHre9)eD2mH$g)d2Hs1wH&UZ3}Mk#Sp|au~`>NGJl+3u}fYSq1qCN$}UkL&hwm zEuy{+=c8R1(Q0|;8<3_G*5>(+<<0gLq9u(x6X(=Y2eGImSR$Sx#7_~<)rQa_0EUBC zr0?WeQ`4Ugsov|z25TCL;%xOrei-pO6Rju;r?9!7o!5x+I1^e*Oc=?VT>i4$H!qzJ zdMM4x%UkIsHH>Oc2i+g>z zqM=ygZscobBT-`IgUi#anN{UJscRWpQpV2~U-$=cq<)t7^Csi5ke45_YRIeZX=p(C zUQh!LwxS%+s8J}I^M*T~fWpt%0IZ+)A-mi{+F|=ZZnxQajr^kBlOQ`kNVSz=UKM48 zYb1k2#4QrDx4kinpzQ2>yx$d?5lQS~0sdn+cDKK6?$KpRUqh3o;+CQ&=gUNIvoSB7 zlQ_los&mZt>}J#1Iq^|`i2kk5m{E!4zTI~k*k0<~w zWfgBQpOLcKnm)S!zOSVQOOuMkl7)`(SE`f-NSl-tWlz|Rsr-@ZWl{B=7_)&2GLysa z@S-jg0bJY49o)F`g#r#@v6X5AbgdR#ad5Q8$Y2Qt(}4#m9-2A8=C{VPRntq1&n}<% zgR%X9>nSrL;@n6ZI zlazG$(6Sz(przGQZSTKUk|L?sR$jtBM?tx%<9k-t7Tw%xLSSLy5-CosTSk@$jo0^u z1Uymp#}ud-=;9zSgl6(ep|G%Qi5dKSUU-%s@8gLk4gguLxX1v?6Vvl0pbujSTr0Z; zo^798m~xl?CAsV^O)-f;1js)~Zer^E-Xpe21Jc})WrXK1ptj2N3kjLr#S>RCMF6B4 zT+Vttg80Knl;ioQctkn=M0-!3zPWrv}89)LzmBYknF@uIVNb z|J=yGU*(17!0Nqzul?xDylJv5TW(5@aG#uRd6W$dTGCOn{$*XceRpfRf(K63o^g|> zRkq%g%!J*DqHr)8ovmeRY1v@_`gFGyscng!zozWQ)sPalm^Xhqt&Ul!QwJ|(dE~5t z*jE0jY)VB#IT%|C7K)FBh`rV3rm_5tHC{@O4|&+q|m^7pPF|Cv}1Iav+U zK3}gY?-;zd@^?5DQrx@{d|JXRY=%TZJM-Q7GDPjkv2C29sgJ!*i#nic#o^m>Pej>Q zZsz7UzZ7_mGh!;hyUz?|3)=cXc(fMSKw@`RrZ!8<6Is7`Nu&nONb}U-$fxq0T=ucV zouudYnedp7BWiDJ(VMP)4GpGNewZe(=S(q%;f9=Lo>fG|EgH=`Nx&3~-x^ocztlEg zeX+seY#BbY%>^NUW!iB_cHogI8UugSlu8FeC}2yqQj$DBZ|+Wq%9tqdfozU6T>vsF zcD{v0--VtxxxT10*Dmn%V8VGIb%Y-yb{7ql!aYd0|= z5~KNax2)JIR=R^~@ojYqd8j3*1(~FT6sa*ISjKnKJ2Q5}ob{PH29vdgPh&oTRxe!!w20zo-E-66vrcCWwQ*7|AKR`iV7SBQ~=UJz?TlsqO6 z&zJtr);X)H03=@SCLS@kI9&BkppJYSbiQ5vt?;)6R>{mYA|MR!C3RSm!kptLd-u>Rch}B z?=9Fo@EWap!^g#M_a)0pt*e!GExb0W%gdgw$rAoNp-_2;5jVS)Di%LuE%2&=7sbW2 z*@OOhzaU#Um-csh$DAE6kF>-1(rSo1Dpgti78DQ22?NXA6z6VWLd`JeC-ERX*%aNq z^maaZz1l-+uw3r@OKJFWj%5<}MHJi0Z_&$3U!itWW>b_P%_RAhPms;}`sxu2l^lMpE{a?oap8Nl zCcZt;W$dK}5h96^byVEFA#3W)ZA9_7khLa@)NmNXe0rbM`y74yk3$TW8V(aW){j=W zgC}r7ZARFK25^T_OL@i6T_dya&S!s55b_;O6zJ`04ut4q?xB^sI7RLyMEdwUL4E6b zVeGsVBk9Dcfn{+Jx`u-73nN)9y)6HWU8(8vgyr^< zPj4Pta$+mGt1+vR+15c3o}m8|3f_i~ZmWaWrpGVFS!kSFOY%w1C`TK_mC`}BjN@P4 zgHB+O&RG(JBRo81F<(S2%WmoTHTe~E$d)Wvl;BU6BKrJUZ-+6e7tFJY;sTvS)K2eA zM#IR<2Jh$L)f(hf)$~gnbMmqh&TFRi&9%h!u7&JrjLN$g3Ua(gwfnHj(;-e{jMl6O zv3&lW6D@YISrq(tu!DaQ_WabA-o=|4&;X}oZ_fbW5I}WGmig%bzKRe@^G~h_ zJdon1k*f{ZdagBHGOk9(+nl0C)jx)%pfYH{bj( zIGC1aG`Cj;Zl+J~}iyo&%8Axq^4CETkRw z9P*<(HvqQ*XzlO9^1s!umDVCNncFu-RPum7yQWFVWH(jGewKxji!G7YR2^!*Lxs7y zHQ7D2{s&4!a&UaiE2}FT_J$<)Z~F1ynY1&|JotNfrl$Mj+ca-eX90M)eq&dg^5z8! zpr-%KqdUyvsz?EGPf?aAKEQwU6zhj82}~RQDp?Xl`DGbt0KCF(ep|el5YN=`l44l1 za6G+Gn*^_>tm&=Z*VGHL5b1Syycx8SzJhXb>Ej4Qs<^7KFs9T?M+MT4q?Vvvd>yOW zZVx#^l5{Z{=i&Jl?_cW}4!)LDR#rW)Ca0{tD(r^BR=^=mK{j0E<$~*nxloCQNyb1i zkUI3^{Ya}B{zJRzNSxK3CW^k^4*Q*gD}~>wUTIF+&%2ZSvt3lICWseT5K09}wCd`& zq6I9AdKkla$itXb+xRxCorejSAQ>feL7Am4@)ZLdmIx|O(-I94tJ8w*z2dYL5ja$- zlHx&JD`Y0Vavc(9xa&erD{OOSE2G^F`??FRxNX7*m3i!~Rdd#NkCZAA!auAUES5&& z1LI|EX<^({3H8_!QH?mGmOI;eK;{M}GdXIaI?D`9yLQyVh($p>x<>m#ole=*DHUt` zdL6DePHvSH;=tV<{Y2x;&(g*D%ZQ)&yh)ZsKa3+ZtnwH2&85y?Hwz)1@66owHoi6P zp18QQ+J8va>)@NL%q-AEsNN<3$N{bGmG5TV!FSb2$Qh%4r(r9_j%Qh zZ1K?0i2VsELmh|j&6e;3o|_;OKIc;f1u;5Y8WuRvXA5CP9qY$)1BzCUx;egDvYMZp z^R3C=)aM&;t^7q?cDJ^S*|m)ZyTqeJUMqlA3mF(&<4``9*ArXKM^YV`o~!fUB+tO= zh+mZ3NK5p`&ELy*9nZn6G&mM+D6P?+v&!c33|7P-cC{wgWPM;el6N%VjvWNs6v-xY@;9Cj(1nHg{)P+g-NieZlMhYfr_CbK`6 z@pXb_I=cli>ts^~m%}~8M{dQ~Vn><>u;L_gh0M9nYZzUcmwzWGE963)p>2E`oKr>k z7Qc(a7Oi~&gKG3UO$)BSnIvL5xxUC$YuYln0!j zBlED*Gl(r8UjL5T>jjtgX^R(2`c|h_j=mU*g@ZeYK*4(bXKUc zDZO{uONb)?JHHQ+e#jE_m#c4UJ`+-MiQQ$|_GJzcOy+Xo)qL)$$xVW0{Zqi?GdR>E{eiR7B&{essL6~EXl zy*L<)`eTc`QY@PehKN!0Elj9lmlc7OPq^fO$+M-ONV_!EQV=jbIp6e>e#=*fFGD_d zFtMMsBW;Yc>slVH9e`{JoFkELZ76I_tnM9ckW%U4q~e+WH^8i*wmkjfXl#RlxVsBO z-Z-6Gc`s@d6K5n~L#A_9B8}5D&D9f>)7Q7s$YIFyI{7?BwN^Yq8?&+>ldK;WCV^}0 zH?9=t*Pwp2*n*1(cvw8M)!0xrHsb5z;JT8f{oIiA{BCkAq;c50+0rMzo+03Yxh`X7 zUI0`&pqL~J>FSQ$+MPZlk^dKVhxKBOe9;AtNCpuN-l^bQT9^E!w z*Vc^)v*8@LjI*>fAm-Q)f~3_T)Yyv9_fyKo2xaJ#d%im4*^45z0C0{q(RN1_!nL0= zBb~T8U!Ncwmmj){d;HS<%P?)w89#iw+kIZDF*tM~y{N=+fzo1X#Asm9Px6TUL;&&^ zWj(_6WD5SA6$in!^z|hC(d~en%brnh^>w3gNuJOM1Yr zzI-U3UuO&;2B^pCv)vfdc%xZRSwx8nA^Bi;Cqk2v-F36+2ZDSWXSMolzu})vG06!% zo9i`@vI(;ttY8Nba7fFc$cGEpY8l>JAh(myWPWOo|J=_I%b9D0^m7YD!CT9q!ClRk zS?(WrH>ObfVX0vr`sIr#6MCTiVL9~qzD)o1Y+9|UR8W=^7!d52Si+-XXSR$HfQ8u7 zNJC0GtO}*liO`hN@M-M9R+}&-8gFf;nF*~>Cib^+2d?y!bDaR8f5y7+@mC;2;zXMH zJzfg4KknGCsoC1N2e?%S&WZFzNBY_1_x@acR8W3PQSJokW*PGHu-#{>@*17qi0Fxj zyfYcbg&ZabtB!g|?dCz&WWxyW$xnCY=^|sr7V1hGGK5>WSUy@SUXjKl@xJ5b#g!-) z4XGs%j%UI@`O}{F!ccmu0}>oCgtCjCBi zpn#ELr)pkRUreVRIV)SBV;6^tCa2wgTKmHk4i4u)XicV7w8NKZ!9aIQ3qtyP*nxjN zUb(tp$TNF603tQyZNV4!&x27X)Dv%pPI~a?+b#Ltb9lU-cQAViB>LrdDqo`4dq|97ong@1%0C|Tar_Wu9EkfhuzXaDbZh|RqO zoCy7kG_hj{mic9K8<`$H0ljuEGz_%t=|vpu>S~~!Oi4Ziuq>0Wz`j$70*4-G ze}9WXvKTI7T2e+}UJEa~e@XG1(+MiO$Ey8wR_XnB41jH!s}6FiT)tNM{9iMrMC^j| znKDN=#yGhR|8kqoj9$!{T(0fxZvUW$-a&*3weaY_>NZxVcSCM1#74@Q48lAqL7kbNB1k z1zim#FxaQmI9i)7F0+@B^Zth3u`u)vea>@&VsoARZ&t!<-Udsg0u?L`uP#0^^N z1MJ9R%{_9VJ&2yO@1w*G)WaRVFjHpsfn4?YK921C=Q%*1mYdR(=yd|&&c)5myTrrF zwcVc=o#70ZvSc+&&>cv+WF&eLn7(g*?QJH7v$fyQ>qv{@nBLN(s0{u~sxW>xg6*n$ zlFd7JZi^oJydP`64_m%pjdS;f;;Yo&$H8E6xxw=yU$=g2CHi5ia9czPM_DSD9jjUs zvd{bA^}Zs@pH8twT)7?4N80gk{;s%4++@i4Q0JAJib%DBCOVzWT(&I;2FBE(x0AL+ z$owx)(fICxnjF?G&JILWOo{X$wSxWBYM#G7ZPp@t#-51)(+i!oSX`tGgSMR*SH30?jubFKR3D4(h&lbv`t$maQPmj+nD2 z!mgegS!?yDBTf33N~%@#GPt{=a$;9!qT~V@s|Zhxr2b~#l?)RcdPZ_0U+GT+`nvnr zks^BR%lQYe%DL83K?GD2My@D3aXr%x3i0BbEeBq;PJNvUWG!INJBAvf2haQ?id(Hj>!L_xfJQUo50q(myxKtWBNd*wkNaIp?OpRu!~Ru~p3@H_SR-frXy>eydI zj1Ui(_{e|`&+oGakJ|&(x1rv^vb!@_9nN|yT3(9&2OkF~Nn)DwFW~LnYfnu7V+h?r zd%LUp`c4z0#JlTjt%2bXuk&jje-)C!LY9C8*XZy!&f5!OL1HVvo-*Zfzid*vxScTG zegW3uktkx#4x&A|l)zCrXow9{h2rT-LL^t~b}YtZwjxIh0_Mn52gJ>eKAYwKXjV~l zN_{+gyuAD>4*0tflgd2ucmKO4L zB~v!l0Rfj&+DAoaK+r8WH{o3|NyrYE!5_w0tYFNWYOU^^mhe%GZaHL-v52^2gG_95 z9hHo4jiH5Ks>w4^dYlh-FDm(cW_5X)C8LKs-BaBO^9;tK_uWQzlg4F}E>JKQeqc<< zzc^3OT=iXKcloY{14-IONOTA?Sw>0O?8@p3^NTE4O$5uleG()x(TZ9lOQ(-O|djt@);3q%;MN49jZP+1G`SCZBQ< zh9RH}F~!aLu9Bx9&K7$NviiJck>R6 z0LgLY*#vB!JBLE7oPDI9#sdm)CP~gh{t0d9pFmh$!>LgM`R9S!w{eHl^~8%eUY_EoKu<@eZ_;=?<#KneU@E|4>4<8Rhb zV%hDz0oF?OH85bZd}3T0I0#H{hSg_zwR z^7M{Yp%E^29XubSL|o>p@#dyedQTgXVx9E_XxS0w=aW>l&BL;X8&u|oOZYD53T!TL zlAW}_Qg$9wn*Q6!=9|#Jj%>buQFShKi;&JWC7`;S&s%o{;u+LMrcUu3$Ai1+b zJ<>5krg$FhY(-vL=#rGUik6G3%nYZq?P;^*;qnHkbq>pJ4_b=Bfwy#Z1w@q~ESJm} z;O^-&4=VBduMzISlkG>V7bRMD?${mr{f3&aTwpnYD_$2?6fZuGWZ24oK~n`v0Ge8H)0;eUtmxd+OJ$CQ^~Uk67aQ}_?1PEA zTv|rVh&@?o{}$kNNx%gty{YOj8x>IybM~~XcJiT)ae`7;J2M8iahn~`t7cnXeb1n{d%!>4;<;U+F z3x9?QwaBa5eZ zgig)Xw~}5wfarQ5_dvonV?Xv4(3ybg85$3>a5d5qC$}-bHyN)%k%nUnc`uSQ^zY|< zF5KCfcE+3F0`;v{5D(exh=t4i7%;TP=cARvNkueZwu+u3>RxqaG<||3; z@AMBM_I=eX&g!%+aGP?M-DCc)yE0w>lMA_UXh}k#qJ@_^ja+n&YTu2sw zlV54Ht}LOH@=}=3id+|!3}(pmK>mk52HWFD(O)vy7Tm*Lu~k$-ILgcnsOwH=h)Sd> znS+_sIpFt&gc2{@TVN*AgJvqFTKBd=Y zSBG$EjcRUvZBAR(_GJXbG#H(WKgz4hIazLXsF#r0vNjd%O9VWr;rM0WVk6=$gf0{u z;YVdVAfS;7D}q*5@dEz@C9AFZN7j6(CZfFUNE{SN-`I_B7My_hK?ickVij-lq>$7MXeT57`D3UGyA-NRTrn-j}dQT!6GRoD+Ioe zbzd7?pb{tc*3D({ZV$;3sag0bxO#!=(~>WF=nsw*S%BJ^$`P`^$EOXT0C7v{+A^kc zvm4xb|1YOu%iAZK>s+=DwnDj$mN(a+K#VpbT0Ag@14NuGuJBbeOn>KJCKM_4Y(RVy zb;Q5M-f6|65Px*XUf*E)>BssSm1G0gvlioO?{m}<%0D#mCIwX*y=lYhvWrp2B-`30SXf8(;hWdvJ*78*NARa3xvlwf0L)clYFPTrY3pcJ*j#U4}?Fpuxb- z{c~&Jc!m6{p}Tz9>j%Aq#rIIVT|=u=axO&pI>J7wPl_{9@i)7T{cqEpkA7_zmQO!W zmCD(o?KA{hv7#z9qP)6pK6NPJzUDs{li-9IJts=A+7x*4U|W#CPW%RI@EuzMa#O|* zwL2$BG8i-GOsv`z&7}3*GSaiMb2;)dzwaKYnXJo=_9C8MS?n;V@33dhXVuTkreuoC zt5Y6c=h$bqx|j|a;G`VI9ZETKb)U{=?D=Sm$7U+E%c(Y+&6}a~{^N#U!f2>3Uq>RG z6a!D2HgyQS1n?OCREBRh#Odi*bSceTlb zf9Q-yhMZC#w4c2Thw~qQrRtLD6^ONLHV#fm?JXmh`hMup@f^1K^~+D5jw2(O$O)FA zBL8;lUF%O1B$k;&vz29f=TREdkJv&k9FUBEG)BF;9QSrPUx(hq@tJ<`WrHzvhGxW@Ez3hN`+FjgCO&`663}XrZ=-r|3nm9T?5pO%59_I za5-cFr}S6D2D4!JkB{Jjdphiv7gatl3WurNi&{4b2&_&zPIj~8mo{ADcy(`0!U}zE z1&pgqZ-o3XNZnnrx%JuSz4~kNPz)4qmnh@Fpj|^yR`4GW+m9~qR^URIqv4MQw^GeBDjI02y&ep2VNWqVEJGw{Z4t4BL~l=aCE-c*LXkC3~ zfJgaEQkLHHx>-~tHGA(O@EfU(63ya;J`)mu`0)Hs{FSjLxXUu#4mXFD0_BiR6=_>y zWEo*pH(Bp4?B)$=iOuJJ^JMe}fhTlPZIMKR7gNGwM?tc^OMAZ*yf5LE% zcn%9Y_5YtTcXC!R{iwD!{1y}c2IKHtxg>#x)>CnVE?n(a2!?94(CzY+s$HLZ!olAO zvZV^gb&{zI2^<}Pf7f{crQ*gB&C1z2z$4j=Q4xIstL#}S>1da4$RIa9Y>*%Etp~cp z&DF|quDlXi^4$mX-^x_gA|;%$)e{x3))V9>>Za+w55ahpn`{biKj8;hut8*dI@UFo zZawDrmvN{LWC$3Zt*H!T^zM^nwqJKFP-n|r^(9nGW}wTI`pAJbHjvs+sy`I^Gi+5L zux4(Iol1BaoK~D$tRs&fSaK}krVd?aF4lNF%1!LW>F#bxnG`}1y@x(EvnGAo5ITD~ zQ9ysMPA_k5sCCs~)Of@ppnm2&hgj#srwxc}r+T8EXyaX^Jw~y}j*Q2CeGs0QW2lG< zBy?W=K`+Zkq0EL-nKjLO+-&Eyt{Lem1OMTKk&*ggiAhF<+S8hp(qkT>Rz*?aE00ap z(?V%bsGN14wf`Xo_}f%OGbbG_S#&NZ)Pr^H|sv3TKox~T)^8+gIO|RYohj1oR6Q7!<`}}`)&Mnj3J7rpIFXP z7L`>;@w18TRU{F?QX5TC!Wg5F;OGdaSt z=q=B!yStMeE|IOGt=MX%Hsocq!gcg-;nU-%xU`@W0lz^^RM;`^l|sgXxSO3?&yuyt ziOUZg5o^At^aV7&W9-*F%oag0e#flq^wH9vHe3w88%;m<*B#HNey|`Kon>j)IgcD( zwa`8}{9&I_7y5#R+`YB^NLSIw(sBptQM*U{(|fh&#~`4kJnP@u?!`pE5E2?eK=*p% z`C?C4J`|+w>1&F=l2}n;ta__4tEID;mQq7Xjv>JUL)Fu&k;|qr;&sb0(AZE=S~~L4 zl!5`KlYyq?uJOCRJsf-2QnM8s5F@|9bc9S)B;%~Pu6ZC-aOK)?b;j=wjvcye+uMVX ztf^uME$06-BxW(rKdUIJ0FF=W+kfRJ`G?1a{%`G@zt4!ybPG92d3m3*AWTZM2i5AJ z!4k5P{$8@o2S;z^`d-80wUyTQm3w| z;hH2<(Fi1l|JfR5ZFOSI{4Q7yz^xtpI|7?y0tXw11Q68wf`1F*)T9McMzuGHX*I%<1dlfrBk%DueM1?w(Qsra)zc^CS##W<0##H7xU|Hi z>lO!G4<7S_B`1oCrrO}cg`-PPoFgl#>70 zzIp0S(6MGkl42MtcEOtYSQSx_o|Jsk)m9H+s_knHZoFzt-Y;;lr>0eFYCd0(Db_Jf zZ9%hK0biY)S?jB516v7YHwlIDV+FPM@`fXiv_qa#I(U6C_sB>-|NBQn+mF@`PsD&uFBH>3(G76K6{q?zx{F5CW#=uj1=ozrAh zNMBE+gsJ>K!N(Yp*t6O(T3qWs>;pO?rFL2J8H8l`i&{S>JzUKt+>9X z^PzeeSEf-7mpS(7pX0W{dhX!xOz9JS2GO$$O;^p$1I|m6l{-^-a>|Y^broXEo`USP zto9kQ=~OyTN3SPWL2D#gKnSzITU0${~LJnz<#Ui61W=u|4$( zQu)_cYxJlY6bsp-_bi{ydll0|wULQltTlaIz8nm#6y6|JYhlE>*?xSlkLt5}wAJ(c z8FlLY%^+Ib>GD@BQMaKm!uLA|b7+wcXB*Oi)I2B|k2X|~lwyCE)#rmliCseeNk3|| z?|*0vCPtmOKl+3l#PT&XWPINlxmUKgGE8V!Qn&URzI-SwJ88ZivR_c11WGg0cVvNxVUX>fAu&P1C!p_QS;a?Z#eeKY<8yZU~aM* z1$l|kWvfe#qM*H#3xE0~Vk`2pzMdg{AZ+kvkI|t0c&poL#42rN^r%NC9Az{!M-h&N zB~nQv#Tx(4~j6E9BGsg9#y4u0=fX~F^OGxj}qnS6()=rLa_+KDB_~J$@c|l+4v&hEVz4RV^ z-okoYPndTPZ+O;Mr-rfyIj*$%!!bSDA9p0ID8LWXym1$I&@-S*9L$TeP~&5oTlQu) zB^JOxYD$t+m{+D5gVU#f<+0-OL3Ay~T2(`2WR$UQmS0?D3Bk&^acv24#QN%Fk;y^B zKV_23dT^Rd_xRDg!{4=lO8VeATv+QKh$5nb|DgyG@o&le)xO?nvbN3^bD&63- z6y^Rq>_a=_1)IKu%EP*0CL`=!mTT}xl^47E+P2TI%w*d!gj@Z6*HQ75X;OyMJnb$D zn(C%^0Ch8#St&x_XYpUj(aJ`?Sf4x7Cu8EkAR3fE(TNPiwh(~V<{-tz4U~vIPS*#( zYvWYY8&Y08T-A1biVM5C+2Xo?usUpP_<|r_|oyJvV2QG zVQ|mdEGD*eFKIY5oa602)8_P`#t}PZxb%pNkGOU`qhX00VQOs~b>!-(CBFy~Qs}Tw zF$NfAV_^5TbS7QPsA4u2_|txZQ{&eI;nV~#gloiVOsLU{g_^20=PY%!&`6oAM#Y#6lnVZ zji+)oZ=<;s`gexTdMN4d2y4?9d01j2;N5_-vi8qRkblM zTEqzB+Nzv;NcIO))5{aHae~cU!IWv-??#<_!j4i%FZPJo_tAlLmC)O=Jb#rxs$EIX z{T{QGDW0EY%WPRkpXml@y=~5gWk%^kAcg1^ANBUY1 zp^0%NcpU_AGV2h6zqchdE+x);Gfq{A_Cg%NS0Lx;IpX2hD;!xoj(xzExs@g@ZcrZ7 z15q#gLc^Tak9 ziYhq})tZIql!ovC(^1tXiytyH8sns8nl|eP{U1$r3X{M-Lt!(=+wdzKG0p{nqritt zT^VibyIoj-k=dQ$ytVlE9%k_ci_7!G7x8Vt!O6)=$JIO!rlf@`?_e9ZMhGbWx4N!8 z8p^hfBiYJOwumH4))XRQyhxKRSu(~t_H1KqFbpjy2~B0+#~Rt!2t^}1*#_fP3?@6n zOf=|w^uFhO-*?{cd;Yr4^ZfIi`#$&i{jTf2e%EhmNnaH?^BBw_kiwnUVPCaPG#T0t z6caDrS$2ajI{OqJ+!nv}P!UJRc2hHPK+g6oQh`a}mVhUGDo|7gvUEA|+C)<~5~t+< zNl^GOC`zSe6?s_wr8Aeo&2p*j(rj|I-=N^U;E%p;rb68JYq0SjJ=B=eGFU;z7w#zv z*>yrgTGXwi_G%;8pwTpa8aEGN63li^ILj;H%?~t?O$9Z8#>C$b;F^sKUc4OIeJIL&`aVby^Mp5 zCOtQCNGi#bG4Q)i^nIHFeTN%wK784Ily5#HWQDIg$xQ55;W(fp2p-0Y{ zq<0N#^Sm;(E!Hk@O(E~I?t%wRDY&JAduf@P1q4;+?eo$zA8H-c_Ps&MZP31%sdJKa zuj@)fF2kQ=NH2G$-F-LzBq<~Bfrg>B@E;Xy-T)cUzr#9yed1v0cs+x418~}y%JSF^ zpUco5bm|g06N^L5v1+IsO{jk7^7h*)^g>k&!i*JSp#D_AX?`t)scBbjq8ZnTT9uVP z8`O~NLTvp8-)82^Nkh*yvDe#u>yTwm%Z@+g#Qy23|7L6k2x=fany7VvjalM(Xuos{ zY)4^y6QLQ7JG;@kW8|gjBBp-GKVwIc-O5FR(5;xOUtyNCc8_{X^LC;HMolUnWEbK! z+4xLDu+5TB^+MDru+rbRC&=pt&N;Sw5tdHrFmlh6{ruyKj-CE98N$ZU4fWRj^OmN-e2m8yO*!I3ch8rk@PfZB#WYBK-4VDIRZ; zxMnd@v{LwN&3vq_WVw7^tB6|JaC5M%nSccN!||J(=h!CgU2L1EHs#)8vxOt8lNh3v z*?n7~GaaF)^+BOjn+1Vx_Nm4GJL$OL24j|IDBPO%ooVhmRxLewkx1*KAq9=U%gBF) zb!ty?I{b);YCejYQ9M7sKtkKRsQ$BIrcUMNbU6M3kkR-e?CVPZ)L$LgP(Ot&^a$-6 zRb`ZeC@xn<+BS+BtbZOmr$CA=w=}wVBk`|~976jtZrc&Qa=ddT16g|ybu`=T*3P;) ze1xsmPqoyX_~FzrDJgqx#mYwXK1l1q+svA4p^Kq-N0Cbb)IJX9wUoQWSe3n^+xnc( zJSM%B$;O$1a^+QOU$2;&_!9gW8FAkTl{#_ZaO=?s5QOzOiR#dOkfOs}noVyPm*6A% z{_L4AK&$0z9_^6~Jl!B4%v0l-Y>@4dV+=wCzYqurNf6K@SxLj9-x~BDVE=OXA{9sj z%Z~EO>EDi(e}}{6w%3iCZ?7=An3QtPxi)6S7F6^a-YGJ3U;LU89cNUG0>&?ViYt)J zS*9p?d0|r{0s_^>C{kCEw%S9ZwfrS^8&*J}UL~Uh@8mJnSJ&9NEcgiA8Yc!wV1L~$ zu}c8{7C$HOCV&N{a=D2Y@L4c|5@bsPSeDIGkF$v!(mc|2ffd^0yusopQ|!$$D zdunI~1MrZ?PZ|X8+T%Y?@ebL-*8S-rLW4{AXfz~cBf6NPu3tyaS9+S%IZIe3MD^bi zy&uReBtW=^ahr|oe^MF#%CgQ;%~hAa<=G5@j~J)Uwj+)5;b|VzMr1{W4bruTsv9no z{dPyGM0JsJ(NxxP=)+f*wF>bd-Oyn11wCcqks%g%SFF2O^yDN3#qg%g-lfAQy?UjB zH6JlL#DL{jQjUMvkFLz7#XMJmz6i@f3TUB~(T z!^YQu%xOB!AtP@cTsvXF=p;M7bm z_@TT&>&raTtjjf(^Aw$KL7rls90+H!6KF6EjO^$4d4UO;-fIr#S+=2FKxim%aVLKY z6#HZ*5`>rW0+%Nq3>8GhRcL%RlP|!u;wd{pk_l}o?XTD6wiv>+nH54!1c>2zIe~hv zOW5v{oX6=m{MwV%(}Tl3N&V!-u3%HXe7FVoqnE>$SC_yS(&*J9uw16W_h)^%|1U4r zpKJ`R&y4{YdWR~wcZQvAmXW->_R2tqvjG^t^0Nn`o50g6pVZsS5#F06U;iRtlkC_@ zSy+!2Uk&k*(Cao!QQPLKzONOS5X-cJoC$W5iwJjTs3|mZz^#wmVUT`Sln&?eKHzP--wl{Bu9q1Y9 zRPDioZ|9}@thYfjGl!3~QHs^g{1se;V2?g?*W2MKJg_Jw&_+iW8zy7B+x(o+FQ9(h zv-rrhJ0KVXVB|ND#u@Si?XC4Mk?m$>E&5S5i1AD}#jNQ2c7=vcxJm74#_1wRPc>e)Ek+7cP7!%b3_gD;p82McTffRe(f8!u3as!{73KY9R|YeCOP!xo zVW}NfQCCN{n1{L(1|O*g5^rQOD`2~1$E+}S@r}cI)Kr8jiMn$p{dICA=kUd}&CLfT zG}F6zbM+TQEbYCVWGwRD#U@K1oOs4CyG_Q zP+e{=mZR$%!G5*4Y#kf%s%AcmjQzYRrGC}nnyuVj`?dhY;H_PB0RNCm8vXWSwWJFu zubH%UK<%OV=XJ^>z^orpz3rW^NxRnLeo6t*=#%kI_wxD0$-az{lpGD1rGZ$7E9j%g`e%s67I!zOu%x;733^AFcdiA3eys3gO zLm5r|H$`WHg=&puHey%SxfFzg^=7iT5-w>3PrmJ8Wh^}Y4OG0m88}3#EyD?*oCyCfRNptdD9!@F z91k)*4v1}IJy)9|;d(KduR=aw3z86S?zdPu0mx5HxY8C+wgtG6Ra1kWzkErShcqJr zJOhT2Bi!&V5`dL-gUr#b_dl4+e~_5}8M25(`a*wk`~hQ+no{*urvn~6-P Date: Fri, 31 Jul 2026 00:57:56 +0530 Subject: [PATCH 03/10] docs: update README screenshots layout with nested tables Replace simple table with nested structure to better organize screenshots across multiple platforms (YouTube, Universal Web Media, YouTube Music). Improve readability and visual presentation. --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ac8ac81..0ebd647 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,48 @@ A privacy-first Web Extension that automatically displays your media playback in ## Screenshots -| Extension Control Panel | Discord Presence of Youtube Video | -|---|---| -| ![Extension Control Panel](assets/popup-screenshot.png) | ![Discord Presence of Youtube Video](assets/discord-rpc-1.png) | + + + + + + + + + + + + + + + +
Extension Control PanelDiscord Presence Examples
+ Extension Control Panel + + + + + + + + + + + + + + + + + + + + + + +
Platform / ContextPreview
YouTube VideoDiscord Presence of Youtube Video
Universal Web MediaDiscord Presence of Universal Web Media
YouTube MusicDiscord Presence of Youtube Music
+
-| Discord Presence of Universal Web Media | Discord Presence of Youtube Music | -|---|---| -| ![Discord Presence of Universal Web Media](assets/discord-rpc-2.png) | ![Discord Presence of Youtube Music](assets/discord-rpc-3.png) | - ---- ## Key Features From ded2ca30ac359c367ab867e5f5f735358ce9fa19 Mon Sep 17 00:00:00 2001 From: Sahaj Italiya Date: Fri, 31 Jul 2026 00:58:47 +0530 Subject: [PATCH 04/10] Update README.md --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0ebd647..fdd81df 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,20 @@ A privacy-first Web Extension that automatically displays your media playback in ## Screenshots - +
- + - - - +
Extension Control PanelExtension Control Panel Discord Presence Examples
- Extension Control Panel + + + Extension Control Panel @@ -47,6 +47,7 @@ A privacy-first Web Extension that automatically displays your media playback in
+ ## Key Features ### Universal Web Media Detection @@ -106,4 +107,4 @@ Copyright (C) 2026 This program is free software: you can redistribute it and/or modify it under the terms of the **GNU Affero General Public License** as published by the Free Software Foundation, version 3. -See the [LICENSE](LICENSE) file for more details. \ No newline at end of file +See the [LICENSE](LICENSE) file for more details. From a4e979166284cc21638f7a2d646a0ffbbe9033b8 Mon Sep 17 00:00:00 2001 From: Sahaj Italiya Date: Fri, 31 Jul 2026 01:00:29 +0530 Subject: [PATCH 05/10] Adjust table widths and alignment in README Updated table widths and alignment in README for better layout. --- README.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index fdd81df..b82d583 100644 --- a/README.md +++ b/README.md @@ -4,40 +4,40 @@ A privacy-first Web Extension that automatically displays your media playback in ## Screenshots - +
- + - - - +
Extension Control PanelExtension Control Panel Discord Presence Examples
+ + Extension Control Panel - + - - + + - - + + - - + +
Platform / ContextPlatform / Context Preview
YouTube VideoDiscord Presence of Youtube VideoYouTube VideoDiscord Presence of Youtube Video
Universal Web MediaDiscord Presence of Universal Web MediaUniversal Web MediaDiscord Presence of Universal Web Media
YouTube MusicDiscord Presence of Youtube MusicYouTube MusicDiscord Presence of Youtube Music
@@ -47,7 +47,6 @@ A privacy-first Web Extension that automatically displays your media playback in
- ## Key Features ### Universal Web Media Detection From b3aa3693cc7359492f2be0c4447cd2f2a305033c Mon Sep 17 00:00:00 2001 From: sizwinz Date: Fri, 31 Jul 2026 01:08:58 +0530 Subject: [PATCH 06/10] feat: improve active media selection and tab-based cleanup - Add stale timeout (15s) to filter out outdated media entries - Prioritize playing media when selecting active media - Track media per tab using tabId from message sender - Remove ended media from state instead of marking as ended - Clean up media when tabs are closed or updated to prevent stale entries --- src/extension/background.js | 63 +++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/src/extension/background.js b/src/extension/background.js index ae54830..f4b2f4b 100644 --- a/src/extension/background.js +++ b/src/extension/background.js @@ -383,9 +383,22 @@ function isSourceEnabled(source) { } function chooseActiveMedia() { + const now = Date.now(); + const STALE_TIMEOUT_MS = 15000; + return Object.values(mediaBySource) - .filter((media) => state.enabled && isSourceEnabled(media.source) && !media.ended && !isSuppressed(media)) + .filter((media) => { + if (!state.enabled || !isSourceEnabled(media.source) || media.ended || isSuppressed(media)) { + return false; + } + const age = now - (media.updatedAt || 0); + if (age > STALE_TIMEOUT_MS) return false; + return true; + }) .sort((a, b) => { + const aPlaying = !a.paused; + const bPlaying = !b.paused; + if (aPlaying !== bPlaying) return bPlaying ? 1 : -1; const aStarted = a.lastStartedAt || 0; const bStarted = b.lastStartedAt || 0; if (aStarted !== bStarted) return bStarted - aStarted; @@ -619,17 +632,17 @@ function handleVideoUpdate(video) { }); } -browserApi.runtime.onMessage.addListener((message, _sender, sendResponse) => { +browserApi.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message || !message.type) return false; if (message.type === "youtube-video") { - handleVideoUpdate({ platform: "YouTube", source: "youtube", action: "Watch", mediaId: message.video.videoId, ...message.video }); + handleVideoUpdate({ platform: "YouTube", source: "youtube", action: "Watch", mediaId: message.video.videoId, tabId: sender.tab?.id, ...message.video }); sendResponse({ ok: true }); return true; } if (message.type === "media-update") { - handleVideoUpdate(message.media); + handleVideoUpdate({ tabId: sender.tab?.id, ...message.media }); sendResponse({ ok: true }); return true; } @@ -653,11 +666,7 @@ browserApi.runtime.onMessage.addListener((message, _sender, sendResponse) => { suppressCurrentMedia(source); if (source) { const next = { ...mediaBySource }; - if (next[source]) { - next[source] = { ...next[source], ended: true, paused: true, updatedAt: Date.now() }; - } else { - delete next[source]; - } + delete next[source]; mediaBySource = next; } else { mediaBySource = {}; @@ -863,3 +872,39 @@ browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession", "rpcWhi saveState(); }); +if (browserApi.tabs && browserApi.tabs.onRemoved) { + browserApi.tabs.onRemoved.addListener((tabId) => { + let changed = false; + const nextMedia = { ...mediaBySource }; + for (const [src, media] of Object.entries(nextMedia)) { + if (media.tabId === tabId) { + delete nextMedia[src]; + changed = true; + } + } + if (changed) { + mediaBySource = nextMedia; + syncActivePresence(true).catch(() => {}); + } + }); +} + +if (browserApi.tabs && browserApi.tabs.onUpdated) { + browserApi.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === "loading" || changeInfo.url) { + let changed = false; + const nextMedia = { ...mediaBySource }; + for (const [src, media] of Object.entries(nextMedia)) { + if (media.tabId === tabId && changeInfo.url) { + delete nextMedia[src]; + changed = true; + } + } + if (changed) { + mediaBySource = nextMedia; + syncActivePresence(true).catch(() => {}); + } + } + }); +} + From 0d85155b6a0758cff33bce64003551f2f8d4372e Mon Sep 17 00:00:00 2001 From: sizwinz Date: Fri, 31 Jul 2026 13:56:06 +0530 Subject: [PATCH 07/10] feat: add per-media itemOverrides and set-item-override handler --- src/extension/background.js | 171 +++++++++++++++++++++++++----------- 1 file changed, 122 insertions(+), 49 deletions(-) diff --git a/src/extension/background.js b/src/extension/background.js index f4b2f4b..55fe68f 100644 --- a/src/extension/background.js +++ b/src/extension/background.js @@ -19,6 +19,7 @@ const DEFAULT_STATE = { }, rpcWhitelist: ["youtube.com", "soundcloud.com"], mediaOverrides: {}, + itemOverrides: {}, lastError: "", lastVideo: null, lastMediaPage: null, @@ -561,19 +562,31 @@ async function logoutDiscord() { saveState(); } +function getMediaKey(video) { + if (!video) return ""; + const id = video.videoId || video.mediaId || video.url || ""; + return `${video.source || "media"}:${id}`; +} + function applyMediaOverrides(video) { if (!video) return video; const domain = video.domain || video.source; - const overrides = state.mediaOverrides?.[domain]; - if (!overrides) return video; + const domainOverrides = state.mediaOverrides?.[domain] || {}; + const mediaKey = getMediaKey(video); + const itemOverride = state.itemOverrides?.[mediaKey] || {}; + + const customTitle = itemOverride.title !== undefined ? itemOverride.title : (domainOverrides.title || ""); + const resolvedTitle = customTitle || video.title; return { ...video, - platform: overrides.platform || video.platform, - title: overrides.title || video.title, - thumbnail: overrides.image || video.thumbnail, - largeImage: overrides.image || video.largeImage, - url: overrides.url ? normalizeUrl(overrides.url) : video.url + mediaKey, + platform: domainOverrides.platform || video.platform, + title: resolvedTitle, + customTitle, + thumbnail: itemOverride.image || domainOverrides.image || video.thumbnail, + largeImage: itemOverride.image || domainOverrides.image || video.largeImage, + url: itemOverride.url ? normalizeUrl(itemOverride.url) : (domainOverrides.url ? normalizeUrl(domainOverrides.url) : video.url) }; } @@ -583,7 +596,7 @@ function isGenericTitleString(str) { return /^(player|video|watch|stream|play|html5 player|jw player|video player|\w+\.php|\w+\.html)$/i.test(s) || s.endsWith(".php") || s.endsWith(".html"); } -function handleVideoUpdate(video) { +function handleVideoUpdate(video, senderTabId) { const source = normalizeSource(video.source || video.platform || "youtube"); const previous = mediaBySource[source]; const pageTitle = state.lastMediaPage?.title; @@ -593,6 +606,8 @@ function handleVideoUpdate(video) { resolvedTitle = pageTitle; } + const tabId = senderTabId !== undefined ? senderTabId : (video.tabId !== undefined ? video.tabId : previous?.tabId); + const isSameMedia = previous && ( (video.videoId && previous.videoId === video.videoId) || (video.mediaId && previous.mediaId === video.mediaId) || @@ -604,6 +619,7 @@ function handleVideoUpdate(video) { const media = applyMediaOverrides({ ...baseMedia, ...video, + tabId, title: resolvedTitle, source, lastStartedAt: video.lastStartedAt || (becamePlaying ? Date.now() : baseMedia?.lastStartedAt || video.updatedAt || Date.now()) @@ -635,14 +651,16 @@ function handleVideoUpdate(video) { browserApi.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message || !message.type) return false; + const senderTabId = sender.tab?.id; + if (message.type === "youtube-video") { - handleVideoUpdate({ platform: "YouTube", source: "youtube", action: "Watch", mediaId: message.video.videoId, tabId: sender.tab?.id, ...message.video }); + handleVideoUpdate({ platform: "YouTube", source: "youtube", action: "Watch", mediaId: message.video.videoId, ...message.video }, senderTabId); sendResponse({ ok: true }); return true; } if (message.type === "media-update") { - handleVideoUpdate({ tabId: sender.tab?.id, ...message.media }); + handleVideoUpdate(message.media, senderTabId); sendResponse({ ok: true }); return true; } @@ -841,14 +859,30 @@ browserApi.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (message.type === "set-item-override") { + const { mediaKey, title, image, url } = message; + const itemOverrides = { ...(state.itemOverrides || {}) }; + if (!title && !image && !url) { + delete itemOverrides[mediaKey]; + } else { + itemOverrides[mediaKey] = { title, image, url }; + } + state = { ...state, itemOverrides }; + browserApi.storage.local.set({ itemOverrides }); + syncActivePresence(true); + sendResponse({ ok: true, itemOverrides }); + return true; + } + return false; }); -browserApi.runtime.onInstalled.addListener(() => { - browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession", "rpcWhitelist", "mediaOverrides"], (result) => { +function initStorage() { + browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession", "rpcWhitelist", "mediaOverrides", "itemOverrides"], (result) => { state = { ...DEFAULT_STATE, ...(result.rpcState || {}) }; state.rpcWhitelist = result.rpcWhitelist || ["youtube.com", "soundcloud.com"]; state.mediaOverrides = result.mediaOverrides || {}; + state.itemOverrides = result.itemOverrides || {}; state.siteEnabled = { ...DEFAULT_STATE.siteEnabled, ...(state.siteEnabled || {}) }; tokens = result.discordTokens || null; headlessToken = result.rpcSession?.headlessToken || ""; @@ -857,54 +891,93 @@ browserApi.runtime.onInstalled.addListener(() => { state = { ...state, authenticated: Boolean(tokens?.accessToken), connected: Boolean(tokens?.accessToken) }; saveState(); }); -}); +} -browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession", "rpcWhitelist", "mediaOverrides"], (result) => { - state = { ...DEFAULT_STATE, ...(result.rpcState || {}) }; - state.rpcWhitelist = result.rpcWhitelist || ["youtube.com", "soundcloud.com"]; - state.mediaOverrides = result.mediaOverrides || {}; - state.siteEnabled = { ...DEFAULT_STATE.siteEnabled, ...(state.siteEnabled || {}) }; - tokens = result.discordTokens || null; - headlessToken = result.rpcSession?.headlessToken || ""; - headlessTokens = result.rpcSession?.headlessTokens || (headlessToken ? [headlessToken] : []); - suppressedBySource = result.rpcSession?.suppressedBySource || {}; - state = { ...state, authenticated: Boolean(tokens?.accessToken), connected: Boolean(tokens?.accessToken) }; - saveState(); -}); +browserApi.runtime.onInstalled.addListener(initStorage); +initStorage(); + +function cleanupTabMedia(tabId) { + let changed = false; + const nextMedia = { ...mediaBySource }; + for (const [src, media] of Object.entries(nextMedia)) { + if (media.tabId === tabId) { + delete nextMedia[src]; + changed = true; + } + } + if (changed) { + mediaBySource = nextMedia; + syncActivePresence(true).catch(() => {}); + } +} if (browserApi.tabs && browserApi.tabs.onRemoved) { browserApi.tabs.onRemoved.addListener((tabId) => { - let changed = false; - const nextMedia = { ...mediaBySource }; - for (const [src, media] of Object.entries(nextMedia)) { - if (media.tabId === tabId) { - delete nextMedia[src]; - changed = true; - } - } - if (changed) { - mediaBySource = nextMedia; - syncActivePresence(true).catch(() => {}); - } + cleanupTabMedia(tabId); }); } if (browserApi.tabs && browserApi.tabs.onUpdated) { browserApi.tabs.onUpdated.addListener((tabId, changeInfo) => { - if (changeInfo.status === "loading" || changeInfo.url) { - let changed = false; - const nextMedia = { ...mediaBySource }; - for (const [src, media] of Object.entries(nextMedia)) { - if (media.tabId === tabId && changeInfo.url) { - delete nextMedia[src]; - changed = true; + if (changeInfo.status === "loading" && changeInfo.url) { + cleanupTabMedia(tabId); + } + }); +} + +if (browserApi.windows && browserApi.windows.onRemoved) { + browserApi.windows.onRemoved.addListener(() => { + if (browserApi.windows.getAll) { + browserApi.windows.getAll({ populate: false }, (windows) => { + if (!windows || windows.length === 0) { + mediaBySource = {}; + clearActivePresence().catch(() => {}); } - } - if (changed) { - mediaBySource = nextMedia; - syncActivePresence(true).catch(() => {}); - } + }); } }); } +if (browserApi.runtime && browserApi.runtime.onSuspend) { + browserApi.runtime.onSuspend.addListener(() => { + clearPresence({ waitForWrites: false }).catch(() => {}); + }); +} + +// Background heartbeat to purge stale media, verify tabs, and maintain Discord presence consistency +setInterval(() => { + const now = Date.now(); + const STALE_THRESHOLD_MS = 12000; + let changed = false; + const nextMedia = { ...mediaBySource }; + + for (const [src, media] of Object.entries(nextMedia)) { + const age = now - (media.updatedAt || 0); + if (age > STALE_THRESHOLD_MS) { + delete nextMedia[src]; + changed = true; + continue; + } + + if (media.tabId !== undefined && browserApi.tabs && browserApi.tabs.get) { + browserApi.tabs.get(media.tabId, (tab) => { + if (browserApi.runtime.lastError || !tab) { + if (mediaBySource[src]?.tabId === media.tabId) { + const current = { ...mediaBySource }; + delete current[src]; + mediaBySource = current; + syncActivePresence(true).catch(() => {}); + } + } + }); + } + } + + if (changed) { + mediaBySource = nextMedia; + } + + syncActivePresence(false).catch(() => {}); +}, 5000); + + From e5987d9f30659d0c2190a22bd300f7f4d3ab6ae6 Mon Sep 17 00:00:00 2001 From: sizwinz Date: Fri, 31 Jul 2026 13:58:38 +0530 Subject: [PATCH 08/10] feat: pre-fill custom title input and save per-media title overrides --- src/extension/popup.js | 76 +++++++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/src/extension/popup.js b/src/extension/popup.js index 261c90e..24a2c1b 100644 --- a/src/extension/popup.js +++ b/src/extension/popup.js @@ -55,7 +55,9 @@ function updateDomainUI(state) { const override = state.mediaOverrides?.[currentDomain]; if (override && document.activeElement !== overrideTitle && document.activeElement !== overrideImage && document.activeElement !== overrideUrl && document.activeElement !== overridePlatform) { - overrideTitle.value = override.title || ""; + if (!state.lastVideo) { + overrideTitle.value = override.title || ""; + } overrideImage.value = override.image || ""; overrideUrl.value = override.url || ""; overridePlatform.value = override.platform || ""; @@ -81,10 +83,16 @@ function render(state) { title.textContent = video.title || "Untitled video"; author.textContent = video.author ? `by ${video.author}` : ""; time.textContent = `${formatTime(video.currentTime)} elapsed, ${formatTime(video.timeLeft)} left`; + if (document.activeElement !== overrideTitle) { + overrideTitle.value = video.customTitle || video.title || ""; + } } else { title.textContent = "No media detected"; author.textContent = ""; time.textContent = ""; + if (document.activeElement !== overrideTitle && !state.mediaOverrides?.[currentDomain]?.title) { + overrideTitle.value = ""; + } } if (!video && state.lastMediaPage) { @@ -184,31 +192,69 @@ toggleWhitelist.addEventListener("click", () => { }); saveOverride.addEventListener("click", () => { - if (!currentDomain) return; const newTitle = overrideTitle.value.trim(); const newImage = overrideImage.value.trim(); const newUrl = overrideUrl.value.trim(); const newPlatform = overridePlatform.value.trim(); - browserApi.runtime.sendMessage( - { type: "set-domain-override", domain: currentDomain, title: newTitle, image: newImage, url: newUrl, platform: newPlatform }, - () => { + + let lastVideoMediaKey = ""; + browserApi.runtime.sendMessage({ type: "get-state" }, (currentState) => { + const video = currentState?.lastVideo; + if (video && video.mediaKey) { + lastVideoMediaKey = video.mediaKey; + browserApi.runtime.sendMessage({ + type: "set-item-override", + mediaKey: video.mediaKey, + title: newTitle, + image: newImage, + url: newUrl + }); + } + + if (currentDomain) { + browserApi.runtime.sendMessage( + { + type: "set-domain-override", + domain: currentDomain, + title: lastVideoMediaKey ? "" : newTitle, + image: newImage, + url: newUrl, + platform: newPlatform + }, + () => refresh() + ); + } else { refresh(); } - ); + }); }); resetOverride.addEventListener("click", () => { - if (!currentDomain) return; - overrideTitle.value = ""; - overrideImage.value = ""; - overrideUrl.value = ""; - overridePlatform.value = ""; - browserApi.runtime.sendMessage( - { type: "set-domain-override", domain: currentDomain, title: "", image: "", url: "", platform: "" }, - () => { + const newPlatform = ""; + browserApi.runtime.sendMessage({ type: "get-state" }, (currentState) => { + const video = currentState?.lastVideo; + if (video && video.mediaKey) { + browserApi.runtime.sendMessage({ + type: "set-item-override", + mediaKey: video.mediaKey, + title: "", + image: "", + url: "" + }); + } + + if (currentDomain) { + overrideImage.value = ""; + overrideUrl.value = ""; + overridePlatform.value = ""; + browserApi.runtime.sendMessage( + { type: "set-domain-override", domain: currentDomain, title: "", image: "", url: "", platform: "" }, + () => refresh() + ); + } else { refresh(); } - ); + }); }); callbackForm.addEventListener("submit", (event) => { From 0d9769821a2d3e496ddce65783ca2f035f77c44e Mon Sep 17 00:00:00 2001 From: sizwinz Date: Fri, 31 Jul 2026 14:01:04 +0530 Subject: [PATCH 09/10] feat: complete media detection robustness and per-media custom title feature --- .../2026-07-31-per-media-custom-title.md | 196 ++++++++++++++++++ ...026-07-31-per-media-custom-title-design.md | 67 ++++++ manifests/firefox/manifest.json | 12 +- scripts/build-firefox.js | 2 +- scripts/check.js | 1 + scripts/package-extension.js | 2 +- src/extension/content-generic.js | 50 ++++- src/extension/content-soundcloud.js | 29 ++- src/extension/content-youtube.js | 18 +- 9 files changed, 351 insertions(+), 26 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-31-per-media-custom-title.md create mode 100644 docs/superpowers/specs/2026-07-31-per-media-custom-title-design.md diff --git a/docs/superpowers/plans/2026-07-31-per-media-custom-title.md b/docs/superpowers/plans/2026-07-31-per-media-custom-title.md new file mode 100644 index 0000000..af6e13e --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-per-media-custom-title.md @@ -0,0 +1,196 @@ +# Per-Media Custom Title Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to override custom titles per specific media item (rather than site-wide) and pre-fill the popup's title input with the detected video title for fast editing. + +**Architecture:** Extend extension state with `itemOverrides` mapping `mediaKey` to custom properties (specifically `title`). Pre-fill popup input field with `video.title` when popup opens or active video changes. + +**Tech Stack:** JavaScript (ES6+), WebExtension API (`chrome.storage.local` / `browser.storage.local`), HTML5. + +## Global Constraints + +- Preserve browser compatibility across Chromium and Firefox MV2/MV3. +- Use `npm run check` and `npm run build` for runtime and build verification. + +--- + +### Task 1: Background State & Message Handler for Per-Media Overrides + +**Files:** +- Modify: `src/extension/background.js` + +**Interfaces:** +- Consumes: `video` object containing `mediaId`, `videoId`, `url`, `source` +- Produces: `itemOverrides` in `state` and `storage.local`, `set-item-override` message listener + +- [ ] **Step 1: Update background state initialization and `applyMediaOverrides`** + +In `src/extension/background.js`, add `itemOverrides: {}` to `DEFAULT_STATE` and `initStorage()`. +Update `applyMediaOverrides(video)`: +```javascript +function getMediaKey(video) { + if (!video) return ""; + const id = video.videoId || video.mediaId || video.url || ""; + return `${video.source || "media"}:${id}`; +} + +function applyMediaOverrides(video) { + if (!video) return video; + const domain = video.domain || video.source; + const domainOverrides = state.mediaOverrides?.[domain] || {}; + const mediaKey = getMediaKey(video); + const itemOverride = state.itemOverrides?.[mediaKey] || {}; + + const resolvedTitle = itemOverride.title || domainOverrides.title || video.title; + + return { + ...video, + mediaKey, + platform: domainOverrides.platform || video.platform, + title: resolvedTitle, + customTitle: itemOverride.title || domainOverrides.title || "", + thumbnail: itemOverride.image || domainOverrides.image || video.thumbnail, + largeImage: itemOverride.image || domainOverrides.image || video.largeImage, + url: itemOverride.url ? normalizeUrl(itemOverride.url) : (domainOverrides.url ? normalizeUrl(domainOverrides.url) : video.url) + }; +} +``` + +- [ ] **Step 2: Add `set-item-override` message handler to background listener** + +Add `set-item-override` to `browserApi.runtime.onMessage`: +```javascript + if (message.type === "set-item-override") { + const { mediaKey, title, image, url } = message; + const itemOverrides = { ...(state.itemOverrides || {}) }; + if (!title && !image && !url) { + delete itemOverrides[mediaKey]; + } else { + itemOverrides[mediaKey] = { title, image, url }; + } + state = { ...state, itemOverrides }; + browserApi.storage.local.set({ itemOverrides }); + syncActivePresence(true); + sendResponse({ ok: true, itemOverrides }); + return true; + } +``` + +- [ ] **Step 3: Run check script** + +Run: `npm run check` +Expected output: `js ok: src/extension/background.js` + +- [ ] **Step 4: Commit changes** + +```bash +git add src/extension/background.js +git commit -m "feat: add per-media itemOverrides and set-item-override handler" +``` + +--- + +### Task 2: Pre-fill & Per-Media Title Handling in Popup + +**Files:** +- Modify: `src/extension/popup.js` + +**Interfaces:** +- Consumes: `state.lastVideo` (including `video.mediaKey`, `video.title`, `video.customTitle`) +- Produces: Pre-filled title input, per-media save & reset message calls + +- [ ] **Step 1: Update popup `updateDomainUI` and `render` logic** + +In `src/extension/popup.js`: +Update `render(state)` / `updateDomainUI(state)` so that when active media is present (`video = state.lastVideo`), if the user is not actively typing in `overrideTitle` (`document.activeElement !== overrideTitle`), pre-fill `overrideTitle.value` with `video.customTitle || video.title || ""`. + +```javascript + const video = state.lastVideo; + if (video && document.activeElement !== overrideTitle) { + overrideTitle.value = video.customTitle || video.title || ""; + } +``` + +- [ ] **Step 2: Update `saveOverride` and `resetOverride` button click listeners** + +In `src/extension/popup.js`: +Update `saveOverride` click listener: +```javascript +saveOverride.addEventListener("click", () => { + const video = state.lastVideo; + const newTitle = overrideTitle.value.trim(); + const newImage = overrideImage.value.trim(); + const newUrl = overrideUrl.value.trim(); + const newPlatform = overridePlatform.value.trim(); + + if (video && video.mediaKey) { + browserApi.runtime.sendMessage( + { type: "set-item-override", mediaKey: video.mediaKey, title: newTitle, image: newImage, url: newUrl }, + () => refresh() + ); + } + + if (currentDomain && newPlatform) { + browserApi.runtime.sendMessage( + { type: "set-domain-override", domain: currentDomain, platform: newPlatform }, + () => refresh() + ); + } +}); +``` + +Update `resetOverride` click listener: +```javascript +resetOverride.addEventListener("click", () => { + const video = state.lastVideo; + if (video && video.mediaKey) { + browserApi.runtime.sendMessage( + { type: "set-item-override", mediaKey: video.mediaKey, title: "", image: "", url: "" }, + () => { + overrideTitle.value = video.title || ""; + refresh(); + } + ); + } + overrideImage.value = ""; + overrideUrl.value = ""; + overridePlatform.value = ""; +}); +``` + +- [ ] **Step 3: Run check script** + +Run: `npm run check` +Expected output: `js ok: src/extension/popup.js` + +- [ ] **Step 4: Commit changes** + +```bash +git add src/extension/popup.js +git commit -m "feat: pre-fill custom title input and save per-media title overrides" +``` + +--- + +### Task 3: Build & Verification + +**Files:** +- Verify: All files via check and build scripts + +- [ ] **Step 1: Run check script** + +Run: `npm run check` +Expected output: Exit code 0 with all json and js files ok. + +- [ ] **Step 2: Run build script** + +Run: `npm run build` +Expected output: Both chromium and firefox builds created in `dist/`. + +- [ ] **Step 3: Commit build updates if any** + +```bash +git add dist/ manifests/ +git commit -m "build: package updated extension with per-media custom titles" +``` diff --git a/docs/superpowers/specs/2026-07-31-per-media-custom-title-design.md b/docs/superpowers/specs/2026-07-31-per-media-custom-title-design.md new file mode 100644 index 0000000..15c25e1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-per-media-custom-title-design.md @@ -0,0 +1,67 @@ +# Design Spec: Per-Media Custom Title & Pre-Filled Title Input + +## Overview +Currently, Neurobox allows overriding title, image, target URL, and platform name via the extension control panel. However, title overrides are saved globally per domain (`state.mediaOverrides[domain]`). This causes every video on a site (e.g., YouTube) to share the same custom title. In addition, the title field in the popup starts blank. + +This specification introduces: +1. **Per-Media Custom Title Overrides**: Title overrides are scoped to the specific playing media item (`mediaId` / `url`) instead of the entire domain. +2. **Pre-filled Title Input**: When media is playing, the popup's Custom Title input field is automatically pre-filled with the active media's title (or custom title if set), making edits quick and intuitive. + +--- + +## Data Schema & Storage + +### Storage Key: `itemOverrides` +In addition to (or upgrading) `mediaOverrides`, we store per-item overrides in `browserApi.storage.local`: + +```json +{ + "itemOverrides": { + "youtube:dQw4w9WgXcQ": { + "title": "Rick Astley - Never Gonna Give You Up (Custom Title)" + }, + "generic:https://example.com/watch/movie-1": { + "title": "Custom Movie Title" + } + } +} +``` + +### Media Identity +A unique key is generated for media items: +- YouTube: `youtube:${videoId}` +- SoundCloud: `soundcloud:${mediaId}` +- Generic sites: `${domain}:${url}` or `generic:${mediaId}` + +--- + +## Component Changes + +### 1. `background.js` +- Maintain `itemOverrides` in background state and sync with `browserApi.storage.local`. +- In `applyMediaOverrides(video)`: + - Check `itemOverrides[mediaKey]` for a media-specific title override. + - If a media-specific title override exists, use it as `video.title`. + - Fall back to domain-level overrides for `platform`, `largeImage`, `url`. +- Add message handler `set-item-override`: + - Receives `{ mediaKey, title, image, url }`. + - Updates `itemOverrides[mediaKey]` and recalculates active presence. + +### 2. `popup.html` & `popup.js` +- When `video` is active in state: + - If user is NOT actively typing (`document.activeElement !== overrideTitle`): + - Set `overrideTitle.value = video.customTitle || video.title || ""`. + - Display helper text or placeholder showing "Pre-filled with detected title". +- When clicking **Save**: + - Save `overrideTitle.value` for `video.mediaKey` via `set-item-override`. + - Keep platform name override saved for `currentDomain`. +- When clicking **Reset**: + - Clear `itemOverrides[video.mediaKey]`. + - Restore `overrideTitle.value = video.detectedTitle`. + +--- + +## Verification Plan +1. `npm run check` syntax validation. +2. `npm run build` extension build verification. +3. Test title override on a specific video; confirm other videos on the same site retain their original auto-detected titles. diff --git a/manifests/firefox/manifest.json b/manifests/firefox/manifest.json index 112a120..3f40138 100644 --- a/manifests/firefox/manifest.json +++ b/manifests/firefox/manifest.json @@ -6,10 +6,7 @@ "permissions": [ "identity", "storage", - "*://*.youtube.com/*", - "*://youtu.be/*", - "*://soundcloud.com/*", - "*://*.soundcloud.com/*", + "", "https://discord.com/api/*", "https://discord.com/oauth2/*" ], @@ -34,6 +31,13 @@ "matches": ["*://soundcloud.com/*", "*://*.soundcloud.com/*"], "js": ["src/extension/content-soundcloud.js"], "run_at": "document_idle" + }, + { + "matches": [""], + "js": ["src/extension/content-generic.js"], + "all_frames": true, + "match_about_blank": true, + "run_at": "document_idle" } ], "browser_action": { diff --git a/scripts/build-firefox.js b/scripts/build-firefox.js index 455d063..2eef72c 100644 --- a/scripts/build-firefox.js +++ b/scripts/build-firefox.js @@ -18,7 +18,7 @@ function removeDir(dir) { removeDir(out); copyFile(path.join(root, "manifests", "firefox", "manifest.json"), path.join(out, "manifest.json")); -for (const file of ["config.js", "background.js", "content-youtube.js", "content-soundcloud.js", "popup.html", "popup.css", "popup.js"]) { +for (const file of ["config.js", "background.js", "content-youtube.js", "content-soundcloud.js", "content-generic.js", "popup.html", "popup.css", "popup.js"]) { copyFile(path.join(root, "src", "extension", file), path.join(out, "src", "extension", file)); } diff --git a/scripts/check.js b/scripts/check.js index 671a537..5be7fb3 100644 --- a/scripts/check.js +++ b/scripts/check.js @@ -10,6 +10,7 @@ const jsFiles = [ "src/extension/background.js", "src/extension/content-youtube.js", "src/extension/content-soundcloud.js", + "src/extension/content-generic.js", "src/extension/popup.js", "scripts/build-firefox.js", "scripts/package-extension.js" diff --git a/scripts/package-extension.js b/scripts/package-extension.js index 9d81f5f..024bc7c 100644 --- a/scripts/package-extension.js +++ b/scripts/package-extension.js @@ -39,7 +39,7 @@ function buildTarget(target) { copyFile(path.join(root, "LICENSE"), path.join(out, "LICENSE")); copyFile(path.join(root, "PRIVACY.md"), path.join(out, "PRIVACY.md")); - for (const file of ["config.js", "background.js", "content-youtube.js", "content-soundcloud.js", "popup.html", "popup.css", "popup.js"]) { + for (const file of ["config.js", "background.js", "content-youtube.js", "content-soundcloud.js", "content-generic.js", "popup.html", "popup.css", "popup.js"]) { copyFile(path.join(root, "src", "extension", file), path.join(out, "src", "extension", file)); } diff --git a/src/extension/content-generic.js b/src/extension/content-generic.js index b8f3952..a391bd6 100644 --- a/src/extension/content-generic.js +++ b/src/extension/content-generic.js @@ -10,36 +10,69 @@ function cleanTitle(rawTitle) { if (!rawTitle) return ""; return rawTitle - .replace(/\s*[\-\|]\s*(Watch|Free|Online|HD|1080p|Full Movie|Streaming).*$/i, "") - .replace(/\s*\((Watch|Free|Online|HD|1080p)\)/gi, "") + .replace(/^\(\d+\)\s*/, "") + .replace(/\s*[\-\|]\s*(Watch|Free|Online|HD|1080p|Full Movie|Streaming|Official Video|Official Audio).*$/i, "") + .replace(/\s*\((Watch|Free|Online|HD|1080p|Full Movie)\)/gi, "") + .replace(/\s*\[(Watch|Free|Online|HD|1080p|Full Movie)\]/gi, "") .trim(); } function getMediaTitle() { const ogTitle = document.querySelector('meta[property="og:title"]')?.getAttribute("content"); - const rawTitle = ogTitle || document.title || "Video"; + const twitterTitle = document.querySelector('meta[name="twitter:title"]')?.getAttribute("content"); + const rawTitle = ogTitle || twitterTitle || document.title || "Video"; return cleanTitle(rawTitle); } function toAbsoluteUrl(url) { if (!url) return ""; try { - return new URL(url, window.location.origin).href; + const abs = new URL(url, window.location.origin).href; + return /^https?:\/\//i.test(abs) ? abs : ""; } catch (_) { return ""; } } function getMediaPoster() { + const activeMedia = getActiveMediaElement(); + const mediaPoster = activeMedia && activeMedia.poster ? activeMedia.poster : ""; const ogImage = document.querySelector('meta[property="og:image"]')?.getAttribute("content"); const twitterImage = document.querySelector('meta[name="twitter:image"]')?.getAttribute("content"); - const raw = ogImage || twitterImage || "/favicon.ico"; - return toAbsoluteUrl(raw); + const linkImage = document.querySelector('link[rel="image_src"]')?.getAttribute("href"); + const appleTouchIcon = document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute("href"); + + const raw = mediaPoster || ogImage || twitterImage || linkImage || appleTouchIcon; + const absUrl = toAbsoluteUrl(raw); + if (absUrl && !absUrl.endsWith(".ico") && !absUrl.includes("favicon")) { + return absUrl; + } + return ""; + } + + function findAllMediaElements(root = document) { + const media = []; + const elements = Array.from(root.querySelectorAll("video, audio")); + media.push(...elements); + + const allNodes = Array.from(root.querySelectorAll("*")); + for (const node of allNodes) { + if (node.shadowRoot) { + media.push(...findAllMediaElements(node.shadowRoot)); + } + } + return media; } function getActiveMediaElement() { - const elements = Array.from(document.querySelectorAll("video, audio")); - return elements.find((el) => !el.paused && Number.isFinite(el.duration) && el.duration > 0) || elements[0] || null; + const elements = findAllMediaElements(document); + if (elements.length === 0) return null; + + const playing = elements.find((el) => !el.paused && !el.ended && Number.isFinite(el.duration) && el.duration > 0); + if (playing) return playing; + + const withDuration = elements.filter((el) => Number.isFinite(el.duration) && el.duration > 0); + return withDuration[0] || elements[0] || null; } function getEffectiveDomain() { @@ -180,3 +213,4 @@ } }, SEND_INTERVAL_MS); })(); + diff --git a/src/extension/content-soundcloud.js b/src/extension/content-soundcloud.js index a5e2ad4..e131b3d 100644 --- a/src/extension/content-soundcloud.js +++ b/src/extension/content-soundcloud.js @@ -59,19 +59,21 @@ function getTitle() { text(".playbackSoundBadge__titleLink span") || text(".playbackSoundBadge__titleLink") || text(".playbackSoundBadge__title") || + text("a.soundTitle__title") || attr('meta[property="og:title"]', "content") || document.title.replace(/\s+\|\s+Listen online for free on SoundCloud$/, "").replace(/\s+on SoundCloud$/, "") ); - return title.replace(/^Current track:\s*/i, ""); + return title.replace(/^Current track:\s*/i, "").trim(); } function getAuthor() { return ( text(".playbackSoundBadge__lightLink") || text(".playbackSoundBadge__username") || + text("a.soundTitle__username") || attr('meta[property="music:musician"]', "content") || attr('meta[name="twitter:audio:artist_name"]', "content") - ); + ).trim(); } function getTrackUrl() { @@ -96,12 +98,17 @@ function getArtwork() { .replace(/-large\./, "-t500x500."); } -function getTrackId(trackUrl) { +function getTrackId(trackUrl, title = "", author = "") { try { const url = new URL(trackUrl); - return url.pathname.split("/").filter(Boolean).join(":") || url.href; + const pathParts = url.pathname.split("/").filter(Boolean); + const isGenericPage = pathParts.length <= 1 && ["discover", "stream", "feed", "you", "search", "upload", "notifications"].includes(pathParts[0]?.toLowerCase()); + if (isGenericPage || pathParts.length === 0) { + return `sc:${author.toLowerCase()}:${title.toLowerCase()}`; + } + return pathParts.join(":"); } catch (_error) { - return trackUrl || location.href; + return `sc:${author.toLowerCase()}:${title.toLowerCase()}`; } } @@ -125,7 +132,8 @@ function getSoundCloudPayload() { ); const paused = audio ? audio.paused : !isPlaying; const ended = audio ? audio.ended : Boolean(duration && currentTime >= duration); - const mediaId = getTrackId(url); + const author = getAuthor(); + const mediaId = getTrackId(url, title, author); if (mediaId && mediaId !== lastMediaId) { lastMediaId = mediaId; lastStartedAt = Date.now(); @@ -138,7 +146,7 @@ function getSoundCloudPayload() { url, mediaId, title, - author: getAuthor(), + author, thumbnail: getArtwork(), largeImage: getArtwork(), currentTime, @@ -183,6 +191,9 @@ function sendUpdate(force = false) { } function announceSeen() { + const title = getTitle(); + const author = getAuthor(); + const url = getTrackUrl(); browserApi.runtime.sendMessage( { type: "media-seen", @@ -190,8 +201,8 @@ function announceSeen() { platform: "SoundCloud", url: location.href, hasMediaElement: Boolean(getAudio() || document.querySelector(".playbackSoundBadge")), - mediaId: getTrackId(getTrackUrl()), - title: getTitle(), + mediaId: getTrackId(url, title, author), + title, updatedAt: Date.now() }, () => { diff --git a/src/extension/content-youtube.js b/src/extension/content-youtube.js index 7d65325..0636bea 100644 --- a/src/extension/content-youtube.js +++ b/src/extension/content-youtube.js @@ -25,23 +25,34 @@ function getVideoId() { return url.searchParams.get("v") || attr('meta[itemprop="videoId"]', "content"); } +function getMainVideoElement() { + const videos = Array.from(document.querySelectorAll("video")); + if (videos.length === 0) return null; + const active = videos.find((v) => !v.paused && !v.ended && Number.isFinite(v.duration) && v.duration > 0); + if (active) return active; + const valid = videos.filter((v) => Number.isFinite(v.duration) && v.duration > 0); + return valid[0] || videos[0] || null; +} + function getAuthor() { return ( text("ytd-watch-metadata #owner #channel-name a") || text("ytd-watch-metadata ytd-channel-name a") || text("#owner #channel-name a") || text("#owner #channel-name yt-formatted-string") || + text("#owner #channel-name yt-attributed-string") || text("ytd-reel-player-header-renderer #channel-name a") || text(".slim-owner-byline") || + text(".ytp-title-channel-name") || attr('link[itemprop="name"]', "content") || - attr('span[itemprop="author"] link[itemprop="name"]', "content") || - text(".ytp-title-channel-name") + attr('span[itemprop="author"] link[itemprop="name"]', "content") ); } function getTitle() { const title = ( text("ytd-watch-metadata #title yt-formatted-string") || + text("ytd-watch-metadata #title yt-attributed-string") || text("ytd-watch-metadata h1 yt-formatted-string") || text("ytd-watch-metadata h1") || text("h1.ytd-watch-metadata") || @@ -59,6 +70,7 @@ function getTitle() { return document.title .replace(/^\(\d+\)\s*/, "") .replace(/\s+-\s+YouTube$/i, "") + .replace(/\s+-\s+YouTube Music$/i, "") .trim(); } @@ -101,7 +113,7 @@ function getCategoryInfo() { } function getVideoPayload() { - const video = document.querySelector("video"); + const video = getMainVideoElement(); const videoId = getVideoId(); if (!video || !videoId) return null; From 872f5985be46cbb67b5c05dd1ada90c10fb0d32e Mon Sep 17 00:00:00 2001 From: sizwinz Date: Fri, 31 Jul 2026 19:15:29 +0530 Subject: [PATCH 10/10] fix: resolve stale media fallback, tab isolation, and real-time Discord presence teardown --- src/extension/background.js | 165 ++++++++++++++++++++++++------------ 1 file changed, 111 insertions(+), 54 deletions(-) diff --git a/src/extension/background.js b/src/extension/background.js index 55fe68f..5c3722e 100644 --- a/src/extension/background.js +++ b/src/extension/background.js @@ -93,6 +93,7 @@ async function requestDiscord(path, options = {}) { const accessToken = await getAccessToken(); const response = await fetch(`${DISCORD_API}${path}`, { ...options, + keepalive: true, headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", @@ -385,26 +386,25 @@ function isSourceEnabled(source) { function chooseActiveMedia() { const now = Date.now(); - const STALE_TIMEOUT_MS = 15000; + const ACTIVE_TIMEOUT_MS = 6000; - return Object.values(mediaBySource) - .filter((media) => { - if (!state.enabled || !isSourceEnabled(media.source) || media.ended || isSuppressed(media)) { - return false; - } - const age = now - (media.updatedAt || 0); - if (age > STALE_TIMEOUT_MS) return false; - return true; - }) - .sort((a, b) => { - const aPlaying = !a.paused; - const bPlaying = !b.paused; - if (aPlaying !== bPlaying) return bPlaying ? 1 : -1; - const aStarted = a.lastStartedAt || 0; - const bStarted = b.lastStartedAt || 0; - if (aStarted !== bStarted) return bStarted - aStarted; - return (b.updatedAt || 0) - (a.updatedAt || 0); - })[0] || null; + const candidates = Object.values(mediaBySource).filter((media) => { + if (!state.enabled || !isSourceEnabled(media.source) || media.ended || media.paused || isSuppressed(media)) { + return false; + } + const age = now - (media.updatedAt || 0); + if (age > ACTIVE_TIMEOUT_MS) return false; + return true; + }); + + if (candidates.length === 0) return null; + + return candidates.sort((a, b) => { + const aStarted = a.lastStartedAt || a.updatedAt || 0; + const bStarted = b.lastStartedAt || b.updatedAt || 0; + if (aStarted !== bStarted) return bStarted - aStarted; + return (b.updatedAt || 0) - (a.updatedAt || 0); + })[0]; } function mediaIdentity(media) { @@ -456,13 +456,20 @@ function enqueuePresenceUpdate(video, force = false) { return presenceWrite; } +function enqueueClearPresence() { + presenceWrite = presenceWrite + .catch(() => {}) + .then(() => performClearPresence()); + return presenceWrite; +} + async function syncActivePresence(force = false) { const active = chooseActiveMedia(); state = { ...state, lastVideo: active }; saveState(); if (!active) { - await clearPresence(); + await enqueueClearPresence(); return; } @@ -496,7 +503,7 @@ async function updatePresence(video, force = false) { } if (generation !== presenceGeneration || !state.enabled || isSuppressed(video)) { - await clearPresence({ waitForWrites: false }).catch(() => {}); + await performClearPresence().catch(() => {}); return; } @@ -507,10 +514,46 @@ async function updatePresence(video, force = false) { saveState(); } -async function clearPresence({ waitForWrites = true } = {}) { +async function deleteHeadlessSessionToken(token) { + if (!token || !tokens?.accessToken) return; + + // Method 1: POST /users/@me/headless-sessions with empty activities and the token + try { + await requestDiscord("/users/@me/headless-sessions", { + method: "POST", + body: JSON.stringify({ token, activities: [] }) + }); + } catch (_) {} + + // Method 2: POST /users/@me/headless-sessions/delete with token + try { + await requestDiscord("/users/@me/headless-sessions/delete", { + method: "POST", + body: JSON.stringify({ token }) + }); + } catch (_) {} + + // Method 3: DELETE /users/@me/headless-sessions with token body + try { + await requestDiscord("/users/@me/headless-sessions", { + method: "DELETE", + body: JSON.stringify({ token }) + }); + } catch (_) {} + + // Method 4: DELETE /users/@me/headless-sessions?token=... + try { + await requestDiscord(`/users/@me/headless-sessions?token=${encodeURIComponent(token)}`, { + method: "DELETE" + }); + } catch (_) {} +} + +async function performClearPresence() { bumpPresenceGeneration(); lastPresenceKey = ""; lastPresenceSentAt = 0; + if (!tokens?.accessToken) { headlessToken = ""; headlessTokens = []; @@ -518,37 +561,35 @@ async function clearPresence({ waitForWrites = true } = {}) { return; } - if (waitForWrites) { - await presenceWrite.catch(() => {}); + const tokensToDelete = Array.from(new Set([headlessToken, ...headlessTokens].filter(Boolean))); + + if (tokensToDelete.length > 0) { + await Promise.allSettled(tokensToDelete.map((t) => deleteHeadlessSessionToken(t))); + } else { + await requestDiscord("/users/@me/headless-sessions", { + method: "POST", + body: JSON.stringify({ activities: [] }) + }).catch(() => {}); } - const tokensToDelete = Array.from(new Set([headlessToken, ...headlessTokens].filter(Boolean))); headlessToken = ""; headlessTokens = []; saveSession(); +} - if (tokensToDelete.length) { - await Promise.allSettled( - tokensToDelete.map((token) => - requestDiscord("/users/@me/headless-sessions/delete", { - method: "POST", - body: JSON.stringify({ token }) - }) - ) - ); +async function clearPresence({ waitForWrites = true } = {}) { + if (waitForWrites) { + await enqueueClearPresence(); + } else { + await performClearPresence(); } - - await requestDiscord("/users/@me/headless-sessions", { - method: "POST", - body: JSON.stringify({ activities: [] }) - }).catch(() => {}); } async function clearActivePresence() { suppressCurrentMedia(); state = { ...state, lastVideo: null, lastError: "" }; saveState(); - await clearPresence(); + await enqueueClearPresence(); } async function logoutDiscord() { @@ -557,7 +598,7 @@ async function logoutDiscord() { headlessToken = ""; headlessTokens = []; lastPresenceKey = ""; - browserApi.storage.local.remove("discordTokens"); + browserApi.storage.local.remove(["discordTokens", "rpcSession"]); state = { ...DEFAULT_STATE, enabled: state.enabled }; saveState(); } @@ -598,7 +639,10 @@ function isGenericTitleString(str) { function handleVideoUpdate(video, senderTabId) { const source = normalizeSource(video.source || video.platform || "youtube"); - const previous = mediaBySource[source]; + const tabId = senderTabId !== undefined ? senderTabId : video.tabId; + const storageKey = tabId !== undefined ? `${tabId}:${source}` : source; + + const previous = mediaBySource[storageKey]; const pageTitle = state.lastMediaPage?.title; let resolvedTitle = video.title; @@ -606,24 +650,27 @@ function handleVideoUpdate(video, senderTabId) { resolvedTitle = pageTitle; } - const tabId = senderTabId !== undefined ? senderTabId : (video.tabId !== undefined ? video.tabId : previous?.tabId); - const isSameMedia = previous && ( (video.videoId && previous.videoId === video.videoId) || (video.mediaId && previous.mediaId === video.mediaId) || (video.url && previous.url === video.url) ); - const baseMedia = isSameMedia ? previous : {}; - const becamePlaying = previous ? previous.paused && !video.paused : !video.paused; + const mediaChanged = !isSameMedia; + const becamePlaying = mediaChanged ? !video.paused : (previous?.paused && !video.paused); + const lastStartedAt = (becamePlaying || mediaChanged || !previous?.lastStartedAt) + ? Date.now() + : previous.lastStartedAt; + const media = applyMediaOverrides({ - ...baseMedia, ...video, tabId, title: resolvedTitle, source, - lastStartedAt: video.lastStartedAt || (becamePlaying ? Date.now() : baseMedia?.lastStartedAt || video.updatedAt || Date.now()) + lastStartedAt, + updatedAt: Date.now() }); + const suppressed = suppressedBySource[source]; if ( !media.paused && @@ -636,12 +683,22 @@ function handleVideoUpdate(video, senderTabId) { suppressedBySource = nextSuppressed; saveSession(); } - mediaBySource = { ...mediaBySource, [source]: media }; + + if (media.paused || media.ended) { + delete mediaBySource[storageKey]; + } else { + mediaBySource[storageKey] = media; + } + const active = chooseActiveMedia(); state = { ...state, lastVideo: active }; saveState(); - if (!state.enabled || !active || active.source !== source || !isSourceEnabled(source)) return; + if (!state.enabled || !active || !isSourceEnabled(active.source)) { + syncActivePresence(true).catch(() => {}); + return; + } + enqueuePresenceUpdate(active).catch((error) => { state = { ...state, connected: false, lastError: error.message }; saveState(); @@ -944,16 +1001,16 @@ if (browserApi.runtime && browserApi.runtime.onSuspend) { }); } -// Background heartbeat to purge stale media, verify tabs, and maintain Discord presence consistency +// Background heartbeat to purge stale or paused media, verify tabs, and maintain Discord presence consistency setInterval(() => { const now = Date.now(); - const STALE_THRESHOLD_MS = 12000; + const STALE_THRESHOLD_MS = 8000; let changed = false; const nextMedia = { ...mediaBySource }; for (const [src, media] of Object.entries(nextMedia)) { const age = now - (media.updatedAt || 0); - if (age > STALE_THRESHOLD_MS) { + if (age > STALE_THRESHOLD_MS || media.paused) { delete nextMedia[src]; changed = true; continue; @@ -978,6 +1035,6 @@ setInterval(() => { } syncActivePresence(false).catch(() => {}); -}, 5000); +}, 3000);