From b0117587c4dc17da339199479fd3c08a9736ee94 Mon Sep 17 00:00:00 2001 From: Jayden <23619946+Silverarmor@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:45:00 +1200 Subject: [PATCH 1/6] Detect and recover from caption injection race Panopto's viewer requests captions via fetch during boot. The custom SRT only applies if the userscript's fetch proxy is installed before that request fires; when Tampermonkey injects late (typically on fast cached loads), the original Panopto captions render while the banner still claims the custom SRT is active. Track whether the proxy actually intercepted a getCaptions request. If the transcript renders without interception, auto-reload up to twice (sessionStorage counter, reset on success or new upload). If it still fails, show a red failure banner instead of the misleading active one. --- panopto/panopto_captions.user.js | 67 +++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/panopto/panopto_captions.user.js b/panopto/panopto_captions.user.js index 16c1e8c..371cb66 100644 --- a/panopto/panopto_captions.user.js +++ b/panopto/panopto_captions.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Custom .srt captions - panopto.com // @namespace https://github.com/Silverarmor -// @version 0.1.14 +// @version 0.1.15 // @description Allows uploading custom SRT captions to Panopto with persistent per-video storage, custom SRT search, drag-and-drop support, clean page refreshing, and direct MP4 audio/video downloads. // @author Silverarmor // @match https://auckland.au.panopto.com/Panopto/Pages/Viewer.aspx* @@ -19,12 +19,35 @@ (function () { "use strict"; - console.log("[PanoptoCC] Script booting at v0.1.14"); + console.log("[PanoptoCC] Script booting at v0.1.15"); let injectedCaptions = null; let isCustomSrtActive = false; let uploadTimestamp = null; let videoUUID = null; + // The fetch proxy only works if this script is injected before the viewer + // requests captions. Track whether interception actually happened so a + // lost race can be detected and recovered from (see checkInjectionHealth). + let interceptedCaptions = false; + let injectionFailed = false; + let injectionRetryHandled = false; + + const MAX_INJECTION_RETRIES = 2; + + function retryCountKey() { + return "panoptocc-retry-" + (videoUUID || "unknown"); + } + + function getRetryCount() { + try { return parseInt(sessionStorage.getItem(retryCountKey()), 10) || 0; } catch (e) { return 0; } + } + + function setRetryCount(value) { + try { + if (value > 0) sessionStorage.setItem(retryCountKey(), String(value)); + else sessionStorage.removeItem(retryCountKey()); + } catch (e) { } + } /* ----------------------------- Helper: Refresh Page @@ -93,6 +116,8 @@ else if (body instanceof URLSearchParams) bodyString = body.toString(); if (bodyString.includes("getCaptions=true") && injectedCaptions) { + interceptedCaptions = true; + setRetryCount(0); return Promise.resolve( new Response(JSON.stringify(injectedCaptions), { status: 200, @@ -599,6 +624,7 @@ timestamp: formattedDate }; GM_setValue(videoUUID || getVideoId(), JSON.stringify(dataToStore)); + setRetryCount(0); refreshPage(); } @@ -669,11 +695,35 @@ }); } + /* ----------------------------- + Injection Health Check + ----------------------------- */ + // If the viewer rendered its transcript without our fetch proxy ever + // intercepting the captions request, this script was injected too late + // (Tampermonkey injection race) and Panopto's original captions are + // showing. Reload to retry; give up after MAX_INJECTION_RETRIES. + function checkInjectionHealth() { + if (!isCustomSrtActive || interceptedCaptions || injectionRetryHandled) return; + if (!getTranscriptRows().length) return; + + injectionRetryHandled = true; + const attempts = getRetryCount(); + if (attempts < MAX_INJECTION_RETRIES) { + setRetryCount(attempts + 1); + console.warn(`[PanoptoCC] Captions request was not intercepted (injected too late). Reloading to retry (${attempts + 1}/${MAX_INJECTION_RETRIES})`); + refreshPage(); + } else { + injectionFailed = true; + console.error("[PanoptoCC] Could not intercept the captions request after retries - Panopto's original captions are showing."); + } + } + /* ----------------------------- DOM Observer ----------------------------- */ function startObservers() { const observer = new MutationObserver(() => { + checkInjectionHealth(); initCustomSearch(); if (isCustomSrtActive) lockCustomSearchControls(); initJumpToCurrentCaptionButton(); @@ -689,15 +739,20 @@ if (isCustomSrtActive) { const warningSpan = document.querySelector(".css-b93d1p .css-1i5jedo"); - if (warningSpan && !warningSpan.dataset.statusSwapped) { - warningSpan.dataset.statusSwapped = "true"; + if (warningSpan) { const timeInfo = uploadTimestamp ? ` (Uploaded: ${uploadTimestamp})` : ""; - warningSpan.textContent = `Custom SRT is active${timeInfo}`; - warningSpan.parentElement.style.color = "#1976d2"; + const statusText = injectionFailed + ? "Custom SRT failed to apply - Panopto's captions are showing. Reload to retry." + : `Custom SRT is active${timeInfo}`; + if (warningSpan.textContent !== statusText) { + warningSpan.textContent = statusText; + warningSpan.parentElement.style.color = injectionFailed ? "#d32f2f" : "#1976d2"; + } } } }); observer.observe(document.body, { childList: true, subtree: true }); + checkInjectionHealth(); initCustomSearch(); if (isCustomSrtActive) lockCustomSearchControls(); initJumpToCurrentCaptionButton(); From 0bcd2fdf881241329f26159e3ad788283539a7d9 Mon Sep 17 00:00:00 2001 From: Jayden <23619946+Silverarmor@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:56:27 +1200 Subject: [PATCH 2/6] Fix one-reload-behind storage lag in UserScripts API Dynamic mode In Tampermonkey MV3 with Content Script API set to "UserScripts API Dynamic" (needed for reliable document-start injection), GM values are injected as a snapshot that updates asynchronously. A GM_setValue or GM_deleteValue followed immediately by a reload therefore boots with the previous value: reverting still shows the old SRT, and a fresh upload does not apply until a second manual reload (Tampermonkey issue #2123). Mirror the latest save/revert in sessionStorage, which is synchronous and survives same-tab reloads, and prefer it over the GM snapshot at boot. The mirror is dropped as soon as GM storage has caught up. --- panopto/panopto_captions.user.js | 51 ++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/panopto/panopto_captions.user.js b/panopto/panopto_captions.user.js index 371cb66..6cae0d5 100644 --- a/panopto/panopto_captions.user.js +++ b/panopto/panopto_captions.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Custom .srt captions - panopto.com // @namespace https://github.com/Silverarmor -// @version 0.1.15 +// @version 0.1.16 // @description Allows uploading custom SRT captions to Panopto with persistent per-video storage, custom SRT search, drag-and-drop support, clean page refreshing, and direct MP4 audio/video downloads. // @author Silverarmor // @match https://auckland.au.panopto.com/Panopto/Pages/Viewer.aspx* @@ -19,7 +19,7 @@ (function () { "use strict"; - console.log("[PanoptoCC] Script booting at v0.1.15"); + console.log("[PanoptoCC] Script booting at v0.1.16"); let injectedCaptions = null; let isCustomSrtActive = false; @@ -49,6 +49,46 @@ } catch (e) { } } + /* ----------------------------- + GM Storage handoff + In Tampermonkey's "UserScripts API Dynamic" mode, GM values are + injected as a snapshot that can lag one reload behind a fresh + GM_setValue/GM_deleteValue. Mirror the latest save/revert in + sessionStorage (synchronous, same-tab) so the reload right after an + upload or revert sees the new state; drop the mirror once GM storage + has caught up. + ----------------------------- */ + const DELETED_SENTINEL = "__PANOPTOCC_DELETED__"; + + function pendingValueKey(uuid) { + return "panoptocc-pending-" + uuid; + } + + function writeCaptionStore(uuid, serialized) { + if (serialized === null) { + GM_deleteValue(uuid); + try { sessionStorage.setItem(pendingValueKey(uuid), DELETED_SENTINEL); } catch (e) { } + } else { + GM_setValue(uuid, serialized); + try { sessionStorage.setItem(pendingValueKey(uuid), serialized); } catch (e) { } + } + } + + function readCaptionStore(uuid) { + let stored = null; + try { stored = GM_getValue(uuid, null); } catch (e) { } + + let pending = null; + try { pending = sessionStorage.getItem(pendingValueKey(uuid)); } catch (e) { } + if (pending === null) return stored; + + const pendingValue = pending === DELETED_SENTINEL ? null : pending; + if (stored === pendingValue) { + try { sessionStorage.removeItem(pendingValueKey(uuid)); } catch (e) { } + } + return pendingValue; + } + /* ----------------------------- Helper: Refresh Page ----------------------------- */ @@ -81,7 +121,7 @@ if (videoUUID) { try { - const storedData = GM_getValue(videoUUID, null); + const storedData = readCaptionStore(videoUUID); if (storedData) { const parsed = JSON.parse(storedData); if (parsed.captions) { @@ -623,7 +663,7 @@ captions: captions, timestamp: formattedDate }; - GM_setValue(videoUUID || getVideoId(), JSON.stringify(dataToStore)); + writeCaptionStore(videoUUID || getVideoId(), JSON.stringify(dataToStore)); setRetryCount(0); refreshPage(); } @@ -654,7 +694,8 @@ btn.style.backgroundColor = "#d32f2f"; btn.onclick = (e) => { e.stopPropagation(); - GM_deleteValue(videoUUID || getVideoId()); + writeCaptionStore(videoUUID || getVideoId(), null); + setRetryCount(0); refreshPage(); }; return btn; From e14241e5d79f2b9d4f5e427eb9429d5920df8ec6 Mon Sep 17 00:00:00 2001 From: Jayden <23619946+Silverarmor@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:59:00 +1200 Subject: [PATCH 3/6] Document UserScripts API Dynamic setup for document-start scripts --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 8a9fae0..3489a0b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ This repo is mostly browser userscripts, but not everything here is a Tampermonk See Tampermonkey's FAQ entry on the Chrome `userScripts` requirement: [Q209](https://www.tampermonkey.net/faq.php?ext=dhdg&q=Q209). +#### Reliable `@run-at document-start` injection + +Some scripts here (such as the Panopto custom captions script) must run before the page's own JavaScript, or they will intermittently fail. In Tampermonkey's default mode under Manifest V3, `@run-at document-start` is not honoured reliably, so switch injection modes: + +1. Make sure **Allow User Scripts** (or **Developer mode**) is enabled as described above — without it, Tampermonkey silently falls back to the slow injection path. +2. Open Tampermonkey's **Settings** tab and set **Config mode** to **Advanced**. +3. Under **Experimental**, set **Content Script API** to **UserScripts API Dynamic**. This replaces the old "Inject Mode: Instant" setting and is the only Chrome option with true `document-start` support. + +Known trade-off of Dynamic mode: Tampermonkey injects GM storage values as a snapshot that can lag one reload behind a fresh write ([tampermonkey#2123](https://github.com/Tampermonkey/tampermonkey/issues/2123)). The Panopto captions script works around this internally, but other scripts that write GM values and immediately reload may appear one reload out of date. + ### Safari alternatives Safari users have a few options: From 01dfcc89d899aa8a16e49aaf76e5cd5fc98bf43e Mon Sep 17 00:00:00 2001 From: Jayden <23619946+Silverarmor@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:38:19 +1200 Subject: [PATCH 4/6] Stop header buttons wrapping into the player on long titles Long lecture titles squeezed the header so "Replace SRT" / "Revert to Default" / "Download" wrapped to multiple lines and bled into the video area. Hide the Panopto small logo to reclaim header width, shorten the button labels to one word with the full text in a hover tooltip, and force nowrap so the buttons can no longer wrap regardless of width. --- panopto/panopto_captions.user.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/panopto/panopto_captions.user.js b/panopto/panopto_captions.user.js index 6cae0d5..bdd727c 100644 --- a/panopto/panopto_captions.user.js +++ b/panopto/panopto_captions.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Custom .srt captions - panopto.com // @namespace https://github.com/Silverarmor -// @version 0.1.16 +// @version 0.1.17 // @description Allows uploading custom SRT captions to Panopto with persistent per-video storage, custom SRT search, drag-and-drop support, clean page refreshing, and direct MP4 audio/video downloads. // @author Silverarmor // @match https://auckland.au.panopto.com/Panopto/Pages/Viewer.aspx* @@ -19,7 +19,7 @@ (function () { "use strict"; - console.log("[PanoptoCC] Script booting at v0.1.16"); + console.log("[PanoptoCC] Script booting at v0.1.17"); let injectedCaptions = null; let isCustomSrtActive = false; @@ -650,6 +650,7 @@ btn.style.alignItems = "center"; btn.style.textDecoration = "none"; btn.style.boxSizing = "border-box"; + btn.style.whiteSpace = "nowrap"; } function saveAndRefresh(captions) { @@ -671,7 +672,8 @@ function createUploadButton() { const btn = document.createElement("button"); applySharedStyles(btn); - btn.textContent = isCustomSrtActive ? "Replace SRT" : "Upload SRT"; + btn.textContent = isCustomSrtActive ? "Replace" : "Upload"; + btn.title = isCustomSrtActive ? "Replace SRT" : "Upload SRT"; btn.style.backgroundColor = "#1976d2"; btn.onclick = (e) => { e.stopPropagation(); @@ -690,7 +692,8 @@ function createClearButton() { const btn = document.createElement("button"); applySharedStyles(btn); - btn.textContent = "Revert to Default"; + btn.textContent = "Revert"; + btn.title = "Revert to Default"; btn.style.backgroundColor = "#d32f2f"; btn.onclick = (e) => { e.stopPropagation(); @@ -707,6 +710,7 @@ const btn = document.createElement("a"); applySharedStyles(btn); btn.textContent = "Download"; + btn.title = "Download audio podcast MP4"; btn.href = `https://auckland.au.panopto.com/Panopto/Podcast/Download/${uuid}.mp4?mediaTargetType=audioPodcast`; btn.download = `AudioPodcast-${uuid}.mp4`; btn.target = "_blank"; @@ -800,6 +804,9 @@ } GM_addStyle(` + #logoContainer.small-logo { + display: none !important; + } #searchRegion.custom-srt-search-active { position: relative; } From 41cac2df859bf87d43c0b839e1f8aef1f00e7a04 Mon Sep 17 00:00:00 2001 From: Jayden <23619946+Silverarmor@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:50:10 +1200 Subject: [PATCH 5/6] Truncate long titles with ellipsis so header buttons stay on screen Verified live: with the one-word buttons an extreme title no longer wraps them into the player, but it pushed them off the right edge of the viewport. Let .header-left shrink (min-width: 0) inside the flex header and ellipsize #deliveryTitle so the buttons always remain visible on a single line. --- panopto/panopto_captions.user.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/panopto/panopto_captions.user.js b/panopto/panopto_captions.user.js index bdd727c..224a340 100644 --- a/panopto/panopto_captions.user.js +++ b/panopto/panopto_captions.user.js @@ -807,6 +807,16 @@ #logoContainer.small-logo { display: none !important; } + #viewerHeader .header-left { + flex: 1 1 auto !important; + min-width: 0 !important; + overflow: hidden !important; + } + #deliveryTitle { + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + } #searchRegion.custom-srt-search-active { position: relative; } From 78c7495188a342368b43f87633865f65847e1620 Mon Sep 17 00:00:00 2001 From: Jayden <23619946+Silverarmor@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:56:36 +1200 Subject: [PATCH 6/6] Make header compaction adaptive - only shorten labels when title needs it Buttons now default to their full labels ("Replace SRT", "Revert to Default") with the logo visible. When the title actually truncates (scrollWidth exceeds clientWidth), the script switches to one-word labels and hides the logo; full labels stay available as hover tooltips either way. Expansion back uses a synchronous trial: restore full labels and logo, force a reflow, and revert if the title truncates - nothing paints mid-task so a failed trial is invisible. The title element is shrink-to-fit (clientWidth always equals scrollWidth when it fits), so truncation is the only reliable signal; a slack-based headroom check can never fire. Trials are throttled to one per second because the label swap itself retriggers the MutationObserver, and a resize listener re-evaluates on window changes. Verified live on the Auckland tenant: compact triggers on an inflated title, stays stable while long, and expands back when the title fits. --- panopto/panopto_captions.user.js | 79 +++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/panopto/panopto_captions.user.js b/panopto/panopto_captions.user.js index 224a340..197d47d 100644 --- a/panopto/panopto_captions.user.js +++ b/panopto/panopto_captions.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Custom .srt captions - panopto.com // @namespace https://github.com/Silverarmor -// @version 0.1.17 +// @version 0.1.18 // @description Allows uploading custom SRT captions to Panopto with persistent per-video storage, custom SRT search, drag-and-drop support, clean page refreshing, and direct MP4 audio/video downloads. // @author Silverarmor // @match https://auckland.au.panopto.com/Panopto/Pages/Viewer.aspx* @@ -19,7 +19,7 @@ (function () { "use strict"; - console.log("[PanoptoCC] Script booting at v0.1.17"); + console.log("[PanoptoCC] Script booting at v0.1.18"); let injectedCaptions = null; let isCustomSrtActive = false; @@ -672,8 +672,10 @@ function createUploadButton() { const btn = document.createElement("button"); applySharedStyles(btn); - btn.textContent = isCustomSrtActive ? "Replace" : "Upload"; - btn.title = isCustomSrtActive ? "Replace SRT" : "Upload SRT"; + btn.dataset.panoptoccFullLabel = isCustomSrtActive ? "Replace SRT" : "Upload SRT"; + btn.dataset.panoptoccShortLabel = isCustomSrtActive ? "Replace" : "Upload"; + btn.textContent = headerCompact ? btn.dataset.panoptoccShortLabel : btn.dataset.panoptoccFullLabel; + btn.title = btn.dataset.panoptoccFullLabel; btn.style.backgroundColor = "#1976d2"; btn.onclick = (e) => { e.stopPropagation(); @@ -692,8 +694,10 @@ function createClearButton() { const btn = document.createElement("button"); applySharedStyles(btn); - btn.textContent = "Revert"; - btn.title = "Revert to Default"; + btn.dataset.panoptoccFullLabel = "Revert to Default"; + btn.dataset.panoptoccShortLabel = "Revert"; + btn.textContent = headerCompact ? btn.dataset.panoptoccShortLabel : btn.dataset.panoptoccFullLabel; + btn.title = btn.dataset.panoptoccFullLabel; btn.style.backgroundColor = "#d32f2f"; btn.onclick = (e) => { e.stopPropagation(); @@ -718,6 +722,61 @@ return btn; } + /* ----------------------------- + Adaptive header sizing + Full button labels by default; switch to one-word labels and hide + the Panopto logo only while the title is squeezed (its ellipsis is + active). Expanding back requires enough slack to refit the full + labels and logo, so the two states cannot oscillate. + ----------------------------- */ + const COMPACT_CLASS = "panoptocc-compact-header"; + const EXPAND_TRIAL_INTERVAL_MS = 1000; + let headerCompact = false; + let lastExpandTrial = 0; + + function headerLabelButtons() { + return Array.from(document.querySelectorAll("[data-panoptocc-full-label]")); + } + + function applyHeaderLabels() { + headerLabelButtons().forEach((btn) => { + const label = headerCompact ? btn.dataset.panoptoccShortLabel : btn.dataset.panoptoccFullLabel; + if (btn.textContent !== label) btn.textContent = label; + }); + document.documentElement.classList.toggle(COMPACT_CLASS, headerCompact); + } + + function updateHeaderCompactness() { + const title = document.querySelector("#deliveryTitle"); + if (!title || !headerLabelButtons().length) return; + + if (!headerCompact) { + if (title.scrollWidth > title.clientWidth + 1) { + headerCompact = true; + applyHeaderLabels(); + } + return; + } + + // Trial expansion: restore full labels and logo, then keep them only + // if the title does not truncate. The title is shrink-to-fit + // (clientWidth === scrollWidth whenever it fits), so truncation is the + // only usable signal. Reading scrollWidth forces a synchronous reflow + // and no paint happens mid-task, so a failed trial is invisible. + // Throttled because the label swap itself triggers the + // MutationObserver that calls this. + const now = Date.now(); + if (now - lastExpandTrial < EXPAND_TRIAL_INTERVAL_MS) return; + lastExpandTrial = now; + + headerCompact = false; + applyHeaderLabels(); + if (title.scrollWidth > title.clientWidth + 1) { + headerCompact = true; + applyHeaderLabels(); + } + } + /* ----------------------------- Drag and Drop & Status UI ----------------------------- */ @@ -769,6 +828,7 @@ function startObservers() { const observer = new MutationObserver(() => { checkInjectionHealth(); + updateHeaderCompactness(); initCustomSearch(); if (isCustomSrtActive) lockCustomSearchControls(); initJumpToCurrentCaptionButton(); @@ -797,6 +857,11 @@ } }); observer.observe(document.body, { childList: true, subtree: true }); + let resizeTimer = 0; + window.addEventListener("resize", () => { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(updateHeaderCompactness, 150); + }); checkInjectionHealth(); initCustomSearch(); if (isCustomSrtActive) lockCustomSearchControls(); @@ -804,7 +869,7 @@ } GM_addStyle(` - #logoContainer.small-logo { + .panoptocc-compact-header #logoContainer.small-logo { display: none !important; } #viewerHeader .header-left {