|
// ==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 ---- |
|
})(); |
Credited in the original README. Thanks for the extension and for making it compatible with Firefox!