Skip to content

Instantly share code, notes, and snippets.

@Moelf
Created September 8, 2026 04:20
Show Gist options
  • Select an option

  • Save Moelf/ffb74c2270a792fd872f6b374364d67a to your computer and use it in GitHub Desktop.

Select an option

Save Moelf/ffb74c2270a792fd872f6b374364d67a to your computer and use it in GitHub Desktop.
Firefox Jellyfin web client screenshot to clipboard Templermonkey script
// ==UserScript==
// @name Jellyfin VideoOSD Screenshot Button (clipboard)
// @namespace https://github.com/chrissix666/Jellyfin-VideoOSD-Screenshot-Button
// @version 2.0.0
// @description Screenshot button and C hotkey for the Jellyfin Web video OSD. Copies the frame to the clipboard and downloads it.
// @author chrissix666, Jerry Ling
// @homepageURL https://github.com/chrissix666/Jellyfin-VideoOSD-Screenshot-Button
// @license MIT
// @match *://*/web/*
// @match *://*/web/index.html*
// @grant none
// @run-at document-idle
// @noframes
// ==/UserScript==
//
// Narrow the @match lines to your own server, e.g.
// // @match https://jellyfin.example.com/web/*
// The JavaScript Injector plugin ignores this metadata block.
//
// Jellyfin VideoOSD Screenshot Button -- v2 "clipboard" variant
//
// Differences from v1:
// * Copies every single-shot screenshot to the system clipboard
// (navigator.clipboard.write with a PNG ClipboardItem), optionally in
// addition to downloading the file. CONFIG.copyToClipboard /
// CONFIG.saveFile control this.
// * Adds a keyboard shortcut (default: C) that takes a screenshot while
// the video OSD page is active. CONFIG.hotkey / CONFIG.hotkeyEnabled.
// * Shows a small toast ("Copied to clipboard", or the failure reason)
// so the hotkey gives visible feedback. CONFIG.showToast.
// * Rapid-fire (hold click) and auto mode (double click) are removed.
// One click, or one keypress, equals one screenshot.
//
// Firefox notes:
// * Clipboard image writes need Firefox 127+ (ClipboardItem enabled by
// default). On 87..126 flip dom.events.asyncClipboard.clipboardItem in
// about:config. Older builds fall back to download-only with a warning.
// * Like Chrome, Firefox only accepts image/png on the clipboard, so the
// clipboard copy is always PNG even when CONFIG.fileFormat is 'jpg'.
// * navigator.clipboard exists only in a secure context (HTTPS or
// localhost). On plain http://<lan-ip>:8096 the copy is skipped and a
// toast tells you why; the download still happens.
// * The write must happen while "transient user activation" from the
// click / keypress is still valid (about 5s in both browsers). The
// clipboard write is therefore issued straight after the frame is
// captured and never waits for the filename API lookup.
(function () {
'use strict';
// Kept inside the IIFE: as a top-level declaration it was an implicit
// global, which ESLint flags (no-implicit-globals / no-unused-vars).
const isSupportedPlatform = () => {
const ua = navigator.userAgent.toLowerCase();
const isMobile = ['mobi', 'ipad', 'iphone', 'ipod', 'silk', 'opera mini'].some((term) => ua.includes(term));
const isTv = ['tv', 'samsungbrowser', 'viera', 'web0s'].some((term) => ua.includes(term));
const isTizen = ua.includes('tizen') || window.tizen != null;
const isAndroid = ua.includes('android');
const isIOS = ['ipad', 'iphone', 'ipod'].some((term) => ua.includes(term)) || (ua.includes('macintosh') && navigator.maxTouchPoints > 1);
return !(isMobile || isTv || isTizen || isAndroid || isIOS);
};
if (!isSupportedPlatform()) return;
// ---- PLUGIN ADAPTER: config source, retrofit for VideoOSD Tweaks and Candy ----
const PLUGIN_GUID = '468b1980-7a6c-4e45-a129-24825085ece4';
const CONFIG = {
hideOnNarrowWindow: true,
// 'png' | 'jpg'. Applies to the downloaded file only; the
// clipboard copy is always PNG (browser limitation).
fileFormat: 'png',
// 'original' | 'library'.
filenameSource: 'library',
includeYearMovies: true,
includeYearEpisodes: false,
includeYearVideos: false,
// One of: 'screenshot_timestamp_label', 'screenshot_timestamp',
// 'screenshot_label', 'timestamp_label', 'screenshot_only',
// 'timestamp_only', 'label_only'.
filenamePattern: 'screenshot_timestamp_label',
// ---- NEW in v2 ----
// Copy screenshots (button click or hotkey) to the system clipboard.
copyToClipboard: true,
// Also download the image file. Set to false for clipboard-only.
saveFile: true,
// Keyboard shortcut. Compared case-insensitively against
// KeyboardEvent.key, so 'c' matches both c and C. Ignored while a
// text field is focused or while Ctrl/Alt/Meta is held, and not
// repeated while the key is held down.
hotkeyEnabled: true,
hotkey: 'c',
// Brief on-screen confirmation after a single shot.
showToast: true
};
async function fetchPluginConfig() {
const maxAttempts = 120;
const delayMs = 250;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (window.ApiClient && typeof ApiClient.getPluginConfiguration === 'function') {
try {
const config = await ApiClient.getPluginConfiguration(PLUGIN_GUID);
if (config) return config;
} catch {
// fall through, try again after the delay below
}
}
await new Promise(function (resolve) { setTimeout(resolve, delayMs); });
}
return null;
}
function applyPluginConfig(pluginConfig) {
if (!pluginConfig) return;
if (typeof pluginConfig.ScreenshotHideOnNarrowWindow === 'boolean') {
CONFIG.hideOnNarrowWindow = pluginConfig.ScreenshotHideOnNarrowWindow;
}
if (typeof pluginConfig.ScreenshotFileFormat === 'string' && pluginConfig.ScreenshotFileFormat) {
CONFIG.fileFormat = pluginConfig.ScreenshotFileFormat;
}
if (typeof pluginConfig.ScreenshotFilenameSource === 'string' && pluginConfig.ScreenshotFilenameSource) {
CONFIG.filenameSource = pluginConfig.ScreenshotFilenameSource;
}
if (typeof pluginConfig.ScreenshotIncludeYearMovies === 'boolean') {
CONFIG.includeYearMovies = pluginConfig.ScreenshotIncludeYearMovies;
}
if (typeof pluginConfig.ScreenshotIncludeYearEpisodes === 'boolean') {
CONFIG.includeYearEpisodes = pluginConfig.ScreenshotIncludeYearEpisodes;
}
if (typeof pluginConfig.ScreenshotIncludeYearVideos === 'boolean') {
CONFIG.includeYearVideos = pluginConfig.ScreenshotIncludeYearVideos;
}
if (typeof pluginConfig.ScreenshotFilenamePattern === 'string' && pluginConfig.ScreenshotFilenamePattern) {
CONFIG.filenamePattern = pluginConfig.ScreenshotFilenamePattern;
}
// v2 fields. The upstream plugin does not expose these (yet); the
// guards mean they are simply ignored until it does.
if (typeof pluginConfig.ScreenshotCopyToClipboard === 'boolean') {
CONFIG.copyToClipboard = pluginConfig.ScreenshotCopyToClipboard;
}
if (typeof pluginConfig.ScreenshotSaveFile === 'boolean') {
CONFIG.saveFile = pluginConfig.ScreenshotSaveFile;
}
if (typeof pluginConfig.ScreenshotHotkeyEnabled === 'boolean') {
CONFIG.hotkeyEnabled = pluginConfig.ScreenshotHotkeyEnabled;
}
if (typeof pluginConfig.ScreenshotHotkey === 'string' && pluginConfig.ScreenshotHotkey.length === 1) {
CONFIG.hotkey = pluginConfig.ScreenshotHotkey;
}
if (typeof pluginConfig.ScreenshotShowToast === 'boolean') {
CONFIG.showToast = pluginConfig.ScreenshotShowToast;
}
}
// ---- END PLUGIN ADAPTER ----
const ADDON_ID = 'screenshotButton';
const ADDON_NAME = 'Screenshot Button';
const RESPONSIVE_STYLE_ID = 'screenshotButtonResponsiveStyle';
const LOG_PREFIX = '[VideoOSD Screenshot Button v2]';
const CUSTOMS_API_NAME = 'JellyfinVideoOSDCustomsMenu';
const CUSTOMS_WAIT_MS = 300;
const CUSTOMS_WAIT_TRIES = 120;
const CUSTOMS_STORAGE_KEY =
CUSTOMS_API_NAME + '.addon.' + ADDON_ID;
let btn = null;
let observer = null;
let pollInterval = null;
let enabled = false;
let hotkeyListenerAttached = false;
let registeredWithCustoms = false;
let customsRegisterTimer = null;
let ignoreStoredCustomsState = false;
let toastEl = null;
let toastTimer = null;
const isCustomsAvailable = () => {
const api = window[CUSTOMS_API_NAME];
return !!api && typeof api.registerAddon === 'function';
};
const isEnabledByCustomsState = () =>
localStorage.getItem(CUSTOMS_STORAGE_KEY) !== 'false';
const refreshResponsiveStyle = () => {
const existing = document.getElementById(RESPONSIVE_STYLE_ID);
if (!CONFIG.hideOnNarrowWindow) {
if (existing) existing.remove();
return;
}
if (existing) return;
const style = document.createElement('style');
style.id = RESPONSIVE_STYLE_ID;
style.textContent = `
@media all and (max-width: 50em) {
.videoOsdBottom .btnScreenshot { display: none !important; }
}
`;
document.head.appendChild(style);
};
const ensureStyles = () => {
if (document.getElementById('auto-screenshot-style')) return;
const style = document.createElement('style');
style.id = 'auto-screenshot-style';
style.textContent = `
@keyframes autoscreenshot-wiggle {
0% { transform: rotate(0deg); }
20% { transform: rotate(-6deg); }
40% { transform: rotate(6deg); }
60% { transform: rotate(-4deg); }
80% { transform: rotate(4deg); }
100% { transform: rotate(0deg); }
}
.auto-screenshot-click {
animation: autoscreenshot-wiggle 0.6s ease-in-out;
}
.screenshot-toast {
position: fixed;
left: 50%;
bottom: 12vh;
transform: translateX(-50%) translateY(0.5em);
padding: 0.55em 1em;
border-radius: 0.4em;
background: rgba(0, 0, 0, 0.78);
color: #fff;
font-size: 1.05em;
font-family: inherit;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease;
z-index: 100000;
white-space: nowrap;
}
.screenshot-toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.screenshot-toast.error {
background: rgba(140, 30, 30, 0.9);
}
`;
document.head.appendChild(style);
};
// NEW: minimal toast so the hotkey has visible feedback. Jellyfin's own
// toast module is not reachable from an injected script, so this is a
// self-contained element.
const showToast = (message, isError) => {
if (!CONFIG.showToast) return;
ensureStyles();
if (!toastEl || !toastEl.isConnected) {
toastEl = document.createElement('div');
toastEl.className = 'screenshot-toast';
document.body.appendChild(toastEl);
}
toastEl.textContent = message;
toastEl.classList.toggle('error', !!isError);
// restart the transition even if a toast is already visible
toastEl.classList.remove('visible');
void toastEl.offsetWidth;
toastEl.classList.add('visible');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastTimer = null;
if (toastEl) toastEl.classList.remove('visible');
}, isError ? 3500 : 1500);
};
const removeToast = () => {
if (toastTimer) {
clearTimeout(toastTimer);
toastTimer = null;
}
if (toastEl) {
toastEl.remove();
toastEl = null;
}
};
const sanitize = str =>
str.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim();
const ICON_ATTR = 'data-screenshot-icon';
const getIcon = () => btn?.querySelector('[' + ICON_ATTR + ']');
// Material Icons "photo_camera" (Apache 2.0), 24x24 viewBox.
const CAMERA_SVG_PATH =
'M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6' +
'c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 ' +
'2.24 5 5-2.24 5-5 5z';
const CAMERA_SVG_CIRCLE = { cx: 12, cy: 12, r: 3.2 };
// The v1 icon was a Material Symbols ligature span, which renders as
// the literal text "photo_camera" on any Jellyfin Web build that does
// not load that font (12.0 does not). An inline SVG has no font
// dependency, so it renders identically on every release. It is sized
// from the neighbouring rating-button icon so it matches whatever
// icon size the current Jellyfin theme uses.
const buildIcon = referenceBtn => {
const icon = document.createElement('span');
icon.className = 'xlargePaperIconButton';
icon.setAttribute(ICON_ATTR, '');
icon.setAttribute('aria-hidden', 'true');
icon.style.display = 'inline-flex';
icon.style.alignItems = 'center';
icon.style.justifyContent = 'center';
icon.style.lineHeight = '1';
const ref = referenceBtn?.querySelector('span, svg');
if (ref) {
const cs = window.getComputedStyle(ref);
// Font icons size by font-size; SVG icons by width/height.
const size = ref.tagName.toLowerCase() === 'svg'
? cs.width
: cs.fontSize;
if (size && size !== '0px') icon.style.fontSize = size;
}
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '1em');
svg.setAttribute('height', '1em');
svg.setAttribute('fill', 'currentColor');
svg.setAttribute('focusable', 'false');
const circle = document.createElementNS(ns, 'circle');
circle.setAttribute('cx', String(CAMERA_SVG_CIRCLE.cx));
circle.setAttribute('cy', String(CAMERA_SVG_CIRCLE.cy));
circle.setAttribute('r', String(CAMERA_SVG_CIRCLE.r));
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', CAMERA_SVG_PATH);
svg.appendChild(circle);
svg.appendChild(path);
icon.appendChild(svg);
return icon;
};
const animateSingleShot = () => {
const icon = getIcon();
if (!icon) return;
icon.classList.remove('auto-screenshot-click');
void icon.offsetWidth;
icon.classList.add('auto-screenshot-click');
};
const getNowPlayingItemInfo = async () => {
if (!window.ApiClient) return null;
try {
const ratingBtn = document.querySelector('#videoOsdPage:not(.hide) .btnUserRating');
const id = ratingBtn?.dataset?.id;
if (!id) return null;
const userId = ApiClient.getCurrentUserId();
const item = await ApiClient.getItem(userId, id);
if (!item) return null;
let kind = 'video';
if (item.Type === 'Movie') kind = 'movie';
else if (item.Type === 'Episode') kind = 'episode';
let originalFilename = null;
if (item.Path) {
originalFilename = item.Path.split(/[\\/]/).pop().replace(/\.[^/.]+$/, '');
}
return {
kind: kind,
originalFilename: originalFilename,
name: item.Name || null,
seriesName: item.SeriesName || null,
seasonNumber: item.ParentIndexNumber || null,
episodeNumber: item.IndexNumber || null,
productionYear: item.ProductionYear || null
};
} catch {
return null;
}
};
const getVideoLabel = async () => {
const info = await getNowPlayingItemInfo();
if (CONFIG.filenameSource === 'original') {
if (info?.originalFilename) {
return ` - ${sanitize(info.originalFilename)}`;
}
// Original filename unavailable -- fall through to library-name approach.
}
// Library name: try API first, fall back to DOM parsing
if (info?.name) {
const kind = info.kind || 'video';
const includeYear = kind === 'movie' ? CONFIG.includeYearMovies
: kind === 'episode' ? CONFIG.includeYearEpisodes
: CONFIG.includeYearVideos;
let label;
if (kind === 'episode' && info.seriesName) {
const s = String(info.seasonNumber || 1).padStart(2, '0');
const e = String(info.episodeNumber || 1).padStart(2, '0');
label = `${info.seriesName} - S${s}E${e} - ${info.name}`;
} else {
label = info.name;
}
if (includeYear && info.productionYear) {
label += ` (${info.productionYear})`;
}
label = label.replace(/\s*:\s*/g, ' - ');
return ` - ${sanitize(label)}`;
}
// DOM fallback (API unavailable)
const pageTitleEl = document.querySelector('h3.pageTitle') ||
Array.from(document.querySelectorAll('[aria-hidden="true"]'))
.find(el => el.className.toLowerCase().includes('pagetitle'));
if (!pageTitleEl) return '';
let text = pageTitleEl.textContent.trim();
const kind = info?.kind || 'video';
const includeYear = kind === 'movie' ? CONFIG.includeYearMovies
: kind === 'episode' ? CONFIG.includeYearEpisodes
: CONFIG.includeYearVideos;
const episodeMatch = text.match(/^(.*?)-\s*S(\d+)[\:-]?E(\d+)\s*-\s*(.*?)(?:\s*\(\d{4}\))?$/i);
if (episodeMatch) {
let showTitle = episodeMatch[1].trim();
showTitle = showTitle.replace(/\s*:\s*/g, ' - ');
const season = episodeMatch[2].padStart(2, '0');
const episode = episodeMatch[3].padStart(2, '0');
const episodeName = episodeMatch[4].trim();
const yearMatch = text.match(/\((\d{4})\)\s*$/);
const yearSuffix = includeYear && yearMatch ? ` (${yearMatch[1]})` : '';
return ` - ${showTitle} - S${season}E${episode} - ${episodeName}${yearSuffix}`;
}
if (!includeYear) {
text = text.replace(/\s*\(\d{4}\)/, '');
}
text = text.replace(/\s*:\s*/g, ' - ');
return ` - ${sanitize(text)}`;
};
const buildTimestampPart = () => {
const now = new Date();
const date = now.toISOString().slice(0, 10);
const time =
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
return `${date} ${time}`;
};
const buildFilename = (labelPart, extension) => {
const timestamp = buildTimestampPart();
const screenshotText = 'Screenshot';
const cleanLabel = labelPart.replace(/^\s*-\s*/, '');
let base;
switch (CONFIG.filenamePattern) {
case 'screenshot_timestamp':
base = `${screenshotText} ${timestamp}`;
break;
case 'screenshot_label':
base = `${screenshotText}${labelPart}`;
break;
case 'timestamp_label':
base = `${timestamp}${labelPart}`;
break;
case 'screenshot_only':
base = screenshotText;
break;
case 'timestamp_only':
base = timestamp;
break;
case 'label_only':
base = cleanLabel;
break;
case 'screenshot_timestamp_label':
default:
base = `${screenshotText} ${timestamp}${labelPart}`;
break;
}
return `${base}.${extension}`;
};
// NEW: PNG blob from the canvas. toBlob is async in every browser but
// resolves within a few ms, well inside the user-activation window.
const canvasToPngBlob = canvas => new Promise((resolve, reject) => {
try {
canvas.toBlob(blob => {
if (blob) resolve(blob);
else reject(new Error('canvas.toBlob returned null'));
}, 'image/png');
} catch (err) {
reject(err);
}
});
// NEW: explains *why* the clipboard is unavailable instead of failing
// silently. Returns null when everything looks usable.
const clipboardUnavailableReason = () => {
if (!window.isSecureContext) {
return 'clipboard needs HTTPS (or localhost)';
}
if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function') {
return 'browser has no async clipboard API';
}
if (typeof ClipboardItem === 'undefined') {
// Firefox 87..126: dom.events.asyncClipboard.clipboardItem = true
return 'ClipboardItem unsupported (Firefox < 127: enable dom.events.asyncClipboard.clipboardItem)';
}
return null;
};
// NEW: copies the captured frame as PNG. Always PNG regardless of
// CONFIG.fileFormat because Chrome and Firefox both reject image/jpeg
// on the clipboard.
const copyCanvasToClipboard = async canvas => {
const reason = clipboardUnavailableReason();
if (reason) throw new Error(reason);
const blob = await canvasToPngBlob(canvas);
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
};
const downloadCanvas = canvas => {
const isJpg = CONFIG.fileFormat === 'jpg';
const mimeType = isJpg ? 'image/jpeg' : 'image/png';
const dataUrl = isJpg ? canvas.toDataURL(mimeType, 0.92) : canvas.toDataURL(mimeType);
const extension = isJpg ? 'jpg' : 'png';
return getVideoLabel().then(label => {
const link = document.createElement('a');
link.download = buildFilename(label, extension);
link.href = dataUrl;
// Firefox ignores click() on an element that is not in the
// document in some versions; attaching it briefly is harmless.
document.body.appendChild(link);
link.click();
link.remove();
});
};
// Pixel capture happens synchronously first so neither the clipboard
// nor the filename lookup can shift which frame is saved. The
// clipboard write is kicked off immediately and independently of the
// (network-bound) filename lookup so it stays inside the
// transient-activation window.
const takeScreenshot = () => {
const video = document.querySelector('video');
if (!video || video.videoWidth === 0) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const wantCopy = CONFIG.copyToClipboard;
const wantSave = CONFIG.saveFile;
animateSingleShot();
if (wantCopy) {
copyCanvasToClipboard(canvas).then(() => {
showToast(wantSave ? 'Screenshot copied and saved' : 'Screenshot copied to clipboard');
}).catch(err => {
console.warn(LOG_PREFIX, 'clipboard copy failed:', err);
const msg = err && err.message ? err.message : String(err);
showToast('Clipboard copy failed: ' + msg, true);
});
} else if (wantSave) {
showToast('Screenshot saved');
}
if (wantSave) {
downloadCanvas(canvas).catch(function (err) {
console.error(LOG_PREFIX, 'takeScreenshot download failed:', err);
});
}
};
const ensureBtn = referenceBtn => {
if (!btn) {
ensureStyles();
btn = document.createElement('button');
btn.className = 'btnScreenshot autoSize paper-icon-button-light';
btn.title = CONFIG.hotkeyEnabled
? `Screenshot (${CONFIG.hotkey.toUpperCase()})`
: 'Screenshot';
btn.appendChild(buildIcon(referenceBtn));
// 'click' (not mousedown) so a press that is dragged off the
// button does nothing, matching normal button semantics. A
// click is a user-activation event, so the clipboard write
// is permitted from here.
btn.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
takeScreenshot();
});
}
return btn;
};
const removeButton = () => {
if (btn) {
btn.remove();
btn = null;
}
};
// NEW: hotkey handling.
const isTextInputTarget = target => {
if (!target || !(target instanceof Element)) return false;
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
return target.isContentEditable === true;
};
const isVideoOsdActive = () =>
!!document.querySelector('#videoOsdPage:not(.hide)');
const onKeyDown = event => {
if (!enabled || !CONFIG.hotkeyEnabled) return;
if (event.repeat) return;
if (event.ctrlKey || event.altKey || event.metaKey) return;
if (typeof event.key !== 'string') return;
if (event.key.toLowerCase() !== String(CONFIG.hotkey).toLowerCase()) return;
if (isTextInputTarget(event.target)) return;
if (!isVideoOsdActive()) return;
const video = document.querySelector('video');
if (!video || video.videoWidth === 0) return;
// Keep Jellyfin's own key handling from also acting on this key.
event.preventDefault();
event.stopPropagation();
takeScreenshot();
};
const attachHotkey = () => {
if (hotkeyListenerAttached) return;
// Capture phase so we see the key before Jellyfin's inputManager.
document.addEventListener('keydown', onKeyDown, true);
hotkeyListenerAttached = true;
};
const detachHotkey = () => {
if (!hotkeyListenerAttached) return;
document.removeEventListener('keydown', onKeyDown, true);
hotkeyListenerAttached = false;
};
const injectButton = () => {
if (!enabled) return false;
const favBtn = document.querySelector('.buttons.focuscontainer-x > .btnUserRating');
if (!favBtn || !favBtn.parentNode) return false;
const container = favBtn.parentNode;
if (!container.querySelector('.btnScreenshot')) {
refreshResponsiveStyle();
const newBtn = ensureBtn(favBtn);
container.insertBefore(newBtn, favBtn);
}
return true;
};
const enable = () => {
if (enabled) return;
enabled = true;
let debounceTimer = null;
observer = new MutationObserver(() => {
if (debounceTimer) return;
debounceTimer = setTimeout(() => {
debounceTimer = null;
injectButton();
}, 100);
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
pollInterval = setInterval(() => {
if (injectButton()) {
clearInterval(pollInterval);
pollInterval = null;
}
}, 300);
injectButton();
attachHotkey();
};
const disable = () => {
if (!enabled) return;
enabled = false;
detachHotkey();
removeToast();
if (observer) {
observer.disconnect();
observer = null;
}
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
removeButton();
};
const tryRegisterWithCustoms = () => {
if (registeredWithCustoms) return false;
const api = window[CUSTOMS_API_NAME];
if (!api || typeof api.registerAddon !== 'function') {
return false;
}
registeredWithCustoms = true;
if (localStorage.getItem(CUSTOMS_STORAGE_KEY) === null) {
localStorage.setItem(CUSTOMS_STORAGE_KEY, 'true');
}
api.registerAddon({
id: ADDON_ID,
name: ADDON_NAME,
enable() {
ignoreStoredCustomsState = false;
enable();
},
disable() {
ignoreStoredCustomsState = false;
disable();
}
});
if (!ignoreStoredCustomsState) {
if (isEnabledByCustomsState()) {
enable();
} else {
disable();
}
} else {
enable();
}
return true;
};
const startCustomsRegistrationWatcher = () => {
tryRegisterWithCustoms();
if (registeredWithCustoms) return;
let tries = 0;
customsRegisterTimer = setInterval(() => {
tries += 1;
tryRegisterWithCustoms();
if (registeredWithCustoms || tries >= CUSTOMS_WAIT_TRIES) {
clearInterval(customsRegisterTimer);
customsRegisterTimer = null;
}
}, CUSTOMS_WAIT_MS);
};
const start = () => {
if (isCustomsAvailable()) {
ignoreStoredCustomsState = false;
tryRegisterWithCustoms();
} else {
ignoreStoredCustomsState = true;
enable();
}
startCustomsRegistrationWatcher();
const clipboardNote = clipboardUnavailableReason();
if (CONFIG.copyToClipboard && clipboardNote) {
console.warn(LOG_PREFIX, 'clipboard copy will be skipped:', clipboardNote);
}
console.log(LOG_PREFIX, 'Script loaded. Hotkey:', CONFIG.hotkeyEnabled ? CONFIG.hotkey.toUpperCase() : 'disabled');
};
if (document.documentElement) {
start();
} else {
document.addEventListener('DOMContentLoaded', start, {
once: true
});
}
// ---- PLUGIN ADAPTER: apply fetched config once it arrives ----
fetchPluginConfig().then(function (pluginConfig) {
applyPluginConfig(pluginConfig);
refreshResponsiveStyle();
if (btn) {
btn.title = CONFIG.hotkeyEnabled
? `Screenshot (${CONFIG.hotkey.toUpperCase()})`
: 'Screenshot';
}
}).catch(function (err) {
console.error(LOG_PREFIX, 'config apply failed:', err);
});
// ---- END PLUGIN ADAPTER ----
})();
@chrissix666

Copy link
Copy Markdown

Credited in the original README. Thanks for the extension and for making it compatible with Firefox!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment