// ==UserScript== // @name Custom .srt captions - panopto.com // @namespace https://github.com/Silverarmor // @version 0.1.14 // @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* // @homepageURL https://github.com/Silverarmor/Userscripts // @updateURL https://raw.githubusercontent.com/Silverarmor/Userscripts/master/panopto/panopto_captions.user.js // @downloadURL https://raw.githubusercontent.com/Silverarmor/Userscripts/master/panopto/panopto_captions.user.js // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant unsafeWindow // @run-at document-start // ==/UserScript== (function () { "use strict"; console.log("[PanoptoCC] Script booting at v0.1.14"); let injectedCaptions = null; let isCustomSrtActive = false; let uploadTimestamp = null; let videoUUID = null; /* ----------------------------- Helper: Refresh Page ----------------------------- */ function refreshPage() { window.location.href = window.location.href; } /* ----------------------------- Helper: Get Video UUID ----------------------------- */ function getVideoId() { const params = new URLSearchParams(window.location.search); let id = params.get("id"); if (!id) { const metaTag = document.querySelector('meta[property="og:url"]'); if (metaTag) { try { const urlObj = new URL(metaTag.getAttribute("content")); id = urlObj.searchParams.get("id"); } catch (e) { } } } return id; } /* ----------------------------- Load captions from GM Storage ----------------------------- */ videoUUID = getVideoId(); if (videoUUID) { try { const storedData = GM_getValue(videoUUID, null); if (storedData) { const parsed = JSON.parse(storedData); if (parsed.captions) { injectedCaptions = parsed.captions; uploadTimestamp = parsed.timestamp; } else { injectedCaptions = parsed; } isCustomSrtActive = true; } } catch (err) { console.error("[PanoptoCC] Storage load failed", err); } } /* ----------------------------- Fetch Proxy ----------------------------- */ const pageWindow = unsafeWindow || window; const originalFetch = pageWindow.fetch; pageWindow.fetch = new Proxy(originalFetch, { apply(target, thisArg, args) { const urlArg = args[0]; const options = args[1] || {}; let url = (typeof urlArg === "string") ? urlArg : (urlArg.url || urlArg.href || ""); if (url.includes("DeliveryInfo.aspx")) { let bodyString = ""; const body = options.body; if (typeof body === "string") bodyString = body; else if (body instanceof URLSearchParams) bodyString = body.toString(); if (bodyString.includes("getCaptions=true") && injectedCaptions) { return Promise.resolve( new Response(JSON.stringify(injectedCaptions), { status: 200, headers: { "Content-Type": "application/json" } }) ); } } return Reflect.apply(target, thisArg, args); } }); /* ----------------------------- SRT Parser ----------------------------- */ function srtTimeToSeconds(time) { const parts = time.split(":"); const hours = parseInt(parts[0]); const minutes = parseInt(parts[1]); const secParts = parts[2].split(","); const seconds = parseInt(secParts[0]); const millis = parseInt(secParts[1] || 0); return hours * 3600 + minutes * 60 + seconds + millis / 1000; } function parseSRT(text) { const blocks = text.replace(/\r/g, "").trim().split(/\n\n+/); const captions = []; for (const block of blocks) { const lines = block.split("\n"); if (lines.length < 2) continue; const match = lines[1].match(/(.+) --> (.+)/); if (!match) continue; const start = srtTimeToSeconds(match[1].trim()); const end = srtTimeToSeconds(match[2].trim()); captions.push({ Caption: lines.slice(2).join(" ").trim(), CaptionDuration: end - start, Time: start, AbsoluteTime: 0, CreatedDuringWebcast: false, CreationDateTime: "\\/Date(-11644473600000)\\/", CreationTime: 0, Data: null, Duration: 0, EventTargetType: null, ID: 0, IsQuestionList: false, IsSessionPlaybackBlocking: false, ObjectIdentifier: null, ObjectPublicIdentifier: "00000000-0000-0000-0000-000000000000", ObjectSequenceNumber: null, ObjectStreamID: "00000000-0000-0000-0000-000000000000", PublicId: "00000000-0000-0000-0000-000000000000", SessionID: "00000000-0000-0000-0000-000000000000", ShowInTableOfContents: false, Url: null, UserDisplayName: null, UserInvocationRequiredInUrl: false, UserName: null }); } return captions; } /* ----------------------------- Custom SRT Search ----------------------------- */ function normaliseSearchText(text) { return (text || "").toString().toLowerCase().replace(/\s+/g, " ").trim(); } function escapeHTML(text) { const div = document.createElement("div"); div.textContent = text || ""; return div.innerHTML; } function escapeRegExp(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function captionStartTime(caption) { return Number(caption && (caption.Time ?? caption.StartTime ?? caption.Start)) || 0; } function formatDuration(seconds) { const safeSeconds = Math.max(0, Math.floor(Number(seconds) || 0)); const hours = Math.floor(safeSeconds / 3600); const minutes = Math.floor((safeSeconds % 3600) / 60); const secs = safeSeconds % 60; if (hours > 0) { return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; } return `${minutes}:${String(secs).padStart(2, "0")}`; } function highlightMatches(text, terms) { let escaped = escapeHTML(text || ""); const uniqueTerms = Array.from(new Set(terms.filter(Boolean))).sort((a, b) => b.length - a.length); if (!uniqueTerms.length) return escaped; const pattern = uniqueTerms.map(escapeRegExp).join("|"); return escaped.replace(new RegExp(`(${pattern})`, "gi"), "$1"); } function getCustomSearchTerms(query) { const phrase = normaliseSearchText(query); const terms = phrase.split(" ").filter(Boolean); return phrase.length > 1 ? [phrase, ...terms] : terms; } function findCustomCaptionMatches(query) { const terms = getCustomSearchTerms(query); if (!terms.length || !Array.isArray(injectedCaptions)) return []; return injectedCaptions .map((caption, index) => ({ caption, index, searchable: normaliseSearchText(caption.Caption) })) .filter((item) => terms.every((term) => item.searchable.includes(term))) .sort((a, b) => captionStartTime(a.caption) - captionStartTime(b.caption)); } function getCaptionEndTime(caption) { return captionStartTime(caption) + (Number(caption && caption.CaptionDuration) || 0); } function findCaptionAtTime(seconds) { if (!Array.isArray(injectedCaptions) || !injectedCaptions.length) return null; const time = Math.max(0, Number(seconds) || 0); const indexedCaptions = injectedCaptions.map((caption, index) => ({ caption, index })); const activeCaption = indexedCaptions.find(({ caption }) => { const start = captionStartTime(caption); const end = getCaptionEndTime(caption); return time >= start && time <= Math.max(start, end); }); if (activeCaption) return activeCaption; const previousCaption = indexedCaptions .filter(({ caption }) => captionStartTime(caption) <= time) .sort((a, b) => captionStartTime(b.caption) - captionStartTime(a.caption))[0]; if (previousCaption) return previousCaption; return indexedCaptions[0]; } function getTranscriptRows() { return Array.from(document.querySelectorAll("#transcriptTabPane li[id^='UserCreatedTranscript-']")); } function findTranscriptRowByTime(seconds, fallbackIndex) { const targetMillis = Math.round(Math.max(0, Number(seconds) || 0) * 1000); const rows = getTranscriptRows(); const timeMatchedRow = rows .map((row) => { const match = row.id.match(/^UserCreatedTranscript-(\d+)/); return match ? { row, delta: Math.abs(Number(match[1]) - targetMillis) } : null; }) .filter(Boolean) .sort((a, b) => a.delta - b.delta)[0]; if (timeMatchedRow && timeMatchedRow.delta <= 1000) return timeMatchedRow.row; return Number.isInteger(fallbackIndex) ? rows[fallbackIndex] : null; } function selectTranscriptTab() { const transcriptTabHeader = document.querySelector("#transcriptTabHeader"); const transcriptTabPane = document.querySelector("#transcriptTabPane"); if (!transcriptTabHeader || !transcriptTabPane) return; if (!transcriptTabHeader.classList.contains("selected")) { transcriptTabHeader.click(); } document.querySelectorAll("#eventTabControl .event-tab-header").forEach((tab) => { tab.classList.remove("selected"); tab.setAttribute("aria-selected", "false"); tab.setAttribute("tabindex", "-1"); }); document.querySelectorAll("#eventTabPanes .event-tab-pane").forEach((pane) => { pane.style.display = "none"; }); transcriptTabHeader.style.display = ""; transcriptTabHeader.classList.add("selected"); transcriptTabHeader.setAttribute("aria-selected", "true"); transcriptTabHeader.setAttribute("tabindex", "0"); transcriptTabPane.style.display = ""; } function selectSearchResultsTab() { const searchTabHeader = document.querySelector("#searchTabHeader"); const searchTabPane = document.querySelector("#searchTabPane"); if (!searchTabHeader || !searchTabPane) return; document.querySelectorAll("#eventTabControl .event-tab-header").forEach((tab) => { tab.classList.remove("selected"); tab.setAttribute("aria-selected", "false"); tab.setAttribute("tabindex", "-1"); }); document.querySelectorAll("#eventTabPanes .event-tab-pane").forEach((pane) => { pane.style.display = "none"; }); searchTabHeader.style.display = ""; searchTabHeader.classList.add("selected"); searchTabHeader.setAttribute("aria-selected", "true"); searchTabHeader.setAttribute("tabindex", "0"); searchTabPane.style.display = ""; } function clearCaptionSearchHighlights() { document.querySelectorAll("#transcriptTabPane .custom-srt-caption-search-match").forEach((row) => { const textSpan = row.querySelector(".event-text span"); if (textSpan && textSpan.dataset.customSrtOriginalText !== undefined) { textSpan.textContent = textSpan.dataset.customSrtOriginalText; delete textSpan.dataset.customSrtOriginalText; } row.classList.remove("custom-srt-caption-search-match", "custom-srt-caption-search-current"); }); } function highlightTranscriptRow(row, terms) { const textSpan = row.querySelector(".event-text span"); if (!textSpan) return; if (textSpan.dataset.customSrtOriginalText === undefined) { textSpan.dataset.customSrtOriginalText = textSpan.textContent || ""; } textSpan.innerHTML = highlightMatches(textSpan.dataset.customSrtOriginalText, terms); row.classList.add("custom-srt-caption-search-match"); } function activateCustomCaptionResult(caption, terms, captionIndex, shouldSeek = true) { const start = captionStartTime(caption); clearCaptionSearchHighlights(); selectTranscriptTab(); const activateRow = (shouldClick) => { const row = findTranscriptRowByTime(start, captionIndex); if (!row) return false; if (shouldClick && shouldSeek) row.click(); highlightTranscriptRow(row, terms); row.classList.add("custom-srt-caption-search-current"); row.scrollIntoView({ behavior: "smooth", block: "center" }); row.focus({ preventScroll: true }); return true; }; if (!activateRow(true) && shouldSeek) { const video = document.querySelector("video"); if (video) { video.currentTime = start; video.play().catch(() => { }); } } setTimeout(() => activateRow(false), 75); setTimeout(() => activateRow(false), 250); setTimeout(() => activateRow(false), 600); } function jumpToCurrentCaption(event) { if (event) { event.preventDefault(); event.stopPropagation(); } const video = document.querySelector("video"); if (!video) return; const match = findCaptionAtTime(video.currentTime); if (!match) return; activateCustomCaptionResult(match.caption, [], match.index, false); } function createJumpToCurrentCaptionButton() { const btn = document.createElement("button"); btn.type = "button"; btn.className = "custom-srt-jump-current-caption MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeMedium"; btn.setAttribute("aria-label", "Jump to current caption"); btn.title = "Jump to current caption"; btn.innerHTML = ` `; btn.addEventListener("click", jumpToCurrentCaption); return btn; } function initJumpToCurrentCaptionButton() { if (!isCustomSrtActive) return; const header = document.querySelector("#transcriptPaneHeader .event-tab-pane-header"); if (!header || header.querySelector(".custom-srt-jump-current-caption")) return; const downloadTranscriptButton = header.querySelector("button[aria-label='Download transcript']"); const jumpButton = createJumpToCurrentCaptionButton(); if (downloadTranscriptButton) { header.insertBefore(jumpButton, downloadTranscriptButton); } else { header.appendChild(jumpButton); } } function renderCustomSearchResults(query) { const resultsList = document.querySelector("#searchTabPane .event-tab-list"); const message = document.querySelector("#searchResultsMessage"); const ariaMessage = document.querySelector("#searchResultsAria"); if (!resultsList || !message) return false; const matches = findCustomCaptionMatches(query); const terms = getCustomSearchTerms(query); resultsList.textContent = ""; clearCaptionSearchHighlights(); updateSearchClearButton(); message.textContent = matches.length ? `${matches.length} custom SRT caption result${matches.length === 1 ? "" : "s"}` : "No custom SRT caption results"; if (ariaMessage) ariaMessage.textContent = message.textContent; for (const { caption, index } of matches) { const start = captionStartTime(caption); const row = document.createElement("li"); row.id = `customSrtSearch-${Math.round(start * 1000)}-${index}`; row.className = "index-event custom-srt-search-result"; row.tabIndex = index === 0 ? 0 : -1; row.innerHTML = `