diff --git a/.github/workflows/patched-libraries.yml b/.github/workflows/patched-libraries.yml index b68ecbd24..b9bac5476 100644 --- a/.github/workflows/patched-libraries.yml +++ b/.github/workflows/patched-libraries.yml @@ -23,6 +23,16 @@ name: Patched libraries # pull request closes the library's older issue and pull request, its issue only the older # issue (an older pull request is still a step forward while the newer version needs work). +# +# Two jobs, so that no code from a new library release runs where the write token is: "recut" +# installs the release and re-cuts the patch, with a read-only token, and hands each result on +# as an artifact - the change as a git patch, or the report of why it stopped. pnpm runs the +# install scripts of the packages allowBuilds lists only, but a release can depend on one of +# those in a new version. "publish" runs on a runner of its own with the write token, installs +# nothing, checks what it was handed, and pushes, opens and closes with git, gh and jq alone +# (SEC-3 of the review of 2026-11-13: one job did both, and an install script could read the +# token from its step's environment, or plant something in the job's later steps through +# $GITHUB_ENV). recut keeps no cache, which would carry what it installed to main's workflows. on: schedule: # Daily, 06:30 UTC. @@ -36,22 +46,27 @@ on: - package.json workflow_dispatch: -permissions: - contents: write # push the patched/- branch - pull-requests: write # open the update pull request - issues: write # open the issue when the re-cut stops - actions: write # start CI and the dependency review on the branch + +permissions: {} concurrency: group: patched-libraries cancel-in-progress: false jobs: - check: - name: Check patched libraries for updates + recut: + name: Re-cut the patches onto new releases + # A push only closes (publish); re-cutting is the daily run's job. + if: github.event_name != 'push' runs-on: ubuntu-latest # A dash.js re-cut lints a 3 MB bundle four times; the rest takes seconds. timeout-minutes: 30 + permissions: + contents: read # the repository + issues: read # the titles raised before; the issues endpoint lists pull requests + pull-requests: read # too, but only to a token that may read them + outputs: + count: ${{ steps.recut.outputs.count }} steps: # The token is not kept in .git/config (persist-credentials: false): every later # step could read it there, dev dependencies' install scripts included. @@ -61,16 +76,131 @@ jobs: - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + # No pnpm cache: its post step would save the store a new release was installed into, + # under main's key, for CI and every other workflow on main to restore. - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version-file: .nvmrc check-latest: true - cache: pnpm # tools/recut-patch.mjs lints with the project's ESLint, and --apply installs. - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Re-cut each patch whose library has a new release + id: recut + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PREFIX: 'Patched library update: ' + run: | + set -euo pipefail + node tools/check-patched-updates.mjs --json > "$RUNNER_TEMP/libraries.jsonl" + node tools/check-patched-updates.mjs + + # --- Every title raised before, as an issue or a pull request, open or closed: the issues + # endpoint lists pull requests too, and pages through all of them. Not the search index, + # which can lag behind one made minutes ago. + # Only this workflow's own: anyone's pull request with such a title blocks nothing. + gh api --paginate "repos/$GITHUB_REPOSITORY/issues?state=all&per_page=100" \ + --jq '.[] | select(.user.login == "github-actions[bot]") | .title' > "$RUNNER_TEMP/titles.txt" + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + start=$(git rev-parse HEAD) + # One folder per library to publish, numbered in order: library.json, report.txt, and + # outcome - "cut" with change.patch, or "stopped". + recuts="$RUNNER_TEMP/recuts" + mkdir -p "$recuts" + count=0 + + # Not a pipe into the loop, and not on stdin either: a failed command in a piped loop + # only ends the subshell, and pnpm or gh inside it could read the list off stdin. + jq -c 'select(.behind)' "$RUNNER_TEMP/libraries.jsonl" > "$RUNNER_TEMP/behind.jsonl" + while read -r library <&3; do + name=$(jq -r .name <<< "$library") + current=$(jq -r .current <<< "$library") + latest=$(jq -r .latest <<< "$library") + title="$PREFIX$name $latest" + if grep -qxF "$title" "$RUNNER_TEMP/titles.txt"; then + echo "Already raised: $title." + continue + fi + + git checkout -q -f -B recut "$start" + status=0 + node tools/recut-patch.mjs "$name" "$latest" --apply --out "$RUNNER_TEMP/recut-$name" \ + > "$RUNNER_TEMP/report.txt" 2>&1 || status=$? + cat "$RUNNER_TEMP/report.txt" + + if [ "$status" = 3 ]; then + echo "$name $latest is too new to install yet; the next run tries again." + else + count=$((count + 1)) + dir="$recuts/$count" + mkdir "$dir" + printf '%s\n' "$library" > "$dir/library.json" + cp "$RUNNER_TEMP/report.txt" "$dir/report.txt" + if [ "$status" = 0 ]; then + # .github/dependabot.yml: a patch dropped hands the library (and what moves + # with it) back to Dependabot (tools/recut-patch.mjs). + git add -A -- package.json pnpm-lock.yaml pnpm-workspace.yaml patches tools/sync-vendor.mjs .github/dependabot.yml + # Anything else changed would be a surprise worth stopping for, not committing. + stray=$(git status --porcelain | grep -v '^[MADR] ' || true) + if [ -n "$stray" ]; then + echo "Unexpected changes after the re-cut:"; echo "$stray"; exit 1 + fi + git commit -q -m "deps: $name $current -> $latest, patch re-cut by tools/recut-patch.mjs" + git format-patch -1 --stdout > "$dir/change.patch" + echo cut > "$dir/outcome" + else + echo stopped > "$dir/outcome" + fi + fi + git checkout -q -f "$start" + git clean -q -fd + done 3< "$RUNNER_TEMP/behind.jsonl" + echo "count=$count" >> "$GITHUB_OUTPUT" + + - name: Hand the re-cuts on + if: steps.recut.outputs.count != '0' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: recuts + path: ${{ runner.temp }}/recuts + retention-days: 1 + if-no-files-found: error + + publish: + name: Check patched libraries for updates + needs: recut + # On a push recut is skipped, and this only closes; a failed re-cut publishes nothing. + if: ${{ !cancelled() && needs.recut.result != 'failure' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write # push the patched/- branch + pull-requests: write # open the update pull request + issues: write # open the issue when the re-cut stops + actions: write # start CI and the dependency review on the branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # For tools/check-patched-updates.mjs, which needs nothing but Node itself: nothing is + # installed in this job. + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + check-latest: true + + - name: Take the re-cuts + if: needs.recut.outputs.count != '' && needs.recut.outputs.count != '0' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: recuts + path: ${{ runner.temp }}/recuts + - name: Sync update pull requests and issues with the patched versions env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -146,50 +276,43 @@ jobs: exit 0 fi - # --- Every title raised before, as an issue or a pull request, open or closed: the issues - # endpoint lists pull requests too, and pages through all of them. Not the search index, - # which can lag behind one made minutes ago. - # Only this workflow's own: anyone's pull request with such a title blocks nothing. - gh api --paginate "repos/$GITHUB_REPOSITORY/issues?state=all&per_page=100" \ - --jq '.[] | select(.user.login == "github-actions[bot]") | .title' > "$RUNNER_TEMP/titles.txt" - git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' start=$(git rev-parse HEAD) - # Not a pipe into the loop, and not on stdin either: a failed command in a piped loop - # only ends the subshell, and pnpm or gh inside it could read the list off stdin. - jq -c 'select(.behind)' "$RUNNER_TEMP/libraries.jsonl" > "$RUNNER_TEMP/behind.jsonl" - while read -r library <&3; do + # --- Publish what the re-cut job made, in its order. Its contents come from a job + # that ran a new release's code, so they are checked here: a name and a version as + # npm has them, and a change to the files a re-cut may change, nothing else. + find "$RUNNER_TEMP/recuts" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V > "$RUNNER_TEMP/recut-dirs.txt" || true + while read -r dir <&3; do + library=$(cat "$dir/library.json") name=$(jq -r .name <<< "$library") current=$(jq -r .current <<< "$library") latest=$(jq -r .latest <<< "$library") url=$(jq -r .url <<< "$library") - title="$PREFIX$name $latest" - if grep -qxF "$title" "$RUNNER_TEMP/titles.txt"; then - echo "Already raised: $title." - continue + if ! [[ $name =~ ^(@[a-z0-9._-]+/)?[a-zA-Z0-9._-]+$ && $latest =~ ^[0-9A-Za-z.+-]+$ && $current =~ ^[0-9A-Za-z.+-]+$ ]]; then + echo "Not a library and version: '$name' '$current' '$latest'."; exit 1 fi + # The link in the pull request or issue: a release on npm or GitHub, nothing else. + if ! [[ $url =~ ^https://(www\.npmjs\.com|github\.com)/[A-Za-z0-9._~%@+/-]+$ ]]; then + echo "Not a release page on npm or GitHub: '$url'."; exit 1 + fi + title="$PREFIX$name $latest" + cp "$dir/report.txt" "$RUNNER_TEMP/report.txt" - branch="patched/$name-$latest" - git checkout -q -f -B "$branch" "$start" - status=0 - node tools/recut-patch.mjs "$name" "$latest" --apply --out "$RUNNER_TEMP/recut-$name" \ - > "$RUNNER_TEMP/report.txt" 2>&1 || status=$? - cat "$RUNNER_TEMP/report.txt" - - if [ "$status" = 3 ]; then - echo "$name $latest is too new to install yet; the next run tries again." - elif [ "$status" = 0 ]; then - # .github/dependabot.yml: a patch dropped hands the library (and what moves - # with it) back to Dependabot (tools/recut-patch.mjs). - git add -A -- package.json pnpm-lock.yaml pnpm-workspace.yaml patches tools/sync-vendor.mjs .github/dependabot.yml - # Anything else changed would be a surprise worth stopping for, not committing. - stray=$(git status --porcelain | grep -v '^[MADR] ' || true) + if [ "$(cat "$dir/outcome")" = cut ]; then + branch="patched/$name-$latest" + git checkout -q -f -B "$branch" "$start" + git am -q "$dir/change.patch" + # --no-renames: a rename is listed as its new name only, and would carry a file + # off the list away unseen. Each file stays a plain one (a deleted one: 000000): + # no link, no submodule, no executable. + stray=$(git diff --no-renames --name-only "$start" HEAD | + grep -vxE 'package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|patches/[^/]+\.patch|tools/sync-vendor\.mjs|\.github/dependabot\.yml' || true) + stray+=$(git diff --no-renames --raw "$start" HEAD | awk '$2 != "100644" && $2 != "000000"') if [ -n "$stray" ]; then - echo "Unexpected changes after the re-cut:"; echo "$stray"; exit 1 + echo "The re-cut of $name $latest changed more than a re-cut may:"; echo "$stray"; exit 1 fi - git commit -q -m "deps: $name $current -> $latest, patch re-cut by tools/recut-patch.mjs" authgit push -q -f origin "$branch" # The backticks are Markdown for the body, not command substitution. # shellcheck disable=SC2016 @@ -223,10 +346,18 @@ jobs: gh issue create --title "$title" --body-file "$RUNNER_TEMP/body.md" fi git checkout -q -f "$start" - git clean -q -fd - done 3< "$RUNNER_TEMP/behind.jsonl" + done 3< "$RUNNER_TEMP/recut-dirs.txt" supersede + report: + name: Report a failure + needs: [recut, publish] + if: failure() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write # the failure issue + steps: # A failed run of this workflow is emailed only to whoever started it (for the schedule, # whoever last changed the cron line): one issue while it is open, as # toolchain-updates.yml does. A failure while it is open adds a diff --git a/chrome/_locales/de/messages.json b/chrome/_locales/de/messages.json index ea99bfd2e..c0ada10ed 100644 --- a/chrome/_locales/de/messages.json +++ b/chrome/_locales/de/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "OLED-Schwarz" }, - "options_general_vischangeaction_pip": { - "message": "Bild-in-Bild umschalten" - }, "options_export_header": { "message": "Import/Export der Einstellungen" }, diff --git a/chrome/_locales/en/messages.json b/chrome/_locales/en/messages.json index 5cc4b81cd..b793e4b13 100644 --- a/chrome/_locales/en/messages.json +++ b/chrome/_locales/en/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "OLED Black" }, - "options_general_vischangeaction_pip": { - "message": "Toggle picture-in-picture" - }, "options_export_header": { "message": "Import/Export Settings" }, diff --git a/chrome/_locales/es/messages.json b/chrome/_locales/es/messages.json index 01eb41073..7853bbaec 100644 --- a/chrome/_locales/es/messages.json +++ b/chrome/_locales/es/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "Negro OLED" }, - "options_general_vischangeaction_pip": { - "message": "Alternar imagen en imagen" - }, "options_export_header": { "message": "Importar/Exportar Configuraciones" }, diff --git a/chrome/_locales/fr/messages.json b/chrome/_locales/fr/messages.json index 054f7846c..d442bdd72 100644 --- a/chrome/_locales/fr/messages.json +++ b/chrome/_locales/fr/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "Noir OLED" }, - "options_general_vischangeaction_pip": { - "message": "Basculer l'image dans l'image" - }, "options_export_header": { "message": "Importer/Exporter les paramètres" }, diff --git a/chrome/_locales/id/messages.json b/chrome/_locales/id/messages.json index b98a63591..163e60da6 100644 --- a/chrome/_locales/id/messages.json +++ b/chrome/_locales/id/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "Hitam OLED" }, - "options_general_vischangeaction_pip": { - "message": "Beralih gambar-dalam-gambar" - }, "options_export_header": { "message": "Impor/Ekspor Pengaturan" }, diff --git a/chrome/_locales/it/messages.json b/chrome/_locales/it/messages.json index 42e65109e..111b937d1 100644 --- a/chrome/_locales/it/messages.json +++ b/chrome/_locales/it/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "Nero OLED" }, - "options_general_vischangeaction_pip": { - "message": "Attiva/disattiva il picture-in-picture" - }, "options_export_header": { "message": "Importa/Esporta le Impostazioni" }, diff --git a/chrome/_locales/ja/messages.json b/chrome/_locales/ja/messages.json index 672cff0e8..5bc3186b0 100644 --- a/chrome/_locales/ja/messages.json +++ b/chrome/_locales/ja/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "OLEDブラック" }, - "options_general_vischangeaction_pip": { - "message": "ピクチャー イン ピクチャー (PiP) を切り替え" - }, "options_export_header": { "message": "設定のインポート/エクスポート" }, diff --git a/chrome/_locales/ko/messages.json b/chrome/_locales/ko/messages.json index 5ded207f3..4092f3024 100644 --- a/chrome/_locales/ko/messages.json +++ b/chrome/_locales/ko/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "OLED 블랙" }, - "options_general_vischangeaction_pip": { - "message": "화면 속 화면 전환" - }, "options_export_header": { "message": "설정 가져오기/내보내기" }, diff --git a/chrome/_locales/ms/messages.json b/chrome/_locales/ms/messages.json index bd2284f32..b4ecc71a8 100644 --- a/chrome/_locales/ms/messages.json +++ b/chrome/_locales/ms/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "Hitam OLED" }, - "options_general_vischangeaction_pip": { - "message": "Togol gambar dalam gambar" - }, "options_export_header": { "message": "Import/Eksport Tetapan" }, diff --git a/chrome/_locales/nl/messages.json b/chrome/_locales/nl/messages.json index 60293f1f3..2ac9417fb 100644 --- a/chrome/_locales/nl/messages.json +++ b/chrome/_locales/nl/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "OLED-zwart" }, - "options_general_vischangeaction_pip": { - "message": "Beeld-in-beeld in-/uitschakelen" - }, "options_export_header": { "message": "Instellingen importeren/exporteren" }, diff --git a/chrome/_locales/pl/messages.json b/chrome/_locales/pl/messages.json index 56967917f..e12c604d8 100644 --- a/chrome/_locales/pl/messages.json +++ b/chrome/_locales/pl/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "Czerń OLED" }, - "options_general_vischangeaction_pip": { - "message": "Przełącz obraz w obrazie" - }, "options_export_header": { "message": "Import/Eksport ustawień" }, diff --git a/chrome/_locales/pt_BR/messages.json b/chrome/_locales/pt_BR/messages.json index 2768c1240..69fda029f 100644 --- a/chrome/_locales/pt_BR/messages.json +++ b/chrome/_locales/pt_BR/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "Preto OLED" }, - "options_general_vischangeaction_pip": { - "message": "Alternar imagem sobreposta (pip)" - }, "options_export_header": { "message": "Configurações de importação/exportação" }, diff --git a/chrome/_locales/ru/messages.json b/chrome/_locales/ru/messages.json index 95dc400f4..abc896c1b 100644 --- a/chrome/_locales/ru/messages.json +++ b/chrome/_locales/ru/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "Чёрный OLED" }, - "options_general_vischangeaction_pip": { - "message": "Картинка в картинке" - }, "options_export_header": { "message": "Настройки импорта/экспорта" }, diff --git a/chrome/_locales/tr/messages.json b/chrome/_locales/tr/messages.json index dbf130089..a0f10d92f 100644 --- a/chrome/_locales/tr/messages.json +++ b/chrome/_locales/tr/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "OLED Siyah" }, - "options_general_vischangeaction_pip": { - "message": "Resim içinde resim aç/kapa" - }, "options_export_header": { "message": "Ayarları İçe/Dışa Aktar" }, diff --git a/chrome/_locales/zh_CN/messages.json b/chrome/_locales/zh_CN/messages.json index f66055cd0..8569ef5cc 100644 --- a/chrome/_locales/zh_CN/messages.json +++ b/chrome/_locales/zh_CN/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "OLED 纯黑" }, - "options_general_vischangeaction_pip": { - "message": "切换画中画" - }, "options_export_header": { "message": "导入/导出设置" }, diff --git a/chrome/_locales/zh_TW/messages.json b/chrome/_locales/zh_TW/messages.json index a23b6f727..87b4abdb7 100644 --- a/chrome/_locales/zh_TW/messages.json +++ b/chrome/_locales/zh_TW/messages.json @@ -959,9 +959,6 @@ "options_general_color_theme_oled": { "message": "OLED 純黑" }, - "options_general_vischangeaction_pip": { - "message": "切換畫中畫" - }, "options_export_header": { "message": "匯入/匯出設定" }, diff --git a/chrome/background/TabTracker.mjs b/chrome/background/TabTracker.mjs index 514d6a31f..5e70d8f74 100644 --- a/chrome/background/TabTracker.mjs +++ b/chrome/background/TabTracker.mjs @@ -280,6 +280,11 @@ export class TabHolder { // webRequest's id, a string; looked up with -1 too, as requestHeaders. /** @type {Map} */ this.redirectOrigins = new Map(); + // What other tabs held when the player in this tab cleared its sources (CLEAR_SOURCES): + // it is not pushed to it again (sendSourcesToMainFramePlayers). The entries themselves, + // not a time: a stream recovered later keeps the page's request time. + /** @type {WeakSet} */ + this.clearedSources = new WeakSet(); this.isOn = false; this.isMpv = false; diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index b466e9da3..06eb2ccd7 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -410,9 +410,11 @@ async function onClicked(tabobj, {playerKey = false} = {}) { if (tab.isOn) { openPlayersWithSources(tab); - } else if (await tabHasPlayer(tab)) { + } else if (await tabHasPlayer(tab) && !tab.isOn) { // A player still loading counts: the MPV cycle already checks it, and without - // it an Off clicked right after On left the player on the page. + // it an Off clicked right after On left the player on the page. Still Off after + // the question (the tab is asked after a restart): an On clicked meanwhile opened + // its player, which the reload took away again. tab.resetForReload(); chrome.tabs.reload(tab.tabId, () => BackgroundUtils.checkMessageError('reload', true)); // closed meanwhile: no unhandled error } @@ -518,6 +520,10 @@ async function openLinkIn(tabobj, linkUrl, mpv) { tab.isOn = true; tab.isMpv = mpv; tab.mpvOnPlay = mpv; + // The player chosen for it: the MPV Allowlist does not decide this site's pages again, as + // it does not once it has (another site is a fresh decision). It started MPV on the link's + // own page when the list named its site. + if (!mpv) tab.mpvMatched = true; BackgroundUtils.updateTabIcon(tab); Tabs.saveTabState(tab); } @@ -635,9 +641,12 @@ chrome.tabs.onCreated.addListener(async (newTab) => { // it has to be dropped here or MPV stays off for the rest of the tab's // life. Cleared here rather than in tab.reset(), which also runs on the // toolbar's Off path, where re-arming would undo the click. The user's Off on the - // site before goes with it (userTurnedOff). - tab.mpvMatched = false; - tab.mpvTurnedOff = false; + // site before goes with it (userTurnedOff). Not from an empty tab: a tab opened on a + // link (openLinkIn) may show about:blank first, and the player chosen for the link went. + if (!EmptyTabUrls.includes(oldURL.href)) { + tab.mpvMatched = false; + tab.mpvTurnedOff = false; + } } // Only the fragment changed (an anchor, #t=...): still the same page, whose player @@ -1248,6 +1257,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { f.getSources().length = 0; f.forgetHandedToPlayer(); } + // A player tab is sent other tabs' streams too, the whole of a tab's list each time it + // finds one: those they hold now stay cleared here. + tab.clearedSources = new WeakSet(); + for (const other of Tabs.tabs.values()) { + for (const f of other.getFrames()) { + for (const found of [...f.getSources(), ...f.getSubtitles()]) tab.clearedSources.add(found); + } + } sendResponse('cleared'); return; } else if (msg.type === MessageTypes.SET_HEADERS) { @@ -1402,7 +1419,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { ensureOptions().then(() => { restorePlayerFrame(tab, frame, sender); const pageFrame = frame.pageFrame; - if (!pageFrame) { + // A player in the tab's top frame has no page around it (its own tab, or the page went + // to it): as for the poll below. The click was asked of the player's own frame, where + // nothing answers. + if (!pageFrame || frame.frameId === 0) { sendResponse('error'); return; } @@ -2124,7 +2144,7 @@ async function loadCustomPatterns() { OptionsCache.customSourcePatterns = customSourcePatterns; const matcher = new MultiRegexMatcher(); - await loadCustomPatternsFile(matcher, Options.customSourcePatterns, true); + await loadCustomPatternsFile(matcher, Options.customSourcePatterns); if (OptionsCache.customSourcePatterns !== customSourcePatterns) return; @@ -2136,9 +2156,8 @@ async function loadCustomPatterns() { /** * @param {MultiRegexMatcher} matcher * @param {string} fileStr - The patterns, one per line. - * @param {boolean} [isPrimary] */ -async function loadCustomPatternsFile(matcher, fileStr, isPrimary = false) { +async function loadCustomPatternsFile(matcher, fileStr) { const {patterns, errors} = parseCustomSourcePatterns(fileStr); for (const {line, text, reason} of errors) { console.warn(`Custom source pattern on line ${line} left out (${reason}): ${text}`); @@ -3136,11 +3155,12 @@ async function sendSourcesToMainFramePlayers(frame) { // if the tab is a faststream tab const mainPlayerFrame = tab.getMainPlayer(); if (mainPlayerFrame) { - // send the source to the tab + // send the source to the tab: what was found since its player cleared its list + const since = (/** @type {object} */ found) => !tab.clearedSources.has(found); chrome.tabs.sendMessage(tab.tabId, { type: MessageTypes.SOURCES, - subtitles: frame.getSubtitles(), - sources: frame.getSources(), + subtitles: frame.getSubtitles().filter(since), + sources: frame.getSources().filter(since), autoSetSource: false, }, () => { BackgroundUtils.checkMessageError('sources'); diff --git a/chrome/content.js b/chrome/content.js index a7a1f98b9..1b856c597 100644 --- a/chrome/content.js +++ b/chrome/content.js @@ -75,7 +75,11 @@ let MiniplayerCooldown = 0; // Set when this frame is sent to the player (handlePlayerOpen's redirect). The frame is // not going away: the player takes it over, and asks the background for the sources - // detected in it. + // detected in it. 'sent', then 'leaving' from the redirect's beforeunload on; a second + // beforeunload is a leave of the page's own (the redirect's was cancelled: a "Leave page?" + // the user said no to), and ends it. Not a timer: an unload slower than one sent + // FRAME_REMOVED for the redirect, and the player got no sources (review, 2026-11-15). + /** @type {false|'sent'|'leaving'} */ let RedirectingToPlayer = false; // This page's name, for the background to tell a player it opens from one a page this // frame showed before opened (FRAME_ADDED, and the player URL's opener). Not @@ -291,10 +295,7 @@ iframeMap.set(request.frameId, newFrameObj); // Watched while it is in the page too: taken out, it gives back what it took // (checkPendingPlayers). - if (!pendingPlayersWatch) { - pendingPlayersWatch = new MutationObserver(checkPendingPlayers); - pendingPlayersWatch.observe(document.documentElement, {childList: true, subtree: true}); - } + watchRemovalOf(iframeElement); checkPendingPlayers(); updateReplacedPlayers(replacedData && replacedData.convertDue ? replacedData : null); @@ -321,9 +322,32 @@ */ function watchPendingPlayer(iframe, attempt) { pendingPlayers.set(iframe, attempt); + watchRemovalOf(iframe); + } + + /** @type {WeakSet} The shadow roots pendingPlayersWatch observes besides the page. */ + let watchedRoots = new WeakSet(); + + /** + * Has pendingPlayersWatch see a player iframe go: the page, and each shadow root it is in + * (a player put where the page's video was, in a web component): an observer of the page + * sees nothing of what changes inside one, and a component that re-rendered its own tree + * took the player out unseen. + * @param {Element} iframe + */ + function watchRemovalOf(iframe) { if (!pendingPlayersWatch) { pendingPlayersWatch = new MutationObserver(checkPendingPlayers); pendingPlayersWatch.observe(document.documentElement, {childList: true, subtree: true}); + watchedRoots = new WeakSet(); + } + let root = iframe.getRootNode(); + while (root && root !== document && /** @type {ShadowRoot} */ (root).host) { + if (!watchedRoots.has(root)) { + watchedRoots.add(root); + pendingPlayersWatch.observe(root, {childList: true, subtree: true}); + } + root = /** @type {ShadowRoot} */ (root).host.getRootNode(); } } @@ -558,7 +582,6 @@ * @property {PauseWatcher} watcher * @property {HTMLElement} old * @property {boolean} softReplace - * @property {boolean} fillScreen * @property {?HTMLVideoElement} [video] * @property {?PlayedVideo} [played] * @property {boolean} [convertDue] @@ -595,7 +618,7 @@ if (request.parentFrameId > -1) { newURL.searchParams.set('parent_frame_id', request.parentFrameId); } - RedirectingToPlayer = true; + RedirectingToPlayer = 'sent'; window.location = /** @type {string & Location} */ (newURL.href); sendResponse('redirect'); } else { @@ -640,7 +663,6 @@ watcher, old: video.highest, softReplace, - fillScreen: playerFillsScreen, }; // The video itself, for the stream it played (GET_PLAYED_VIDEO). A site's own query // may have found its player's box: the one video in it, and none when there are @@ -720,7 +742,8 @@ const windowedFullscreenState = iframeObj.windowedFullscreenState; - if (request.force === undefined || request.force !== windowedFullscreenState.active) { + // A state not entered yet has no `active`: an exit asked of it (force false) entered. + if (request.force === undefined || request.force !== !!windowedFullscreenState.active) { windowedFullscreenToggle(iframeObj); } @@ -761,7 +784,8 @@ } try { - if ((request.force !== undefined && request.force === miniplayerState.active) || iframeObj.windowedFullscreenState.active || document.fullscreenElement) { + // As for windowed fullscreen: a state not entered yet is out, not unknown. + if ((request.force !== undefined && request.force === !!miniplayerState.active) || iframeObj.windowedFullscreenState.active || document.fullscreenElement) { updateMiniPlayer(iframeObj); } else { toggleMiniPlayer(iframeObj); @@ -784,7 +808,7 @@ value: false, }); - if (result[0] !== 'recieved') { + if (result !== 'recieved') { console.error('Failed to send miniplayer state to player, assuming its closed'); unmakeMiniPlayer(iframeObj); updateReplacedPlayers(); @@ -807,32 +831,24 @@ } /** - * @param {number|number[]|'all'} frameId + * Tells a player something of its page (through the background, SEND_TO_PLAYER). + * @param {number|undefined} frameId - The player's frame: none for a state never entered. * @param {*} data + * @return {Promise<*>} Its answer; undefined when there is none, or no player to ask. */ - async function sendToPlayer(frameId, data) { - const frameIds = []; - if (typeof frameId === 'number') { - frameIds.push(frameId); - } else if (Array.isArray(frameId)) { - frameIds.push(...frameId); - } else if (frameId === 'all') { - iframeMap.forEach((iframeObj) => { - frameIds.push(iframeObj.frameId); - }); - } - - return Promise.all(frameIds.map((id) => { - return new Promise((resolve) => { - chrome.runtime.sendMessage({ - type: MessageTypes.SEND_TO_PLAYER, - frameId: id, - data, - }, (response) => { - resolve(response); - }); + function sendToPlayer(frameId, data) { + if (typeof frameId !== 'number') return Promise.resolve(undefined); + return new Promise((resolve) => { + chrome.runtime.sendMessage({ + type: MessageTypes.SEND_TO_PLAYER, + frameId, + data, + }, (response) => { + // A player gone meanwhile: no answer, which the caller reads as such. + void chrome.runtime.lastError; + resolve(response); }); - })); + }); } /** @@ -1621,8 +1637,8 @@ }; // Its sounds too, as for the ones added later: an