Transcript-only YouTubeToTranscript helper with a floating player-bar popup for Safari userscript managers.
// ==UserScript==
// @name YouTube Transcript Floating Bar
// @namespace https://youtubetotranscript.com/
// @version 0.1.6
// @description Transcript-only YouTubeToTranscript helper with a floating player-bar popup for Safari userscript managers.
// @author YouTube To Transcript userscript variant
// @match https://www.youtube.com/*
// @match https://m.youtube.com/*
// @match https://youtubetotranscript.com/*
// @connect youtubetotranscript.com
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const SCRIPT_ID = "ytt-transcript-bar";
const SERVICE_BASE = "https://youtubetotranscript.com";
const TOKEN_LIFETIME_MS = 31536000000;
const CACHE_TTL_MS = 15 * 60 * 1000;
const state = {
videoID: "",
transcript: null,
loading: false,
error: "",
toolbar: null,
panel: null,
statusNode: null,
mutationTimer: 0
};
GM_addStyle(`
.${SCRIPT_ID}-toolbar {
display: inline-flex;
height: 100%;
min-width: 40px;
overflow: visible;
position: relative;
vertical-align: top;
}
.${SCRIPT_ID}-button {
align-items: center;
background: transparent;
border: 0;
border-radius: 0;
color: #fff;
cursor: pointer;
display: inline-flex;
font: 700 13px/1 Arial, sans-serif;
height: 100%;
justify-content: center;
margin: 0;
min-width: 40px;
opacity: 0.92;
padding: 0;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.85);
}
.${SCRIPT_ID}-button:hover {
opacity: 1;
}
.${SCRIPT_ID}-panel {
background: #0f0f0f;
border: 1px solid #3f3f3f;
border-radius: 12px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
color: #fff;
display: none;
font: 13px/1.4 Arial, sans-serif;
max-height: min(620px, calc(100vh - 130px));
overflow: hidden;
padding: 0;
position: absolute;
right: 0;
bottom: 52px;
width: min(420px, calc(100vw - 28px));
z-index: 2147483646;
}
.${SCRIPT_ID}-panel.${SCRIPT_ID}-open {
display: grid;
grid-template-rows: auto auto minmax(160px, 1fr) auto;
}
.${SCRIPT_ID}-header,
.${SCRIPT_ID}-actions,
.${SCRIPT_ID}-auth {
padding: 10px;
}
.${SCRIPT_ID}-header {
align-items: center;
border-bottom: 1px solid #272727;
display: flex;
gap: 8px;
justify-content: space-between;
}
.${SCRIPT_ID}-title {
font-size: 15px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${SCRIPT_ID}-status {
color: #aaa;
font-size: 12px;
min-height: 17px;
padding: 8px 10px 0;
}
.${SCRIPT_ID}-body {
margin: 8px 10px;
max-height: min(420px, calc(100vh - 265px));
overflow: auto;
overscroll-behavior: contain;
padding-right: 4px;
white-space: pre-wrap;
word-break: break-word;
}
.${SCRIPT_ID}-cue {
border-radius: 5px;
display: grid;
gap: 8px;
grid-template-columns: 54px 1fr;
padding: 5px 3px;
}
.${SCRIPT_ID}-cue:hover {
background: #272727;
}
.${SCRIPT_ID}-time {
color: #3ea6ff;
cursor: pointer;
font-variant-numeric: tabular-nums;
text-decoration: none;
}
.${SCRIPT_ID}-text {
color: #f1f1f1;
}
.${SCRIPT_ID}-empty {
color: #aaa;
padding: 18px 2px;
text-align: center;
}
.${SCRIPT_ID}-actions {
align-items: center;
border-top: 1px solid #272727;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
.${SCRIPT_ID}-left-actions,
.${SCRIPT_ID}-right-actions {
align-items: center;
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.${SCRIPT_ID}-small,
.${SCRIPT_ID}-primary {
align-items: center;
border: 1px solid #3f3f3f;
border-radius: 18px;
color: #fff;
cursor: pointer;
display: inline-flex;
font: 600 12px/1 Arial, sans-serif;
height: 30px;
justify-content: center;
padding: 0 10px;
}
.${SCRIPT_ID}-small {
background: #272727;
}
.${SCRIPT_ID}-primary {
background: #fff;
border-color: #fff;
color: #0f0f0f;
}
.${SCRIPT_ID}-small:hover,
.${SCRIPT_ID}-primary:hover {
filter: brightness(1.1);
}
.${SCRIPT_ID}-toggle {
align-items: center;
color: #f1f1f1;
display: inline-flex;
gap: 6px;
user-select: none;
white-space: nowrap;
}
.${SCRIPT_ID}-toggle input {
height: 15px;
margin: 0;
width: 15px;
}
.${SCRIPT_ID}-auth {
border-top: 1px solid #272727;
display: none;
gap: 8px;
grid-template-columns: 1fr auto;
}
.${SCRIPT_ID}-auth.${SCRIPT_ID}-show {
display: grid;
}
.${SCRIPT_ID}-auth-text {
align-items: center;
color: #aaa;
display: flex;
font-size: 12px;
min-height: 30px;
}
@media (max-width: 520px) {
.${SCRIPT_ID}-panel {
right: -72px;
width: min(360px, calc(100vw - 18px));
}
.${SCRIPT_ID}-auth {
grid-template-columns: 1fr;
}
}
`);
function gmGet(key, fallback) {
try {
const value = GM_getValue(key);
return value === undefined ? fallback : value;
} catch (_) {
return fallback;
}
}
function gmSet(key, value) {
try {
GM_setValue(key, value);
} catch (_) {
// Storage is optional; the script can still fetch without caching.
}
}
function getVideoID() {
try {
const url = new URL(location.href);
if (url.pathname.startsWith("/shorts/")) return url.pathname.split("/")[2] || "";
if (url.pathname.startsWith("/embed/")) return url.pathname.split("/")[2] || "";
return url.searchParams.get("v") || "";
} catch (_) {
return "";
}
}
function getVideoElement() {
const videos = Array.from(document.querySelectorAll("video"));
return videos.find((video) => Number.isFinite(video.duration) && video.duration > 0) || videos[0] || null;
}
function getControlsContainer() {
const settingsButton = document.querySelector(".html5-video-player .ytp-settings-button")
|| document.querySelector(".ytp-settings-button");
return settingsButton && settingsButton.parentElement
|| document.querySelector(".html5-video-player .ytp-right-controls-left")
|| document.querySelector(".ytp-right-controls-left")
|| document.querySelector(".html5-video-player .ytp-right-controls")
|| document.querySelector(".ytp-right-controls");
}
function mountToolbar() {
if (!state.toolbar) return false;
const controls = getControlsContainer();
if (!controls) return false;
if (state.toolbar.parentElement !== controls) {
const anchor = controls.querySelector(":scope > .ytp-settings-button")
|| controls.querySelector(":scope > .ytp-miniplayer-button")
|| controls.firstElementChild;
controls.insertBefore(state.toolbar, anchor);
}
return true;
}
function buildToolbar() {
if (state.toolbar) {
mountToolbar();
return;
}
const toolbar = document.createElement("div");
toolbar.className = `${SCRIPT_ID}-toolbar`;
const button = document.createElement("button");
button.className = `${SCRIPT_ID}-button`;
button.type = "button";
button.textContent = "TR";
button.title = "YouTube transcript";
const panel = document.createElement("div");
panel.className = `${SCRIPT_ID}-panel`;
panel.append(
createHeader(),
createStatus(),
createBody(),
createActions(),
createAuthForm()
);
button.addEventListener("click", () => {
const open = panel.classList.toggle(`${SCRIPT_ID}-open`);
if (open) void refreshTranscript(false);
});
document.addEventListener("click", (event) => {
if (!panel.classList.contains(`${SCRIPT_ID}-open`)) return;
if (toolbar.contains(event.target)) return;
panel.classList.remove(`${SCRIPT_ID}-open`);
}, true);
toolbar.append(button, panel);
state.toolbar = toolbar;
state.panel = panel;
mountToolbar();
}
function createHeader() {
const header = document.createElement("div");
header.className = `${SCRIPT_ID}-header`;
const title = document.createElement("div");
title.className = `${SCRIPT_ID}-title`;
title.textContent = "Transcript";
const signOut = document.createElement("button");
signOut.className = `${SCRIPT_ID}-small ${SCRIPT_ID}-signout`;
signOut.type = "button";
signOut.textContent = "Sign out";
signOut.style.display = isLoggedIn() ? "inline-flex" : "none";
signOut.addEventListener("click", () => {
clearAuth();
setStatus("Signed out.");
updatePanel();
});
header.append(title, signOut);
return header;
}
function createStatus() {
const status = document.createElement("div");
status.className = `${SCRIPT_ID}-status`;
state.statusNode = status;
return status;
}
function createBody() {
const body = document.createElement("div");
body.className = `${SCRIPT_ID}-body`;
return body;
}
function createActions() {
const actions = document.createElement("div");
actions.className = `${SCRIPT_ID}-actions`;
const left = document.createElement("div");
left.className = `${SCRIPT_ID}-left-actions`;
const toggle = document.createElement("label");
toggle.className = `${SCRIPT_ID}-toggle`;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = timestampsEnabled();
checkbox.addEventListener("change", () => {
gmSet("timestampsEnabled", checkbox.checked);
renderTranscript();
});
const labelText = document.createElement("span");
labelText.textContent = "Timestamps";
toggle.append(checkbox, labelText);
const reload = document.createElement("button");
reload.className = `${SCRIPT_ID}-small`;
reload.type = "button";
reload.textContent = "Reload";
reload.addEventListener("click", () => void refreshTranscript(true));
left.append(toggle, reload);
const right = document.createElement("div");
right.className = `${SCRIPT_ID}-right-actions`;
const copy = document.createElement("button");
copy.className = `${SCRIPT_ID}-primary`;
copy.type = "button";
copy.textContent = "Copy";
copy.addEventListener("click", () => void copyTranscript(copy));
const chatgpt = document.createElement("button");
chatgpt.className = `${SCRIPT_ID}-small`;
chatgpt.type = "button";
chatgpt.textContent = "ChatGPT";
chatgpt.title = "Copy transcript and open ChatGPT";
chatgpt.addEventListener("click", () => void openChatGPTWithTranscript(chatgpt));
right.append(chatgpt, copy);
actions.append(left, right);
return actions;
}
function createAuthForm() {
const form = document.createElement("form");
form.className = `${SCRIPT_ID}-auth`;
if (!isLoggedIn()) form.classList.add(`${SCRIPT_ID}-show`);
const text = document.createElement("div");
text.className = `${SCRIPT_ID}-auth-text`;
text.textContent = "Sign in with the YouTubeToTranscript email flow.";
const submit = document.createElement("button");
submit.className = `${SCRIPT_ID}-primary`;
submit.type = "button";
submit.textContent = "Login";
submit.addEventListener("click", () => {
openServiceLogin();
setStatus("Complete login in the YouTubeToTranscript window, then return here.");
});
form.append(text, submit);
stopYouTubeShortcutsInside(form);
return form;
}
function stopYouTubeShortcutsInside(element) {
const stop = (event) => {
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
};
for (const type of ["keydown", "keypress", "keyup"]) {
element.addEventListener(type, stop, true);
}
}
function updatePanel() {
if (!state.panel) return;
const signOut = state.panel.querySelector(`.${SCRIPT_ID}-signout`);
if (signOut) signOut.style.display = isLoggedIn() ? "inline-flex" : "none";
const form = state.panel.querySelector(`.${SCRIPT_ID}-auth`);
if (form) form.classList.toggle(`${SCRIPT_ID}-show`, !isLoggedIn());
renderTranscript();
}
function setStatus(message) {
if (state.statusNode) state.statusNode.textContent = message || "";
}
function timestampsEnabled() {
return gmGet("timestampsEnabled", true) !== false;
}
function isLoggedIn() {
const auth = gmGet("auth", null);
return !!(auth && auth.token && auth.refreshToken);
}
function storeAuth(payload) {
const expiresAt = payload.expires_at
? new Date(payload.expires_at).getTime()
: Date.now() + TOKEN_LIFETIME_MS;
gmSet("auth", {
token: payload.access_token || payload.token,
refreshToken: payload.refresh_token || payload.refreshToken,
expiresAt: Number.isFinite(expiresAt) ? expiresAt : Date.now() + TOKEN_LIFETIME_MS,
userEmail: payload.userEmail || ""
});
}
function clearAuth() {
gmSet("auth", null);
}
function openServiceLogin() {
const popup = window.open(
`${SERVICE_BASE}/auth/login?flow=extension`,
"yttTranscriptLogin",
"popup=yes,width=520,height=720"
);
if (popup && popup.focus) popup.focus();
}
async function authHeaders() {
const auth = gmGet("auth", null);
const headers = { "Content-Type": "application/json" };
if (!auth || !auth.token || !auth.refreshToken) return headers;
if (Number(auth.expiresAt) && Date.now() >= Number(auth.expiresAt) - 60000) {
try {
const refreshed = await requestJson(`${SERVICE_BASE}/auth/refresh`, {
method: "POST",
headers: { Authorization: `Bearer ${auth.refreshToken}` }
});
storeAuth({
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token || auth.refreshToken,
expires_at: refreshed.expires_at,
userEmail: auth.userEmail
});
headers.Authorization = `Bearer ${refreshed.access_token}`;
return headers;
} catch (error) {
if (String(errorMessage(error)).match(/401|unauthori[sz]ed|expired|invalid/i)) clearAuth();
}
}
headers.Authorization = `Bearer ${auth.token}`;
return headers;
}
function requestJson(url, options = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || "GET",
url,
headers: options.headers || {},
data: options.data,
timeout: options.timeout || 20000,
onload: (response) => {
const text = response.responseText || "";
let parsed = null;
if (text) {
try {
parsed = JSON.parse(text);
} catch (_) {
parsed = text;
}
}
if (response.status < 200 || response.status >= 300) {
const detail = describeErrorPayload(parsed) || text;
reject(new Error(`HTTP ${response.status}${detail ? `: ${detail}` : ""}`));
return;
}
resolve(parsed);
},
onerror: reject,
ontimeout: () => reject(new Error("Request timed out"))
});
});
}
function describeErrorPayload(payload) {
if (!payload || typeof payload !== "object") return "";
if (typeof payload.message === "string") return payload.message;
if (typeof payload.error === "string") return payload.error;
if (typeof payload.detail === "string") return payload.detail;
const detail = payload.detail;
if (Array.isArray(detail)) {
return detail
.map((item) => {
if (!item || typeof item !== "object") return String(item);
const loc = Array.isArray(item.loc) ? item.loc.join(".") : "";
const msg = item.msg || item.message || JSON.stringify(item);
return loc ? `${loc}: ${msg}` : msg;
})
.join("; ");
}
return JSON.stringify(payload);
}
async function refreshTranscript(force) {
const videoID = getVideoID();
if (!videoID) {
state.videoID = "";
state.transcript = null;
state.error = "Open a YouTube video to load a transcript.";
updatePanel();
return;
}
if (!force && state.videoID === videoID && (state.transcript || state.loading)) {
renderTranscript();
return;
}
state.videoID = videoID;
state.loading = true;
state.error = "";
state.transcript = null;
renderTranscript();
setStatus("Loading transcript...");
try {
const transcript = await getTranscript(videoID, force);
if (state.videoID !== videoID) return;
state.transcript = transcript;
state.error = transcript.length ? "" : "No transcript cues were found.";
setStatus(transcript.length ? `${transcript.length} transcript lines loaded.` : state.error);
} catch (error) {
state.error = errorMessage(error);
setStatus(state.error);
} finally {
if (state.videoID === videoID) {
state.loading = false;
updatePanel();
}
}
}
async function getTranscript(videoID, force) {
const cacheKey = `transcript:${videoID}`;
const cached = gmGet(cacheKey, null);
if (!force && cached && Date.now() - cached.time < CACHE_TTL_MS && Array.isArray(cached.transcript)) {
return cached.transcript;
}
const serviceTranscript = await getServiceTranscript(videoID);
gmSet(cacheKey, { time: Date.now(), transcript: serviceTranscript });
return serviceTranscript;
}
async function getServiceTranscript(videoID) {
const headers = await authHeaders();
if (!headers.Authorization) {
throw new Error("Login is required to fetch transcripts from YouTubeToTranscript.");
}
const data = await requestJson(`${SERVICE_BASE}/api/transcript?video_id=${encodeURIComponent(videoID)}`, {
headers
});
return normalizeTranscript(data);
}
function normalizeTranscript(data) {
const raw = Array.isArray(data)
? data
: data && Array.isArray(data.transcript)
? data.transcript
: data && Array.isArray(data.data)
? data.data
: data && Array.isArray(data.items)
? data.items
: [];
if (typeof data === "string") {
return splitPlainTranscript(data);
}
return raw
.map((item) => {
if (typeof item === "string") {
const text = cleanCueText(item);
return text ? { start: 0, duration: 0, text } : null;
}
const start = Number(item.start ?? item.startTime ?? item.offset ?? item.time ?? 0);
const duration = Number(item.duration ?? item.dur ?? 0);
const text = cleanCueText(String(item.text ?? item.content ?? item.caption ?? ""));
if (!text) return null;
return {
start: Number.isFinite(start) ? start : 0,
duration: Number.isFinite(duration) ? duration : 0,
text
};
})
.filter(Boolean);
}
function cleanCueText(text) {
return decodeHtmlEntities(String(text || ""))
.replace(/<br\s*\/?>/gi, " ")
.replace(/<\/?[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function decodeHtmlEntities(text) {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, "\"")
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(parseInt(code, 16)));
}
function splitPlainTranscript(text) {
const lines = String(text || "")
.split(/\r?\n+/)
.map(cleanCueText)
.filter(Boolean);
if (lines.length) return lines.map((line) => ({ start: 0, duration: 0, text: line }));
const cleaned = cleanCueText(text);
return cleaned ? [{ start: 0, duration: 0, text: cleaned }] : [];
}
function renderTranscript() {
if (!state.panel) return;
const body = state.panel.querySelector(`.${SCRIPT_ID}-body`);
if (!body) return;
body.textContent = "";
if (state.loading) {
const loading = document.createElement("div");
loading.className = `${SCRIPT_ID}-empty`;
loading.textContent = "Loading transcript...";
body.appendChild(loading);
return;
}
if (state.error && !state.transcript) {
const error = document.createElement("div");
error.className = `${SCRIPT_ID}-empty`;
error.textContent = state.error;
body.appendChild(error);
return;
}
if (!state.transcript || !state.transcript.length) {
const empty = document.createElement("div");
empty.className = `${SCRIPT_ID}-empty`;
empty.textContent = "Open a YouTube video, then click Reload.";
body.appendChild(empty);
return;
}
const showTimestamps = timestampsEnabled();
for (const cue of state.transcript) {
const row = document.createElement("div");
row.className = `${SCRIPT_ID}-cue`;
const time = document.createElement("button");
time.className = `${SCRIPT_ID}-time`;
time.type = "button";
time.textContent = formatTime(cue.start);
time.title = "Seek to this timestamp";
time.addEventListener("click", () => seekTo(cue.start));
const text = document.createElement("div");
text.className = `${SCRIPT_ID}-text`;
text.textContent = cue.text;
if (showTimestamps) {
row.append(time, text);
} else {
row.style.gridTemplateColumns = "1fr";
row.append(text);
}
body.appendChild(row);
}
}
async function copyTranscript(button) {
if (!state.transcript || !state.transcript.length) {
setStatus("No transcript to copy.");
return;
}
const text = transcriptToText(state.transcript, timestampsEnabled());
const original = button.textContent;
button.textContent = "Copying...";
try {
await navigator.clipboard.writeText(text);
button.textContent = "Copied";
setStatus("Copied to clipboard.");
} catch (_) {
fallbackCopy(text);
button.textContent = "Copied";
setStatus("Copied to clipboard.");
} finally {
setTimeout(() => {
button.textContent = original;
}, 1600);
}
}
async function openChatGPTWithTranscript(button) {
if (!state.transcript || !state.transcript.length) {
setStatus("No transcript to send to ChatGPT.");
return;
}
const original = button.textContent;
button.textContent = "Copying...";
const chatWindow = window.open("https://chatgpt.com/", "_blank");
try {
await copyTranscriptText(transcriptToText(state.transcript, timestampsEnabled()));
if (chatWindow && chatWindow.focus) chatWindow.focus();
setStatus("Transcript copied. ChatGPT opened.");
} catch (_) {
setStatus("Could not copy transcript for ChatGPT.");
} finally {
button.textContent = "Opened";
setTimeout(() => {
button.textContent = original;
}, 1600);
}
}
async function copyTranscriptText(text) {
try {
await navigator.clipboard.writeText(text);
} catch (_) {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.documentElement.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand("copy");
textarea.remove();
}
function transcriptToText(transcript, includeTimestamps) {
if (includeTimestamps) {
return transcript.map((cue) => `[${formatTime(cue.start)}] ${cue.text}`).join("\n");
}
return transcript.map((cue) => cue.text).join(" ").replace(/\s+/g, " ").trim();
}
function formatTime(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 remainingSeconds = String(safeSeconds % 60).padStart(2, "0");
if (hours) return `${hours}:${String(minutes).padStart(2, "0")}:${remainingSeconds}`;
return `${minutes}:${remainingSeconds}`;
}
function seekTo(seconds) {
const video = getVideoElement();
if (!video) return;
video.currentTime = Math.max(0, Number(seconds) || 0);
video.play().catch(() => {});
}
function errorMessage(error) {
return error && error.message ? error.message : String(error || "Unknown error");
}
function updateForCurrentVideo() {
buildToolbar();
const videoID = getVideoID();
if (videoID && videoID !== state.videoID) {
state.videoID = videoID;
state.transcript = null;
state.error = "";
state.loading = false;
if (state.panel && state.panel.classList.contains(`${SCRIPT_ID}-open`)) {
void refreshTranscript(false);
} else {
updatePanel();
}
}
}
function scheduleUpdate() {
if (state.mutationTimer) return;
state.mutationTimer = window.setTimeout(() => {
state.mutationTimer = 0;
updateForCurrentVideo();
}, 250);
}
function init() {
if (location.hostname === "youtubetotranscript.com") {
initServiceAuthBridge();
return;
}
updateForCurrentVideo();
window.addEventListener("yt-navigate-finish", scheduleUpdate);
window.addEventListener("popstate", scheduleUpdate);
window.addEventListener("resize", () => mountToolbar());
const observer = new MutationObserver(scheduleUpdate);
observer.observe(document.documentElement, { childList: true, subtree: true });
window.setInterval(updateForCurrentVideo, 2000);
}
function initServiceAuthBridge() {
injectServicePageBridge();
window.addEventListener("message", (event) => {
if (event.origin !== SERVICE_BASE) return;
const data = event.data || {};
if (data.type === "EXTENSION_REQUEST_TOKENS") {
const auth = gmGet("auth", null) || {};
window.postMessage({
type: "EXTENSION_TOKENS_FROM_USERSCRIPT",
token: auth.token || null,
refresh_token: auth.refreshToken || null,
isLoggedIn: !!(auth.token && auth.refreshToken),
expiresAt: auth.expiresAt || null
}, SERVICE_BASE);
return;
}
if (data.type !== "EXTENSION_AUTH_SUCCESS") return;
const token = data.token || data.access_token;
const refreshToken = data.refresh_token || data.refreshToken;
if (!token || !refreshToken) return;
storeAuth({
access_token: token,
refresh_token: refreshToken,
expires_at: data.expiresAt || Date.now() + TOKEN_LIFETIME_MS
});
showServiceAuthNotice("Login saved. You can return to YouTube now.");
});
window.__sendTokensToExtension = (token, refreshToken) => {
if (!token || !refreshToken) return;
storeAuth({
access_token: token,
refresh_token: refreshToken,
expires_at: Date.now() + TOKEN_LIFETIME_MS
});
showServiceAuthNotice("Login saved. You can return to YouTube now.");
};
}
function injectServicePageBridge() {
const script = document.createElement("script");
script.textContent = `
(function () {
if (window.__yttTranscriptUserscriptBridgeInstalled) return;
window.__yttTranscriptUserscriptBridgeInstalled = true;
window.__sendTokensToExtension = function (token, refreshToken) {
window.postMessage({
type: "EXTENSION_AUTH_SUCCESS",
token: token,
refresh_token: refreshToken
}, window.location.origin);
};
window.addEventListener("message", function (event) {
if (event.origin !== window.location.origin) return;
if (!event.data || event.data.type !== "EXTENSION_TOKENS_FROM_USERSCRIPT") return;
window.postMessage({
type: "EXTENSION_TOKENS",
token: event.data.token || null,
refresh_token: event.data.refresh_token || null,
isLoggedIn: !!event.data.isLoggedIn,
expiresAt: event.data.expiresAt || null
}, window.location.origin);
});
})();
`;
(document.head || document.documentElement).appendChild(script);
script.remove();
}
function showServiceAuthNotice(message) {
let notice = document.getElementById(`${SCRIPT_ID}-service-notice`);
if (!notice) {
notice = document.createElement("div");
notice.id = `${SCRIPT_ID}-service-notice`;
notice.style.cssText = [
"position: fixed",
"left: 50%",
"bottom: 24px",
"transform: translateX(-50%)",
"z-index: 2147483647",
"background: #0f0f0f",
"color: #fff",
"border: 1px solid #3f3f3f",
"border-radius: 12px",
"box-shadow: 0 12px 36px rgba(0,0,0,.45)",
"font: 14px Arial, sans-serif",
"padding: 12px 16px"
].join(";");
document.documentElement.appendChild(notice);
}
notice.textContent = message;
}
init();
})();