Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Socket / backend endpoint Synclify will fetch /create from and open a
# socket.io connection to. Without this, the extension falls back to
# http://localhost:3001 (see src/types/socket.ts) and "Create Room" fails
# with `Failed to fetch` in the background service worker.
#
# Set it to your own running synclify-server, e.g.:
# WXT_SOCKET_ENDPOINT=http://localhost:3001
# WXT_SOCKET_ENDPOINT=https://your-server.example.com
#
# The upstream production URL lives only in GitHub Actions secrets
# (PLASMO_PUBLIC_SOCKET_ENDPOINT in .github/workflows/submit.yml) and is
# not committed to this repo.
Comment on lines +10 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect submit workflow env/secret variable names.
fd -a 'submit\.yml$|submit\.yaml$' .github/workflows
for f in $(fd 'submit\.yml$|submit\.yaml$' .github/workflows); do
  echo "=== $f ==="
  sed -n '1,220p' "$f"
done

# Cross-check all references to socket endpoint env keys in repo.
rg -n "WXT_SOCKET_ENDPOINT|PLASMO_PUBLIC_SOCKET_ENDPOINT" . -S

Repository: synclify/Synclify

Length of output: 2319


Rename the injected socket env key

.github/workflows/submit.yml still writes PLASMO_PUBLIC_SOCKET_ENDPOINT, but the extension reads WXT_SOCKET_ENDPOINT. Update the workflow/secret name to WXT_SOCKET_ENDPOINT so production builds don’t fall back to localhost.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 10 - 12, The socket endpoint env key is mismatched
between the workflow and the extension, so production builds can fall back to
localhost. Update the GitHub Actions secret/injection in submit workflow to use
WXT_SOCKET_ENDPOINT instead of PLASMO_PUBLIC_SOCKET_ENDPOINT, and make sure the
extension-side lookup that reads WXT_SOCKET_ENDPOINT stays consistent with the
injected name.

WXT_SOCKET_ENDPOINT=

# Optional PostHog telemetry. Leave blank to disable.
WXT_PUBLIC_POSTHOG_KEY=
WXT_PUBLIC_POSTHOG_HOST=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ yarn-error.log*

# local env files
.env*
!.env.example

out/
build/
Expand Down
90 changes: 70 additions & 20 deletions src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,15 @@ export default defineBackground(async () => {
hostPatterns: [/mubi\.com$/],
videoSelector: "video",
playerContainer: ".player"
},
vkvideo: {
hostPatterns: [/(^|\.)vkvideo\.ru$/, /(^|\.)vk\.com$/],
videoSelector: '[data-testid="video-container"] video',
playerContainer: ".vk-vp-root",
watchPageTest: () =>
/^\/video_ext\.php/.test(location.pathname) ||
/^\/video-?\d+_\d+/.test(location.pathname),
excludeSelector: ".ads-container video"
}
}

Expand All @@ -322,6 +331,8 @@ export default defineBackground(async () => {
"#hudson-wrapper",
'[data-testid="playerContainer"]',
".ContentPlayer",
".vk-vp-root",
'[data-testid="video-container"]',
".html5-video-player",
".video-player",
".jw-wrapper",
Expand Down Expand Up @@ -374,29 +385,66 @@ export default defineBackground(async () => {
)
}

/* Find candidate videos */
/* Deep-walk that crosses open shadow roots. VK Video, and a growing
number of modern players, mount their <video> inside a closed-looking
shadow tree (mode: "open" but not queryable via document.*). */
const collectAllVideos = (): HTMLVideoElement[] => {
const found: HTMLVideoElement[] = []
const visit = (root: Document | ShadowRoot) => {
for (const v of Array.from(
root.querySelectorAll<HTMLVideoElement>("video")
)) {
found.push(v)
}
for (const el of Array.from(root.querySelectorAll<HTMLElement>("*"))) {
const sr = el.shadowRoot
if (sr) visit(sr)
}
}
visit(document)
return found
}

/* Find candidate videos. When the site-specific selector matches,
trustSelector=true and we skip the isPlayable filter — the player
<video> may not yet have src/dimensions (e.g. VK Video uses MSE
blob-URL only after the first Play). On the fallback path and on
unknown sites we still require isPlayable so generic detection
picks the right element. */
const allVideos = collectAllVideos()
let candidates: HTMLVideoElement[]
let trustSelector = false
if (siteConfig) {
// Use site-specific selector for more precise matching
candidates = Array.from(
document.querySelectorAll<HTMLVideoElement>(siteConfig.videoSelector)
)
// Filter out excluded elements (ads, overlays, etc.)
if (siteConfig.excludeSelector) {
const excludeSel = siteConfig.excludeSelector
candidates = candidates.filter((v) => !v.matches(excludeSel))
}
// If site-specific selector returned nothing, fall back to all videos
if (candidates.length === 0) {
candidates = Array.from(document.getElementsByTagName("video"))
const sel = siteConfig.videoSelector
const exclude = siteConfig.excludeSelector
candidates = allVideos.filter((v) => {
try {
if (!v.matches(sel)) return false
} catch {
return false
}
if (exclude) {
try {
if (v.matches(exclude)) return false
} catch {
/* ignore bad exclude selector */
}
}
return true
})
if (candidates.length > 0) {
trustSelector = true
} else {
// Fall back to all <video> (including shadow) if site selector matched nothing
candidates = allVideos
}
} else {
candidates = Array.from(document.getElementsByTagName("video"))
candidates = allVideos
}

return candidates
.map((video) => {
if (!isPlayable(video)) return null
if (!trustSelector && !isPlayable(video)) return null
Comment on lines +408 to +447

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't bypass isPlayable() for every site match.

Line 447 currently trusts any site-configured selector match. For configs like paramountplus, hotstar, and mubi where videoSelector is just video, this will emit every matched element, including hidden/pre-roll placeholders, and can flip the flow to MULTIPLE_VIDEOS or the wrong videoId. Scope this bypass to VK only, or better, drive it from the same per-site flag as src/lib/video-detection.ts.

Suggested direction
     let candidates: HTMLVideoElement[]
     let trustSelector = false
     if (siteConfig) {
       const sel = siteConfig.videoSelector
       const exclude = siteConfig.excludeSelector
@@
-      if (candidates.length > 0) {
-        trustSelector = true
+      if (candidates.length > 0) {
+        trustSelector = detectedSite === "vkvideo"
       } else {
         // Fall back to all <video> (including shadow) if site selector matched nothing
         candidates = allVideos
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/* Find candidate videos. When the site-specific selector matches,
trustSelector=true and we skip the isPlayable filter the player
<video> may not yet have src/dimensions (e.g. VK Video uses MSE
blob-URL only after the first Play). On the fallback path and on
unknown sites we still require isPlayable so generic detection
picks the right element. */
const allVideos = collectAllVideos()
let candidates: HTMLVideoElement[]
let trustSelector = false
if (siteConfig) {
// Use site-specific selector for more precise matching
candidates = Array.from(
document.querySelectorAll<HTMLVideoElement>(siteConfig.videoSelector)
)
// Filter out excluded elements (ads, overlays, etc.)
if (siteConfig.excludeSelector) {
const excludeSel = siteConfig.excludeSelector
candidates = candidates.filter((v) => !v.matches(excludeSel))
}
// If site-specific selector returned nothing, fall back to all videos
if (candidates.length === 0) {
candidates = Array.from(document.getElementsByTagName("video"))
const sel = siteConfig.videoSelector
const exclude = siteConfig.excludeSelector
candidates = allVideos.filter((v) => {
try {
if (!v.matches(sel)) return false
} catch {
return false
}
if (exclude) {
try {
if (v.matches(exclude)) return false
} catch {
/* ignore bad exclude selector */
}
}
return true
})
if (candidates.length > 0) {
trustSelector = true
} else {
// Fall back to all <video> (including shadow) if site selector matched nothing
candidates = allVideos
}
} else {
candidates = Array.from(document.getElementsByTagName("video"))
candidates = allVideos
}
return candidates
.map((video) => {
if (!isPlayable(video)) return null
if (!trustSelector && !isPlayable(video)) return null
/* Find candidate videos. When the site-specific selector matches,
trustSelector=true and we skip the isPlayable filter the player
<video> may not yet have src/dimensions (e.g. VK Video uses MSE
blob-URL only after the first Play). On the fallback path and on
unknown sites we still require isPlayable so generic detection
picks the right element. */
const allVideos = collectAllVideos()
let candidates: HTMLVideoElement[]
let trustSelector = false
if (siteConfig) {
const sel = siteConfig.videoSelector
const exclude = siteConfig.excludeSelector
candidates = allVideos.filter((v) => {
try {
if (!v.matches(sel)) return false
} catch {
return false
}
if (exclude) {
try {
if (v.matches(exclude)) return false
} catch {
/* ignore bad exclude selector */
}
}
return true
})
if (candidates.length > 0) {
trustSelector = detectedSite === "vkvideo"
} else {
// Fall back to all <video> (including shadow) if site selector matched nothing
candidates = allVideos
}
} else {
candidates = allVideos
}
return candidates
.map((video) => {
if (!trustSelector && !isPlayable(video)) return null
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/entrypoints/background.ts` around lines 408 - 447, The current
trustSelector bypass in the video candidate selection logic is too broad and
skips isPlayable for every site-configured match, which can surface placeholder
or hidden videos. Restrict this bypass in the background video detection flow
inside the candidate filtering around collectAllVideos()/isPlayable so it only
applies to the intended VK-style case, or wire it to the same per-site flag used
by src/lib/video-detection.ts; keep generic site configs like Paramount+,
Hotstar, and Mubi still requiring isPlayable before returning candidates.

if (!video.dataset.synclifyId)
video.dataset.synclifyId = Math.random().toString(36).slice(2, 7)

Expand Down Expand Up @@ -678,7 +726,8 @@ export default defineBackground(async () => {

if (!frameIds) {
let videos: Array<Video & { frameId: number }> = []
for (let attempt = 0; attempt < 3 && videos.length === 0; attempt++) {
const MAX_ATTEMPTS = 5
for (let attempt = 0; attempt < MAX_ATTEMPTS && videos.length === 0; attempt++) {
const result = await browser.scripting.executeScript({
func: detectPageVideos,
target: { tabId: tabId, allFrames: true }
Expand All @@ -698,8 +747,8 @@ export default defineBackground(async () => {
})
)

if (videos.length === 0 && attempt < 2) {
await wait(700)
if (videos.length === 0 && attempt < MAX_ATTEMPTS - 1) {
await wait(800)
}
}

Expand Down Expand Up @@ -850,7 +899,8 @@ export default defineBackground(async () => {
frameId: number
needsCustomPlayer: boolean
}> = []
for (let attempt = 0; attempt < 3 && videos.length === 0; attempt++) {
const MAX_ATTEMPTS = 5
for (let attempt = 0; attempt < MAX_ATTEMPTS && videos.length === 0; attempt++) {
const result = await browser.scripting.executeScript({
func: detectPageVideos,
target: { tabId, allFrames: true }
Expand All @@ -871,7 +921,7 @@ export default defineBackground(async () => {
frameId: injection.frameId
}))
)
if (videos.length === 0 && attempt < 2) await wait(700)
if (videos.length === 0 && attempt < MAX_ATTEMPTS - 1) await wait(800)
}
if (videos.length === 0) return

Expand Down
18 changes: 11 additions & 7 deletions src/entrypoints/injected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
} from "~/types/socket"
import type { State, TabState } from "~/types/state"
import { VIDEO_EVENTS } from "~/types/video"
import { findSiteVideo, detectStreamingSite } from "~/lib/video-detection"
import {
findSiteVideo,
detectStreamingSite,
deepQuerySelector
} from "~/lib/video-detection"
import { debugRoomLog } from "~/lib/debug"
import browser from "webextension-polyfill"
import { io } from "socket.io-client"
Expand Down Expand Up @@ -309,11 +313,11 @@ export default defineUnlistedScript(async () => {
})

const getVideo = (videoId?: string) => {
// First try by synclify-id if provided
// First try by synclify-id if provided (deep — crosses shadow boundaries)
if (videoId) {
video = document.querySelector(
video = deepQuerySelector<HTMLVideoElement>(
`[data-synclify-id="${videoId}"]`
) as HTMLVideoElement | null
)
}

// If no videoId or element not found, use site-specific detection
Expand All @@ -330,12 +334,12 @@ export default defineUnlistedScript(async () => {
}
}

// Final fallback: first video on the page
// Final fallback: first video on the page (deep — shadow-aware)
if (!video) {
video = document.querySelector("video")
video = deepQuerySelector<HTMLVideoElement>("video")
posthog.capture("video_id_null_fallback", {
message:
"videoId is null, using first element returned by document.querySelector"
"videoId is null, using first element returned by deepQuerySelector"
})
}

Expand Down
23 changes: 22 additions & 1 deletion src/entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,23 @@ function App() {
} else {
browser.runtime
.sendMessage({ action: "createRoom" })
.then((roomCode: string) => roomCallback(roomCode))
.then((roomCode: string) => {
if (typeof roomCode !== "string" || roomCode.length === 0) {
setError(true)
setErrorMessage(t("videoNotDetectedYet"))
return
}
roomCallback(roomCode)
Comment on lines +247 to +253

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Normalize roomCode before validation to avoid accepting whitespace payloads.

res.text() payloads can contain trailing/newline whitespace; current logic accepts those as valid codes and can join with an invalid room ID.

Suggested fix
-          .then((roomCode: string) => {
-            if (typeof roomCode !== "string" || roomCode.length === 0) {
+          .then((roomCode: string) => {
+            const normalizedRoomCode =
+              typeof roomCode === "string" ? roomCode.trim() : ""
+            if (normalizedRoomCode.length === 0) {
               setError(true)
               setErrorMessage(t("videoNotDetectedYet"))
               return
             }
-            roomCallback(roomCode)
+            roomCallback(normalizedRoomCode)
           })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.then((roomCode: string) => {
if (typeof roomCode !== "string" || roomCode.length === 0) {
setError(true)
setErrorMessage(t("videoNotDetectedYet"))
return
}
roomCallback(roomCode)
.then((roomCode: string) => {
const normalizedRoomCode =
typeof roomCode === "string" ? roomCode.trim() : ""
if (normalizedRoomCode.length === 0) {
setError(true)
setErrorMessage(t("videoNotDetectedYet"))
return
}
roomCallback(normalizedRoomCode)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/entrypoints/popup/App.tsx` around lines 247 - 253, The room join flow in
App.tsx is validating the raw `roomCode` from the `.then((roomCode: string) =>
...)` handler, so whitespace-only or newline-padded payloads can slip through as
valid IDs. Normalize the value first by trimming before the empty-string check,
then use the trimmed result for the `typeof`/length validation and for the
`roomCallback` call so only clean room codes are accepted.

})
.catch((err) => {
console.error("createRoom failed", err)
setError(true)
setErrorMessage(
err instanceof Error && err.message
? err.message
: t("videoNotDetectedYet")
)
})
}
},
[roomCallback]
Expand Down Expand Up @@ -643,6 +659,11 @@ function App() {
className="relative w-full overflow-hidden rounded-lg bg-[hsl(38_92%_55%)] py-5 text-sm font-semibold tracking-wide text-[hsl(220_20%_6%)] shadow-lg shadow-[hsl(38_92%_55%/0.2)] transition-all hover:bg-[hsl(38_80%_50%)] hover:shadow-[hsl(38_92%_55%/0.3)]">
{t("createRoom")}
</Button>
{error && (
<div className="mt-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-center text-xs text-destructive">
{errorMessage}
</div>
)}
</div>

{/* Divider */}
Expand Down
96 changes: 83 additions & 13 deletions src/lib/video-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type StreamingSite =
| "stan"
| "britbox"
| "shudder"
| "vkvideo"
| "unknown"

/* ------------------------------------------------------------------
Expand Down Expand Up @@ -181,6 +182,23 @@ export const SITE_CONFIGS: Record<
hostPatterns: [/shudder\.com$/],
videoSelector: "video",
playerContainer: ".player-container"
},

/* ---- VK Video (vkvideo.ru + vk.com, including /video_ext.php embeds) ----
The VK player lives inside an open Shadow DOM rooted at
div.shadow-root-container. Inside, the main <video> sits in
.vk-vp-root > .player-wrapper > ... > [data-testid="video-container"],
and an ad <video> sits in .ads-container. The detection code uses a
deep walk that crosses shadow boundaries — selectors below are
matched against video elements regardless of shadow root. */
vkvideo: {
hostPatterns: [/(^|\.)vkvideo\.ru$/, /(^|\.)vk\.com$/],
videoSelector: '[data-testid="video-container"] video',
playerContainer: ".vk-vp-root",
watchPageTest: () =>
/^\/video_ext\.php/.test(location.pathname) ||
/^\/video-?\d+_\d+/.test(location.pathname),
excludeSelector: ".ads-container video"
}
}

Expand Down Expand Up @@ -208,6 +226,49 @@ export function getSiteConfig(hostname?: string): SiteConfig | null {
return SITE_CONFIGS[site]
}

/* ------------------------------------------------------------------
* Shadow-DOM aware traversal helpers
*
* Modern players (VK Video, Bitmovin, etc.) put their <video> inside
* open shadow roots. document.querySelector* does not cross shadow
* boundaries — these helpers do.
* ------------------------------------------------------------------ */

export function collectAllVideos(): HTMLVideoElement[] {
const found: HTMLVideoElement[] = []
const visit = (root: Document | ShadowRoot) => {
for (const v of Array.from(
root.querySelectorAll<HTMLVideoElement>("video")
)) {
found.push(v)
}
for (const el of Array.from(root.querySelectorAll<HTMLElement>("*"))) {
const sr = el.shadowRoot
if (sr) visit(sr)
}
}
visit(document)
return found
}

export function deepQuerySelector<E extends Element = Element>(
selector: string
): E | null {
const visit = (root: Document | ShadowRoot): E | null => {
const direct = root.querySelector<E>(selector)
if (direct) return direct
for (const el of Array.from(root.querySelectorAll<HTMLElement>("*"))) {
const sr = el.shadowRoot
if (sr) {
const nested = visit(sr)
if (nested) return nested
}
}
return null
}
return visit(document)
}

/* ------------------------------------------------------------------
* Find the primary video element using site-specific knowledge
*
Expand All @@ -224,22 +285,31 @@ export function findSiteVideo(hostname?: string): HTMLVideoElement | null {
return null
}

const candidates = Array.from(
document.querySelectorAll<HTMLVideoElement>(config.videoSelector)
)

// Filter out excluded elements
const filtered = config.excludeSelector
? candidates.filter((v) => !v.matches(config.excludeSelector!))
: candidates
const sel = config.videoSelector
const exclude = config.excludeSelector
const candidates = collectAllVideos().filter((v) => {
try {
if (!v.matches(sel)) return false
} catch {
return false
}
if (exclude) {
try {
if (v.matches(exclude)) return false
} catch {
/* ignore bad exclude selector */
}
}
return true
})

// Return the first video that has actual content
for (const video of filtered) {
for (const video of candidates) {
if (isPlayableVideo(video)) return video
}

// Fallback: the selector might not match yet (lazy load); return first
return filtered[0] ?? null
return candidates[0] ?? null
}

// Unknown site — use generic heuristic
Expand All @@ -253,9 +323,7 @@ export function findSiteVideo(hostname?: string): HTMLVideoElement | null {
* ------------------------------------------------------------------ */

function findGenericVideo(): HTMLVideoElement | null {
const videos = Array.from(
document.querySelectorAll<HTMLVideoElement>("video")
)
const videos = collectAllVideos()
const scored = videos
.filter((v) => isPlayableVideo(v))
.map((v) => ({
Expand Down Expand Up @@ -298,6 +366,8 @@ export const COMMERCIAL_PLAYER_SELECTORS = [
"#hudson-wrapper", // Disney+, Peacock, Crunchyroll, Apple TV+
'[data-testid="playerContainer"]', // Max / HBO Max
".ContentPlayer", // Hulu
".vk-vp-root", // VK Video (shadow DOM player root)
'[data-testid="video-container"]', // VK Video (player video container)

// --- Generic commercial player wrappers ---
".html5-video-player",
Expand Down
Loading