Skip to content

Add VK Video support with shadow-DOM aware video detection#81

Open
ZiraiMode wants to merge 1 commit into
synclify:masterfrom
ZiraiMode:master
Open

Add VK Video support with shadow-DOM aware video detection#81
ZiraiMode wants to merge 1 commit into
synclify:masterfrom
ZiraiMode:master

Conversation

@ZiraiMode

@ZiraiMode ZiraiMode commented May 11, 2026

Copy link
Copy Markdown

Summary

  • Add a vkvideo entry to SITE_CONFIGS (in both src/lib/video-detection.ts and the inline copy serialised into pages from src/entrypoints/background.ts), targeting the VK player rooted in an open Shadow DOM at div.shadow-root-container.vk-vp-root[data-testid="video-container"] video, with .ads-container video as exclude.
  • Introduce shadow-DOM-aware traversal helpers collectAllVideos / deepQuerySelector; route findSiteVideo, findGenericVideo, the inline detectPageVideos, plus [data-synclify-id] lookups in injected.ts and videoPlayer.tsx through them. document.querySelector* does not cross shadow boundaries, so the previous code never saw the VK player.
  • Skip isPlayable filter when the site-specific selector matches — VK uses MSE blob URLs that only appear after the first Play, so the <video> would otherwise be discarded for missing src/videoWidth.
  • Bump inject retry budget in handleInject / reinjectTab to 5 × 800 ms; the VK SPA player loads noticeably slower than YouTube/Netflix chunks.
  • Popup UX: createOrJoinRoom was swallowing rejected sendMessage promises, so a missing socket server produced no feedback. Add .catch and an error pill under the Create Room button.
  • Add .env.example documenting WXT_SOCKET_ENDPOINT; unignore it in .gitignore (the .env* glob was hiding it).

Out of scope (left for a follow-up): collapsing the duplicated SITE_CONFIGS between video-detection.ts and the inline detectPageVideos. The inline copy can't share imports because it is serialised and run in the page via chrome.scripting.executeScript({ func }).

Test plan

  • Open https://vkvideo.ru/video-387766_456270520, click the Synclify icon → Create Room. Expect "Video detected" toast and "connected and syncing" in the popup.
  • Join the same room from a second browser. Verify play / pause / seek / volume sync. Test both before and after the first Play (the relaxed isPlayable path).
  • Navigate between videos via SPA (no full reload) — autoInject re-syncs the new player.
  • https://vkvideo.ru/ (no /video-X_Y path) — Synclify must not activate (watchPageTest returns false).
  • Embedded <iframe src="https://vk.com/video_ext.php?oid=...&id=..."> on a third-party page — detection works inside the frame (executeScript already runs with allFrames: true).
  • Regressions: YouTube /watch and Netflix /watch/... still detect, and Synclify still does not activate on YouTube/Netflix home pages.

Summary by CodeRabbit

  • New Features

    • Added support for VK Video detection and player handling.
    • Improved video detection to find videos inside shadow DOM, making playback controls work on more sites.
  • Bug Fixes

    • Made room creation fail gracefully when a video isn’t detected yet.
    • Increased retry attempts and wait time when locating videos before injection.
    • Ensured the example environment file is included for setup guidance.
  • Documentation

    • Expanded environment setup notes with required backend settings and optional telemetry placeholders.

VK Video (vkvideo.ru, vk.com, /video_ext.php embeds) mounts its <video>
inside an open shadow root rooted at div.shadow-root-container, so
detectPageVideos and the injected sync code never found it via
document.querySelector*.

- Add the vkvideo entry to SITE_CONFIGS in both video-detection.ts and
  the inline copy in background.ts (the latter is serialised into the
  page via executeScript and can't share imports — collapsing the
  duplication is left to a follow-up PR).
- Introduce collectAllVideos / deepQuerySelector helpers that recurse
  through open shadow roots, and route findSiteVideo, findGenericVideo,
  detectPageVideos, the injected.ts data-synclify-id lookup, and the
  videoPlayer.tsx attach lookup through them.
- Skip the isPlayable filter when the site-specific selector matches —
  VK uses MSE blob URLs that only appear after the first Play, so the
  <video> would otherwise be discarded for missing src/dimensions.
- Bump the inject retry budget to 5 x 800 ms; the VK SPA player loads
  noticeably slower than YouTube/Netflix chunks.
- Surface backend errors in the popup: createOrJoinRoom was silently
  swallowing rejected sendMessage promises, so a missing socket server
  produced no UI feedback. Add a .catch and an error pill under the
  Create Room button.
- Add .env.example documenting WXT_SOCKET_ENDPOINT and unignore it in
  .gitignore (the .env* glob was hiding it).
@andreademasi andreademasi self-requested a review June 25, 2026 09:02

@andreademasi andreademasi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the PR! 🙏🏼

I found one issue I’d like fixed before merge: the new trustSelector path in background.ts skips the isPlayable() check for any known site once the site-specific selector matches. That looks broader than intended. For VK it makes sense because the video may not have src/dimensions before first play, but for existing sites like YouTube/Netflix/Max this can now select unloaded or placeholder videos that previously would have been ignored/retried.

Could you scope that relaxed behavior to VK only, instead of applying it to all site-specific selectors?

A couple of smaller follow-ups worth considering:

  • handleCreateRoom() still returns the response body even when fetch() gets a non-OK status, so the popup .catch won’t catch backend HTTP errors unless the request rejects. It should probably throw on !res.ok.
  • The injected fallback lookup is now shadow-aware, but the MutationObserver still only observes document. If VK creates the shadow host first and inserts the video later inside the shadow root, the retry may miss it.
  • shouldShowCustomPlayer() still uses video.closest(), which won’t cross shadow boundaries. Now that generic detection can find shadow-DOM videos, unknown commercial players inside shadow roots may be misclassified.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/video-detection.ts (1)

49-55: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the unplayable fallback opt-in.

Line 312 now returns the first matched <video> for every known site, not just VK. That widens selectors like video (paramountplus, hotstar, mubi) into arbitrary preload/ad elements when nothing is playable yet. Please add an explicit allowUnplayableMatch flag and enable it only for vkvideo; the inline detector in src/entrypoints/background.ts should use the same contract.

Suggested direction
 export type SiteConfig = {
   hostPatterns: RegExp[]
   videoSelector: string
   playerContainer: string
   watchPageTest?: () => boolean
   excludeSelector?: string
+  allowUnplayableMatch?: boolean
 }

   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"
+    excludeSelector: ".ads-container video",
+    allowUnplayableMatch: true
   }

     for (const video of candidates) {
       if (isPlayableVideo(video)) return video
     }

-    return candidates[0] ?? null
+    return config.allowUnplayableMatch ? (candidates[0] ?? null) : null

Also applies to: 288-312

🤖 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/lib/video-detection.ts` around lines 49 - 55, The fallback that returns
the first matched <video> is currently applied too broadly in the site detection
logic, which can match non-playable preload or ad elements for sites like
paramountplus, hotstar, and mubi. Add an explicit allowUnplayableMatch flag to
SiteConfig and update the matching flow in the video detection code so only
vkvideo enables this fallback; keep the other site configs on the playable-only
path. Also update the inline detector in background.ts to use the same contract
and gate the fallback through the same flag in the relevant matching function.
🤖 Prompt for all review comments with 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.

Inline comments:
In @.env.example:
- Around line 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.

In `@src/entrypoints/background.ts`:
- Around line 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.

In `@src/entrypoints/popup/App.tsx`:
- Around line 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.

---

Outside diff comments:
In `@src/lib/video-detection.ts`:
- Around line 49-55: The fallback that returns the first matched <video> is
currently applied too broadly in the site detection logic, which can match
non-playable preload or ad elements for sites like paramountplus, hotstar, and
mubi. Add an explicit allowUnplayableMatch flag to SiteConfig and update the
matching flow in the video detection code so only vkvideo enables this fallback;
keep the other site configs on the playable-only path. Also update the inline
detector in background.ts to use the same contract and gate the fallback through
the same flag in the relevant matching function.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f963666a-5b7a-49e3-8913-6bae382e3a64

📥 Commits

Reviewing files that changed from the base of the PR and between a2bc3ae and 0b50c3d.

📒 Files selected for processing (7)
  • .env.example
  • .gitignore
  • src/entrypoints/background.ts
  • src/entrypoints/injected.ts
  • src/entrypoints/popup/App.tsx
  • src/lib/video-detection.ts
  • src/runtime/videoPlayer.tsx

Comment thread .env.example
Comment on lines +10 to +12
# 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.

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.

Comment on lines +408 to +447
/* 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

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.

Comment on lines +247 to +253
.then((roomCode: string) => {
if (typeof roomCode !== "string" || roomCode.length === 0) {
setError(true)
setErrorMessage(t("videoNotDetectedYet"))
return
}
roomCallback(roomCode)

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.

@synclify synclify deleted a comment from coderabbitai Bot Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants