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..b82d583 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,109 @@ # 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 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
+
-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. -### Copyright (C) 2026 Sheathed +## Key Features -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. +### 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`). -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. +### 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. -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 +### 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. diff --git a/assets/discord-rpc-1.png b/assets/discord-rpc-1.png new file mode 100644 index 0000000..4acdbba Binary files /dev/null and b/assets/discord-rpc-1.png differ diff --git a/assets/discord-rpc-2.png b/assets/discord-rpc-2.png new file mode 100644 index 0000000..8f612b5 Binary files /dev/null and b/assets/discord-rpc-2.png differ diff --git a/assets/discord-rpc-3.png b/assets/discord-rpc-3.png new file mode 100644 index 0000000..2bcfa95 Binary files /dev/null and b/assets/discord-rpc-3.png differ diff --git a/assets/popup-screenshot.png b/assets/popup-screenshot.png new file mode 100644 index 0000000..dd7d1b3 Binary files /dev/null and b/assets/popup-screenshot.png differ 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/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/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/background.js b/src/extension/background.js index b2f878d..5c3722e 100644 --- a/src/extension/background.js +++ b/src/extension/background.js @@ -10,10 +10,16 @@ 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: {}, + itemOverrides: {}, lastError: "", lastVideo: null, lastMediaPage: null, @@ -87,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", @@ -97,6 +104,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 +232,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 +252,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 +349,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) }); } @@ -290,14 +385,26 @@ function isSourceEnabled(source) { } function chooseActiveMedia() { - return Object.values(mediaBySource) - .filter((media) => state.enabled && isSourceEnabled(media.source) && !media.ended && !isSuppressed(media)) - .sort((a, b) => { - 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 now = Date.now(); + const ACTIVE_TIMEOUT_MS = 6000; + + 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) { @@ -349,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; } @@ -376,27 +490,70 @@ 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(() => {}); + await performClearPresence().catch(() => {}); return; } - rememberHeadlessToken(result.token || headlessToken); + rememberHeadlessToken(result?.token || headlessToken); lastPresenceKey = key; lastPresenceSentAt = now; state = { ...state, authenticated: true, connected: true, lastError: "" }; 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 = []; @@ -404,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() { @@ -443,21 +598,79 @@ async function logoutDiscord() { headlessToken = ""; headlessTokens = []; lastPresenceKey = ""; - browserApi.storage.local.remove("discordTokens"); + browserApi.storage.local.remove(["discordTokens", "rpcSession"]); state = { ...DEFAULT_STATE, enabled: state.enabled }; saveState(); } -function handleVideoUpdate(video) { +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 customTitle = itemOverride.title !== undefined ? itemOverride.title : (domainOverrides.title || ""); + const resolvedTitle = customTitle || video.title; + + return { + ...video, + 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) + }; +} + +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, senderTabId) { const source = normalizeSource(video.source || video.platform || "youtube"); - const previous = mediaBySource[source]; - const becamePlaying = previous ? previous.paused && !video.paused : !video.paused; - const media = { - ...previous, + 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; + 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 mediaChanged = !isSameMedia; + const becamePlaying = mediaChanged ? !video.paused : (previous?.paused && !video.paused); + const lastStartedAt = (becamePlaying || mediaChanged || !previous?.lastStartedAt) + ? Date.now() + : previous.lastStartedAt; + + const media = applyMediaOverrides({ ...video, + tabId, + title: resolvedTitle, source, - lastStartedAt: video.lastStartedAt || (becamePlaying ? Date.now() : previous?.lastStartedAt || video.updatedAt || Date.now()) - }; + lastStartedAt, + updatedAt: Date.now() + }); + const suppressed = suppressedBySource[source]; if ( !media.paused && @@ -470,29 +683,41 @@ function handleVideoUpdate(video) { 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(); }); } -browserApi.runtime.onMessage.addListener((message, _sender, sendResponse) => { +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, ...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(message.media); + handleVideoUpdate(message.media, senderTabId); sendResponse({ ok: true }); return true; } @@ -516,11 +741,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 = {}; @@ -552,6 +773,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 +890,56 @@ 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; + } + + 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"], (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 || ""; @@ -644,15 +948,93 @@ browserApi.runtime.onInstalled.addListener(() => { 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) => { + cleanupTabMedia(tabId); + }); +} + +if (browserApi.tabs && browserApi.tabs.onUpdated) { + browserApi.tabs.onUpdated.addListener((tabId, changeInfo) => { + 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 (browserApi.runtime && browserApi.runtime.onSuspend) { + browserApi.runtime.onSuspend.addListener(() => { + clearPresence({ waitForWrites: false }).catch(() => {}); + }); +} + +// Background heartbeat to purge stale or paused media, verify tabs, and maintain Discord presence consistency +setInterval(() => { + const now = Date.now(); + 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 || media.paused) { + 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(() => {}); +}, 3000); + -browserApi.storage.local.get(["rpcState", "discordTokens", "rpcSession"], (result) => { - state = { ...DEFAULT_STATE, ...(result.rpcState || {}) }; - 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(); -}); diff --git a/src/extension/content-generic.js b/src/extension/content-generic.js new file mode 100644 index 0000000..a391bd6 --- /dev/null +++ b/src/extension/content-generic.js @@ -0,0 +1,216 @@ +(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(/^\(\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 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 { + 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 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 = 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() { + 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-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 88244bf..0636bea 100644 --- a/src/extension/content-youtube.js +++ b/src/extension/content-youtube.js @@ -25,47 +25,112 @@ 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") ); } function getTitle() { - return ( + 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") || 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, "") + .replace(/\s+-\s+YouTube Music$/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 video = getMainVideoElement(); const videoId = getVideoId(); if (!video || !videoId) return null; 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 +189,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 +235,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 +244,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..24a2c1b 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,37 @@ 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) { + if (!state.lastVideo) { + 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}` : ""}` @@ -41,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) { @@ -60,6 +108,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 +133,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 +184,79 @@ 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", () => { + const newTitle = overrideTitle.value.trim(); + const newImage = overrideImage.value.trim(); + const newUrl = overrideUrl.value.trim(); + const newPlatform = overridePlatform.value.trim(); + + 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", () => { + 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) => { event.preventDefault(); const url = callbackUrl.value.trim(); @@ -125,5 +269,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); +