function dlIsSupportedPlatform() { 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); } function dlMain() { 'use strict'; // ---- PLUGIN ADAPTER: config source for VideoOSD Tweaks and Candy ---- const CONFIG = { // ============================================================ // == SHARED VALUES (both standalone and plugin usage) == // Hand-edit these for standalone use; the plugin overwrites // them via applyPluginConfig() once fetched. No separate // standalone branch needed, same as the other bottom-right // addons. No Centered Gap field here: the General/Individual // Centered Gap only applies to the 3 bottom-left addons // (A-B Loop, Speed, FrameByFrame), never to // Download/Screenshot in the bottom-right zone. // ============================================================ hideOnNarrowWindow: true, // 'original' | 'library'. 'original' leaves a.download empty, // so the browser uses the server's own Content-Disposition // filename. filenameChoice: 'original', // Only relevant when filenameChoice is 'library'. Same // defaults and same colon-replacement/year logic as // Screenshot's matching setting. includeYearMovies: true, includeYearEpisodes: false, includeYearVideos: false }; async function fetchPluginConfig() { const maxAttempts = 120; const delayMs = 250; let failures = 0; for (let attempt = 0; attempt < maxAttempts; attempt++) { // No ApiClient yet (jellyfin-web creates it once a server is // known, e.g. after the server selection page): wait without // using up an attempt, like the not-logged-in case below. if (!window.ApiClient) attempt--; if (window.ApiClient && typeof ApiClient.getJSON === 'function') { // Not logged in yet (e.g. still on the login page): every // request would only fail with 401, so wait without using up // an attempt. if (typeof ApiClient.accessToken === 'function' && !ApiClient.accessToken()) { attempt--; await new Promise(function (resolve) { setTimeout(resolve, delayMs); }); continue; } try { // The plugin's own endpoint, readable for every signed-in user. const config = await ApiClient.getJSON(ApiClient.getUrl('VideoOSDTweaksCandy/ClientConfiguration')); if (config) return config; throw new Error('empty configuration'); } catch (err) { // 403: no access; 404: plugin not installed (standalone // use). Retrying can't change // either, so stop and use the defaults instead of sending // up to 120 failing requests. if (err && (err.status === 403 || err.status === 404)) return null; // Server error (5xx), network error or empty answer: at most 3 // retries, 0.5 / 1 / 2 s apart, then the defaults until the next // fetch. if (++failures > 3) return null; await new Promise(function (resolve) { setTimeout(resolve, delayMs * Math.pow(2, failures)); }); continue; } } await new Promise(function (resolve) { setTimeout(resolve, delayMs); }); } return null; } // Maps the plugin's Download settings onto CONFIG, same pattern as // the other addons. Guarded per field, so a missing or partial // config never wipes a default. function applyPluginConfig(pluginConfig) { if (!pluginConfig) return; if (typeof pluginConfig.DownloadHideOnNarrowWindow === 'boolean') { CONFIG.hideOnNarrowWindow = pluginConfig.DownloadHideOnNarrowWindow; } if (typeof pluginConfig.DownloadFilenameChoice === 'string' && pluginConfig.DownloadFilenameChoice) { CONFIG.filenameChoice = pluginConfig.DownloadFilenameChoice; } if (typeof pluginConfig.DownloadIncludeYearMovies === 'boolean') { CONFIG.includeYearMovies = pluginConfig.DownloadIncludeYearMovies; } if (typeof pluginConfig.DownloadIncludeYearEpisodes === 'boolean') { CONFIG.includeYearEpisodes = pluginConfig.DownloadIncludeYearEpisodes; } if (typeof pluginConfig.DownloadIncludeYearVideos === 'boolean') { CONFIG.includeYearVideos = pluginConfig.DownloadIncludeYearVideos; } } // ---- END PLUGIN ADAPTER ---- const ADDON_ID = 'downloadButton'; const ADDON_NAME = 'Download Button'; const RESPONSIVE_STYLE_ID = 'downloadButtonResponsiveStyle'; 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 lastVideoId = null; let observer = null; let pollInterval = null; let enabled = false; let registeredWithCustoms = false; let customsRegisterTimer = null; let ignoreStoredCustomsState = false; const isCustomsAvailable = () => { const api = window[CUSTOMS_API_NAME]; return !!api && typeof api.registerAddon === 'function'; }; const isEnabledByCustomsState = () => localStorage.getItem(CUSTOMS_STORAGE_KEY) !== 'false'; // Adds or removes the "hide on narrow window" style according to // CONFIG.hideOnNarrowWindow, same pattern as the other addons. 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 .btnDownload { display: none !important; } } `; document.head.appendChild(style); }; const getCurrentVideoId = () => { const ratingBtn = document.querySelector('#videoOsdPage:not(.hide) .btnUserRating'); if (ratingBtn && ratingBtn.dataset && ratingBtn.dataset.id) { return ratingBtn.dataset.id; } // No logging here: checkVideoChange() below calls this on every // MutationObserver tick (class/style changes happen constantly // during playback, e.g. the progress slider), and "no id yet" // (OSD not showing, rating button without data-id) is a normal, // frequent outcome. A warning here would flood the console with // tens of thousands of lines, which costs performance even with // devtools closed. return null; }; const sanitizeFilename = str => str.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim(); // Name for a download from the stream endpoint, which sends no file name // of its own: the item's name made safe for Windows (at most 150 // characters, no trailing dot or space) plus its container as extension. const streamFilename = item => { let name = sanitizeFilename(String(item.Name || '').replace(/\s*:\s*/g, ' - ').replace(/[\u0000-\u001f]/g, '')) .slice(0, 150).replace(/[. ]+$/, ''); if (!name) return ''; const containers = String(item.Container || '').toLowerCase().split(','); const ext = containers.includes('mp4') ? 'mp4' : containers[0]; if (/^[a-z0-9]+$/.test(ext) && !name.toLowerCase().endsWith('.' + ext)) name += '.' + ext; return name; }; const getItemInfo = async (id) => { if (!window.ApiClient || !id) return null; try { 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'; const includeYear = kind === 'movie' ? CONFIG.includeYearMovies : kind === 'episode' ? CONFIG.includeYearEpisodes : CONFIG.includeYearVideos; let label = item.Name || ''; if (item.Type === 'Episode' && item.SeriesName) { // "!= null" (not "||"): season 0 (specials) / episode 0 are real numbers. const s = String(item.ParentIndexNumber != null ? item.ParentIndexNumber : 1).padStart(2, '0'); const e = String(item.IndexNumber != null ? item.IndexNumber : 1).padStart(2, '0'); label = `${item.SeriesName} - S${s}E${e} - ${item.Name || ''}`; } if (includeYear && item.ProductionYear) { label += ` (${item.ProductionYear})`; } label = label.replace(/\s*:\s*/g, ' - '); label = sanitizeFilename(label); let extension = null; if (item.Path) { const m = item.Path.match(/\.([^./\\]+)$/); if (m) extension = m[1].toLowerCase(); } return { label: label, extension: extension }; } catch (err) { console.error('[VideoOSD Download Button] getItemInfo failed:', err); return null; } }; const downloadCurrentVideo = async () => { const id = getCurrentVideoId(); if (!id || !window.ApiClient) return; // Respect the account's download permission, like Jellyfin's own // download entries. The "library" filename mode uses the stream // endpoint, which the server serves regardless of the download // permission, and in the default mode the download would just // fail silently. Only the permission itself is checked here (not // item.CanDownload, which is also false for e.g. .strm items // that the library mode can still download). try { const user = await ApiClient.getCurrentUser(); if (user && user.Policy && user.Policy.EnableContentDownloading === false) { if (window.Dashboard && typeof Dashboard.alert === 'function') { Dashboard.alert('Downloading is not allowed for your account.'); } return; } } catch (err) { // User lookup failed: download without the permission check. } const a = document.createElement('a'); if (CONFIG.filenameChoice === 'library') { const info = await getItemInfo(id); const filename = (info && info.label) ? (info.extension ? `${info.label}.${info.extension}` : info.label) : null; if (filename) { a.href = `${ApiClient.serverAddress()}/Videos/${id}/stream?ApiKey=${ApiClient.accessToken()}&static=true`; a.download = filename; } else { a.href = `${ApiClient.serverAddress()}/Items/${id}/Download?ApiKey=${ApiClient.accessToken()}`; a.download = ''; } } else { // Items without a downloadable original (e.g. Playnite Bridge's game // videos: Items/{id}/Download answers 404) get the file from the // stream endpoint instead. let canDownload = true; let item = null; try { item = await ApiClient.getItem(ApiClient.getCurrentUserId(), id); canDownload = !!(item && item.CanDownload === true); } catch (err) { // Item lookup failed: keep the download endpoint. } a.href = canDownload ? `${ApiClient.serverAddress()}/Items/${id}/Download?ApiKey=${ApiClient.accessToken()}` : `${ApiClient.serverAddress()}/Videos/${id}/stream?ApiKey=${ApiClient.accessToken()}&static=true`; a.download = canDownload ? '' : streamFilename(item); } a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); }; const ensureBtn = () => { if (!btn) { btn = document.createElement('button'); btn.className = 'btnDownload autoSize paper-icon-button-light'; btn.title = 'Download'; // Inline SVG instead of the "Material Symbols Outlined" icon // font: jellyfin-web ships no Material Symbols font, so without // it (loaded by some other theme/plugin/CSS) the ligature text // "download" would render as plain text instead of an icon. The // path is the font's own "download" glyph (FILL 0, wght 400, // GRAD 0, opsz 24), sized to the font's standard 24px, so it // looks exactly like the font rendering. const icon = document.createElement('span'); icon.className = 'xlargePaperIconButton jvosd-download-icon'; icon.style.cssText = 'display:inline-block;width:24px;height:24px;line-height:0;'; icon.innerHTML = ''; btn.appendChild(icon); btn.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); downloadCurrentVideo(); }); } return btn; }; const removeButton = () => { if (btn) { btn.remove(); btn = null; } }; // Greys the button out (one-line reason as tooltip) when the server has no // file for the playing item at all (e.g. a Steam video streamed as HLS). // Checked once per item: no downloadable original -> one 1-byte request // to the stream endpoint. const downloadable = {}; const updateAvailability = async id => { if (!id || !window.ApiClient) return; if (!(id in downloadable)) { downloadable[id] = true; try { const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), id); if (!(item && item.CanDownload === true)) { const r = await fetch(`${ApiClient.serverAddress()}/Videos/${id}/stream?ApiKey=${ApiClient.accessToken()}&static=true`, { headers: { Range: 'bytes=0-0' } }); downloadable[id] = r.ok; if (r.body) r.body.cancel(); } } catch (err) { downloadable[id] = true; } } if (btn && id === getCurrentVideoId()) { btn.disabled = !downloadable[id]; btn.title = downloadable[id] ? 'Download' : 'No file to download for this video'; } }; const checkVideoChange = () => { const id = getCurrentVideoId(); if (id !== lastVideoId) { lastVideoId = id; updateAvailability(id); } }; const injectButton = () => { if (!enabled) return false; const favBtn = document.querySelector('#videoOsdPage:not(.hide) .buttons.focuscontainer-x > .btnUserRating'); if (!favBtn || !favBtn.parentNode) return false; const container = favBtn.parentNode; if (!container.querySelector('.btnDownload')) { refreshResponsiveStyle(); const newBtn = ensureBtn(); container.insertBefore(newBtn, favBtn); updateAvailability(getCurrentVideoId()); } return true; }; const enable = () => { if (enabled) return; enabled = true; // This observer watches the whole document.body subtree for // style/class changes, which fire constantly during playback // (Jellyfin's progress bar updates style/class very often), and // other addons run similar observers. Debounced to at most once // every 100ms: still quick to catch newly inserted elements, but // a burst of mutations becomes a single check instead of // hundreds per second. let debounceTimer = null; observer = new MutationObserver(() => { if (debounceTimer) return; debounceTimer = setTimeout(() => { debounceTimer = null; injectButton(); checkVideoChange(); }, 100); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); pollInterval = setInterval(() => { if (injectButton()) { clearInterval(pollInterval); pollInterval = null; } }, 300); injectButton(); }; const disable = () => { if (!enabled) return; enabled = false; if (observer) { observer.disconnect(); observer = null; } if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } removeButton(); lastVideoId = null; }; 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(); console.log('[VideoOSD Download Button] Script loaded.'); }; 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(); }).catch(function (err) { console.error('[VideoOSD Download Button] config apply failed:', err); }); // ---- END PLUGIN ADAPTER ---- } // Phones, tablets and TV devices: this addon stays off there unless the plugin // setting "DownloadOnTouchAndTvDevices" allows it (admin setting, default off). The setting is // read from the plugin's own endpoint, which every signed-in user may read. function dlAllowedOnThisDevice() { // Signed out (login page) nothing is sent and no timer runs: the check // waits for the first view change ("viewshow") or hash change with a token. return new Promise(function (resolve) { let started = false; const attempt = function () { const api = window.ApiClient; if (started || !api || typeof api.accessToken !== 'function' || !api.accessToken()) return; started = true; document.removeEventListener('viewshow', attempt, true); window.removeEventListener('hashchange', attempt); api.getJSON(api.getUrl('VideoOSDTweaksCandy/ClientConfiguration')).then(function (config) { resolve(!!config && config.DownloadOnTouchAndTvDevices === true); }, function () { resolve(false); }); }; attempt(); if (!started) { document.addEventListener('viewshow', attempt, true); window.addEventListener('hashchange', attempt); } }); } if (dlIsSupportedPlatform()) { dlMain(); } else { dlAllowedOnThisDevice().then(function (allowed) { if (allowed) dlMain(); }); }