/* * Jellyfin-VideoOSD-Tweaks-Candy-Core-v1.js * * Control script for the VideoOSD Tweaks and Candy Jellyfin plugin. It is * not usable on its own: everything it does (hiding vanilla OSD elements, * rebuilding the title, ordering vanilla and custom OSD buttons) is driven * by the plugin's server-side configuration, read from the endpoint * VideoOSDTweaksCandy/ClientConfiguration. Without the plugin there is no * configuration and the script changes nothing. Unlike the other 8 mods * of this project it has no standalone defaults, since it has no feature * of its own to fall back to. * * It does not watch the whole document. Jellyfin dispatches bubbling * "pageshow"/"pagehide" events on each page's root element on every * navigation (src/components/Page.tsx, the same events pageClassOn() and * pageIdOn() are built on). The script reacts to those for #videoOsdPage * and, while the video page is shown, observes only that page's subtree. * A document-wide observer fires constantly during heavy DOM activity * (e.g. while the admin Dashboard loads) and can make the whole UI * unresponsive. */ (function () { 'use strict'; const OSD_PAGE_ID = 'videoOsdPage'; // Jellyfin is a single-page app and this script runs once, at the first // index.html parse, which can be before window.ApiClient exists. So the // fetch retries every 250ms for up to 30 seconds instead of giving up // after the first failed attempt. 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. Retrying // can't change either, so give up 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 give up 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; } // ============================================================ // SHARED HIDE MECHANISM // ============================================================ const FORCE_HIDE_CLASS = 'jvosd-tc-force-hide'; const STYLE_ID = 'jvosd-tc-core-style'; function ensureCoreStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; // Title separators use margins, not spaces in the text: .pageTitle // is "display: inline-flex", and leading/trailing white space of a // flex item's text collapses, so " - " would render as "-". // Volume: the slider stays in its native ".volumeButtons" wrapper // (see applyBottomRightOrder()), so the native flex-grow and the // 43em narrow-window hide keep working. Only the wrapper's // asymmetric native margin ("0 1em 0 0.29em", made for its one // fixed position) is replaced with the uniform 0.29em of every // button-sized neighbor, so it spaces evenly at any sorted // position. The direct-child selector matches only this wrapper. // Mute below 43em: Jellyfin hides .volumeButtons (mute + slider) // there (videoosd.scss); applyBottomRightOrder() moves mute out of // that wrapper, so it is hidden here the same way. // Jellyfin 12.1, modern (MUI) layout: the video header is a React // toolbar (.videoOsd-appBar), the legacy header elements are // rendered but hidden. React owns those nodes, so they are never // changed directly: these rules act only while applyMuiHeader() // sets the matching flag on . Buttons are found by their MUI // icon (data-testid), independent of the UI language. 10.10.7 and // the legacy layouts have no .videoOsd-appBar, so nothing matches. // [data-jvosd-mui-bar] = the same MUI video toolbar in the 10.10.7 // "experimental" layout, which has no class of its own (getMuiBar()). // The buttons carry [data-jvosd-mui-btn] (markMuiButtons()), so the // rules need neither the has- nor the is- pseudo-class (older TV / // browser engines). // [data-jvosd-pre-hide-*] cover the frames between the header's // mount and the first markMuiButtons() (React paints it before the // OSD page's "pageshow"): set from the config alone (applyMuiPreHides()), // they hide the icons wherever a MUI toolbar sits directly in a Box; // in both versions only the video header does (the app toolbar sits // in the AppBar header; jellyfin-web 10.10.7 / 12.1 sources). style.textContent = `.${FORCE_HIDE_CLASS} { display: none !important; } .jvosd-tc-title-sep { margin: 0 0.35em; } .jvosd-tc-title-year-sep { margin-left: 0.35em; } .videoOsdBottom .buttons > .volumeButtons { margin: 0 0.29em; } @media all and (max-width: 43em) { #videoOsdPage .videoOsdBottom .buttonMute { display: none !important; } } html[data-jvosd-mui-hide-back] [data-jvosd-mui-btn="back"] { display: none !important; } html[data-jvosd-mui-hide-sync] [data-jvosd-mui-btn="sync"] { display: none !important; } html[data-jvosd-mui-hide-cast] [data-jvosd-mui-btn="cast"] { display: none !important; } html[data-jvosd-mui-cast-first] [data-jvosd-mui-btn="cast"] { order: -1; } html[data-jvosd-pre-hide-back] .MuiBox-root > .MuiToolbar-root svg[data-testid="ArrowBackIcon"], html[data-jvosd-pre-hide-sync] .MuiBox-root > .MuiToolbar-root svg[data-testid="GroupsIcon"], html[data-jvosd-pre-hide-cast] .MuiBox-root > .MuiToolbar-root svg[data-testid="CastIcon"], html[data-jvosd-pre-hide-cast] .MuiBox-root > .MuiToolbar-root svg[data-testid="CastConnectedIcon"] { visibility: hidden !important; } html[data-jvosd-mui-own-title] .videoOsd-appBar > .MuiTypography-root:not(.jvosd-tc-mui-title), html[data-jvosd-mui-own-title] [data-jvosd-mui-bar] > .MuiTypography-root:not(.jvosd-tc-mui-title) { display: none !important; }`; document.head.appendChild(style); } function setHidden(selector, hidden) { document.querySelectorAll(selector).forEach(function (el) { el.classList.toggle(FORCE_HIDE_CLASS, !!hidden); }); } // Jellyfin keeps up to 3 pages in the DOM (viewContainer.js, // pageContainerCount = 3, slots reused round-robin), and a playback // started from a non-video page puts a fresh #videoOsdPage into the // next slot. A hidden #videoOsdPage from an earlier playback can still // sit in an earlier slot, where getElementById() (first match) would // find it. The live OSD page is the one without the "hide" class (the // shown view has none by the time "pageshow" fires). All OSD lookups // go through getActiveOsdPage(), never through a document-wide query. // // leavingOsdPage is set only during the synchronous re-apply in the // "pagehide" handler: when leaving to a React page (search, user // profile, quick connect), Page.tsx -> viewManager.hideView() // dispatches "pagehide" before it adds "hide" to the OSD page, so the // page being left would otherwise still count as the live OSD. let leavingOsdPage = null; function getActiveOsdPage() { const pages = document.querySelectorAll('#' + OSD_PAGE_ID); for (let i = 0; i < pages.length; i++) { if (pages[i] !== leavingOsdPage && !pages[i].classList.contains('hide')) return pages[i]; } return null; } function isVideoOsdActive() { return !!getActiveOsdPage(); } // ============================================================ // VANILLA HIDE/SHOW -- elements inside the video OSD page. Scoped to // "#videoOsdPage ": most of these class names are reused elsewhere // (photo slideshow, item detail pages). // ============================================================ function applyOsdInternalHides(config) { setHidden('#videoOsdPage .btnPause', config.HidePlayPauseButton); setHidden('#videoOsdPage .btnRewind, #videoOsdPage .btnFastForward', config.HideRewindFastForward); setHidden('#videoOsdPage .btnPreviousChapter, #videoOsdPage .btnNextChapter', config.HideChapterButtons); setHidden('#videoOsdPage .btnPreviousTrack, #videoOsdPage .btnNextTrack', config.HideTrackButtons); setHidden('#videoOsdPage .btnRecord', config.HideRecordButton); // Only the inner ".endsAtText" is hidden, not ".osdTimeText": // ".osdTimeText" carries "margin-right: auto", the flex spacer that // pushes the following buttons (Favorite, Subtitles, Audio, Volume, // Settings, ...) to the right edge. Hiding it would collapse that // group to the left. setHidden('#videoOsdPage .osdTimeText .endsAtText', config.HideEndsAtInfo); setHidden('#videoOsdPage .btnUserRating', config.HideFavoriteButton); setHidden('#videoOsdPage .btnSubtitles', config.HideSubtitlesButton); setHidden('#videoOsdPage .btnAudio', config.HideAudioButton); setHidden('#videoOsdPage .buttonMute', config.HideMuteButton); setHidden('#videoOsdPage .osdVolumeSliderContainer', config.HideVolumeSlider); setHidden('#videoOsdPage .btnVideoOsdSettings', config.HideSettingsButton); setHidden('#videoOsdPage .btnPip', config.HidePictureInPictureButton); setHidden('#videoOsdPage .btnFullscreen', config.HideFullscreenButton); setHidden('#videoOsdPage .btnAirPlay', config.HideAirPlayButton); } // ============================================================ // VANILLA HIDE/SHOW -- global header elements (Back/Title/SyncPlay/ // Cast). These live in the shared AppHeader, a sibling of // #videoOsdPage used on every page, so they are hidden only while // the video OSD is active. // ============================================================ function applyHeaderButtonHides(config) { const active = isVideoOsdActive(); setHidden('.headerBackButton', active && config.HideBackButton); setHidden('.headerSyncButton', active && config.HideSyncPlayButton); setHidden('.headerCastButton', active && config.HideCastButton); } // ============================================================ // TITLE RECONSTRUCTION // ============================================================ const TITLE_ID = 'pageTitle'; const RAW_TEXT_MARKER_ATTR = 'data-jvosdTcRawText'; // Year digits use \p{Nd} (with the "u" flag), not \d: Jellyfin // renders the year via toLocaleString() in the current locale, which // yields native digits for e.g. fa/bn/mr/ne ("(۲۰۰۸)"), and \d // only matches ASCII digits. The episode name part is optional: // Jellyfin omits it for episodes without a name ("Show - S1:E2"). const EPISODE_TITLE_REGEX = /^(.*?)\s-\sS(\d+):E(\d+)(?:-(\d+))?(?:\s-\s(.*?))?(?:\s\((\p{Nd}{4})\))?$/u; const PLAIN_TITLE_REGEX = /^(.*?)(?:\s\((\p{Nd}{4})\))?$/u; function parseTitleSync(rawText, itemInfo) { const episodeMatch = rawText.match(EPISODE_TITLE_REGEX); if (episodeMatch) { return { kind: 'episode', seriesName: episodeMatch[1], season: episodeMatch[2], episode: episodeMatch[3], episodeEnd: episodeMatch[4] || null, episodeName: episodeMatch[5] || '', year: episodeMatch[6] || null }; } const plainMatch = rawText.match(PLAIN_TITLE_REGEX); // Episodes Jellyfin titles without "S1:E2": specials ("Show - // Special - Name", the middle label is localized, e.g. "Extra" in // German) and episodes without an index number ("Show - Name"). // Parsing the localized label is not possible, so the known // series and episode names of the playing item are used to split // the title instead; the middle part is kept verbatim as the // season/episode part. if (plainMatch && itemInfo && itemInfo.kind === 'episode' && itemInfo.seriesName && itemInfo.name) { const body = plainMatch[1]; const prefix = itemInfo.seriesName + ' - '; // The special label ("Extra - {0}") is joined to the episode // name with " - " or, in some translations, " – " (en dash; // German since Jellyfin 12.1). const suffixes = [' - ' + itemInfo.name, ' \u2013 ' + itemInfo.name]; let sxeText = null; if (body === prefix + itemInfo.name) { sxeText = ''; } else if (body.startsWith(prefix)) { const suffix = suffixes.find(function (s) { return body.endsWith(s) && body.length > prefix.length + s.length; }); if (suffix) sxeText = body.slice(prefix.length, body.length - suffix.length); } if (sxeText !== null) { return { kind: 'episode', seriesName: itemInfo.seriesName, sxeText: sxeText, episodeName: itemInfo.name, year: plainMatch[2] || null }; } } return { kind: 'plain', name: plainMatch ? plainMatch[1] : rawText, year: plainMatch ? (plainMatch[2] || null) : null }; } let cachedItemInfo = null; let cachedItemInfoName = null; async function getNowPlayingItemInfo() { if (!(window.ApiClient && window.ApiClient.getSessions)) return null; try { const sessions = await ApiClient.getSessions({ deviceId: ApiClient.deviceId() }); const session = sessions.find(function (s) { return s.NowPlayingItem && s.PlayState; }) || sessions.find(function (s) { return s.NowPlayingItem; }); const item = session && session.NowPlayingItem; if (!item) return null; const itemName = item.Id || item.Name || 'unknown'; if (cachedItemInfo && cachedItemInfoName === itemName) { return cachedItemInfo; } let kind = 'video'; if (item.Type === 'Movie') kind = 'movie'; else if (item.Type === 'Episode') kind = 'episode'; cachedItemInfo = { kind: kind, id: item.Id || null, originalTitle: item.OriginalTitle || null, name: item.Name || null, seriesName: item.SeriesName || null, type: item.Type || null }; cachedItemInfoName = itemName; return cachedItemInfo; } catch (err) { return null; } } // The year is not joined with the " - " separator: Jellyfin's format // is "Name (2008)", with a plain space, never "Name - (2008)". function renderTitleParts(el, orderedParts, yearText) { const visible = orderedParts.filter(function (p) { return p.text; }); el.innerHTML = ''; visible.forEach(function (p, idx) { if (idx > 0) { const sep = document.createElement('span'); sep.className = 'jvosd-tc-title-sep'; sep.textContent = '-'; el.appendChild(sep); } const span = document.createElement('span'); span.className = 'jvosd-tc-title-' + p.key; span.textContent = p.text; el.appendChild(span); }); if (yearText) { const yearSpan = document.createElement('span'); yearSpan.className = 'jvosd-tc-title-year jvosd-tc-title-year-sep'; yearSpan.textContent = yearText; el.appendChild(yearSpan); } } function getEpisodeTitleOrder(config) { const DEFAULT_ORDER = ['series', 'sxe', 'title']; const raw = config.TopLeftOrder; if (typeof raw !== 'string' || !raw) return DEFAULT_ORDER; const requested = raw.split(',').map(function (s) { return s.trim(); }).filter(Boolean); const known = requested.filter(function (k) { return DEFAULT_ORDER.includes(k); }); const missing = DEFAULT_ORDER.filter(function (k) { return !known.includes(k); }); return known.concat(missing); } // With every title setting at its default, the title is left alone: // Jellyfin's native rendering is already right, and rebuilding it into // our own spans would only add risk. function needsTitleIntervention(config) { if (config.HideTitleBar) return true; if (config.HideSeriesTitle || config.HideSeasonEpisodeNumber || config.HideEpisodeTitle) return true; if (config.HideYearMovies || config.HideYearEpisodes || config.HideYearVideos) return true; if (config.ShowOriginalTitleMovies) return true; if (typeof config.TopLeftOrder === 'string' && config.TopLeftOrder && config.TopLeftOrder !== 'series,sxe,title') return true; return false; } function applyTitleDisplay(config, itemInfo) { const el = document.querySelector('h3.' + TITLE_ID); // h3.pageTitle is the shared header title on every page, so it is // only touched while the video OSD is active. On leaving, the hide // class (HideTitleBar) is removed here: Jellyfin never removes it, // and the title (including the home page logo, rendered through // the same element) would stay hidden until a reload. The render // cache is dropped too, so the next OSD visit renders fresh. if (!isVideoOsdActive()) { if (el) { if (el.classList.contains(FORCE_HIDE_CLASS)) el.classList.remove(FORCE_HIDE_CLASS); // Jellyfin doesn't reset the title on every page, so our // rebuilt text could stay behind and be read as native text // on the next video start: put Jellyfin's own text back. const marker = el.getAttribute(RAW_TEXT_MARKER_ATTR); if (marker && marker === el.textContent && el.dataset.jvosdTcSourceText !== undefined) { el.textContent = el.dataset.jvosdTcSourceText; } delete el.dataset.jvosdTcLastRenderSignature; el.removeAttribute(RAW_TEXT_MARKER_ATTR); delete el.dataset.jvosdTcSourceText; } titleRawTextForItemInfo = null; return; } if (!el) return; if (!needsTitleIntervention(config)) { // Nothing configured needs our own rendering at all. If an // earlier config change left our span structure in place // (its cached raw text still matches, i.e. Jellyfin hasn't // re-set the title since), restore plain native text and // clear our own bookkeeping, so a later real intervention // starts from a clean slate rather than an already-rebuilt // one. if (el.getAttribute(RAW_TEXT_MARKER_ATTR) === el.textContent && el.dataset.jvosdTcSourceText) { el.textContent = el.dataset.jvosdTcSourceText; el.removeAttribute(RAW_TEXT_MARKER_ATTR); delete el.dataset.jvosdTcSourceText; } el.classList.remove(FORCE_HIDE_CLASS); return; } // el.textContent is either Jellyfin's fresh text (if it just set // the title) or the text of our own rebuilt spans (if nothing has // set it since our last render). The marker tells the two apart, // since our own output must not be parsed as raw text. const rawText = el.getAttribute(RAW_TEXT_MARKER_ATTR) === el.textContent ? el.dataset.jvosdTcSourceText : el.textContent; if (!rawText) return; // The next item of a queue (next episode, playlist) plays without // any navigation (appRouter.show() is a no-op for the path already // shown), so no "pageshow" brings new item info. A new native // title text means a new item: drop the old info (the title falls // back to the parsed kind until the fresh info arrives) and fetch // it again. if (rawText !== titleRawTextForItemInfo) { const firstTitle = titleRawTextForItemInfo === null; titleRawTextForItemInfo = rawText; if (!firstTitle) { currentItemInfo = null; itemInfo = null; refreshItemInfoAndReapply(); } } // Skip the rebuild when nothing changed: Jellyfin's time display // updates roughly every 700ms during playback and touches // innerHTML, which osdObserver (childList) picks up. // The render signature is kept apart from RAW_TEXT_MARKER_ATTR, // which must keep telling our text from Jellyfin's. It includes // itemInfo, because itemInfo arrives asynchronously after a first // render with the same rawText, and every title setting, so a // changed setting re-renders the same title. The early return also // requires that what is on screen is still our own render: // Jellyfin re-sets the same title text when the same item is // replayed or an audio/subtitle track changes, and that text has // to be rebuilt again. const itemInfoSignature = ((itemInfo && itemInfo.kind) || '') + '\u0000' + ((itemInfo && itemInfo.originalTitle) || '') + '\u0000' + ((itemInfo && itemInfo.seriesName) || '') + '\u0000' + ((itemInfo && itemInfo.name) || ''); const configSignature = [ config.HideTitleBar, config.HideSeriesTitle, config.HideSeasonEpisodeNumber, config.HideEpisodeTitle, config.HideYearMovies, config.HideYearEpisodes, config.HideYearVideos, config.ShowOriginalTitleMovies, config.TopLeftOrder ].map(function (v) { return String(v); }).join('\u0000'); const renderSignature = rawText + '\u0001' + itemInfoSignature + '\u0001' + configSignature; const stillOurRender = config.HideTitleBar ? el.classList.contains(FORCE_HIDE_CLASS) : el.getAttribute(RAW_TEXT_MARKER_ATTR) === el.textContent; if (stillOurRender && el.dataset.jvosdTcLastRenderSignature === renderSignature) return; el.dataset.jvosdTcLastRenderSignature = renderSignature; if (config.HideTitleBar) { if (!el.classList.contains(FORCE_HIDE_CLASS)) el.classList.add(FORCE_HIDE_CLASS); return; } el.classList.remove(FORCE_HIDE_CLASS); const parsed = parseTitleSync(rawText, itemInfo); const kind = (itemInfo && itemInfo.kind) || (parsed.kind === 'episode' ? 'episode' : null); const includeYear = kind === 'movie' ? !config.HideYearMovies : kind === 'episode' ? !config.HideYearEpisodes : kind === 'video' ? !config.HideYearVideos : true; const yearText = (includeYear && parsed.year) ? ('(' + parsed.year + ')') : ''; let orderedParts; if (parsed.kind === 'episode') { const order = getEpisodeTitleOrder(config); const partsByKey = { series: { key: 'series', text: config.HideSeriesTitle ? '' : parsed.seriesName }, sxe: { key: 'sxe', text: config.HideSeasonEpisodeNumber ? '' : (typeof parsed.sxeText === 'string' ? parsed.sxeText : ('S' + parsed.season + ':E' + parsed.episode + (parsed.episodeEnd ? '-' + parsed.episodeEnd : ''))) }, title: { key: 'title', text: config.HideEpisodeTitle ? '' : parsed.episodeName } }; orderedParts = order.map(function (k) { return partsByKey[k]; }); } else { // ShowOriginalTitleMovies replaces the title with the original // title; the normal title stays when there is none or it is // identical. const displayName = (kind === 'movie' && config.ShowOriginalTitleMovies && itemInfo && itemInfo.originalTitle && itemInfo.originalTitle !== parsed.name) ? itemInfo.originalTitle : parsed.name; orderedParts = [{ key: 'name', text: displayName }]; } renderTitleParts(el, orderedParts, yearText); el.dataset.jvosdTcSourceText = rawText; el.setAttribute(RAW_TEXT_MARKER_ATTR, el.textContent); } // ============================================================ // ZONE ORDERING // ============================================================ // Moves the elements listed in orderCsv (matched by idAttr) into that // sequence, at the container's front or directly after anchor. With // an anchor, everything up to it stays untouched (Bottom-Left also // holds 7 native transport buttons that are in no order list). // insertBefore() always produces a childList mutation, even for an // element already in place, and these containers sit inside the // subtree osdObserver watches, so nothing is moved when the order is // already correct; otherwise every apply would re-trigger the // observer in an endless loop. function applyOrder(container, orderCsv, idAttr, anchor) { if (!container || typeof orderCsv !== 'string' || !orderCsv) return; const order = orderCsv.split(',').map(function (s) { return s.trim(); }).filter(Boolean); if (!order.length) return; const targetEls = order .map(function (id) { return container.querySelector('[' + idAttr + '="' + CSS.escape(id) + '"]'); }) .filter(Boolean); if (!targetEls.length) return; const validAnchor = (anchor && anchor.parentNode === container) ? anchor : null; // Compare element nodes only: whitespace text nodes between tags // would offset the positions, the "already correct" check would // never match, and every pass would move nodes again and // re-trigger the observer. const firstSlot = validAnchor ? validAnchor.nextSibling : container.firstChild; const currentFromSlot = []; for (let node = firstSlot; node; node = node.nextSibling) { if (node.nodeType === 1) currentFromSlot.push(node); } const alreadyCorrect = targetEls.every(function (el, idx) { return currentFromSlot[idx] === el; }); if (alreadyCorrect) return; // In reverse, each one inserted right after the anchor (or at the // container's front). The insertion point is read fresh on every // iteration, so each element lands before the one placed in the // previous iteration, building up the final sequence. targetEls.slice().reverse().forEach(function (el) { const insertBeforeNode = validAnchor ? validAnchor.nextSibling : container.firstChild; container.insertBefore(el, insertBeforeNode); }); } // With only two items, ordering is a swap or nothing. ".headerRight" // also holds other native elements around sync and cast // (headerSelectedPlayer, headerAudioPlayerButton, headerSearchButton, // headerUserButton), so only these two are swapped relative to each // other and nothing else is moved. function applyTopRightOrder(config) { const container = document.querySelector('.headerRight'); if (!container) return; const sync = container.querySelector('.headerSyncButton'); const cast = container.querySelector('.headerCastButton'); if (!sync || !cast) return; const orderCsv = config.TopRightOrder; if (typeof orderCsv !== 'string' || !orderCsv) return; const order = orderCsv.split(',').map(function (s) { return s.trim(); }).filter(Boolean); const castIdx = order.indexOf('cast'); const syncIdx = order.indexOf('sync'); if (castIdx === -1 || syncIdx === -1) return; const wantCastFirst = castIdx < syncIdx; const children = Array.prototype.slice.call(container.children); const castCurrentlyFirst = children.indexOf(cast) < children.indexOf(sync); if (wantCastFirst === castCurrentlyFirst) return; // A real swap: a plain insertBefore(cast, sync) would also displace // whatever sits between the two (headerSelectedPlayer, // headerAudioPlayerButton). Each element is moved before the // other's original next sibling, so anything in between stays put. const syncNext = sync.nextSibling; const castNext = cast.nextSibling; container.insertBefore(sync, castNext); container.insertBefore(cast, syncNext); } // ============================================================ // JELLYFIN 12.1 MODERN (MUI) VIDEO HEADER // ============================================================ // Same settings as the legacy header above, applied through the CSS // flags of ensureCoreStyle(). The title is rendered into the (hidden) // legacy h3.pageTitle by applyTitleDisplay() exactly as on 10.10.7 and // mirrored into an own element next to the React title, which is // hidden; the React title's text is never written to (React would // overwrite it on the next title change). const MUI_TITLE_CLASS = 'jvosd-tc-mui-title'; function setRootFlag(name, on) { const root = document.documentElement; if (on) { if (!root.hasAttribute(name)) root.setAttribute(name, ''); } else if (root.hasAttribute(name)) { root.removeAttribute(name); } } // Config-only flags for the first frames of the MUI video header (see // ensureCoreStyle()); independent of the OSD state, they match nothing // outside the video header. function applyMuiPreHides(config) { setRootFlag('data-jvosd-pre-hide-back', !!(config && config.HideBackButton)); setRootFlag('data-jvosd-pre-hide-sync', !!(config && config.HideSyncPlayButton)); setRootFlag('data-jvosd-pre-hide-cast', !!(config && config.HideCastButton)); } // The MUI video toolbar: 12.1 modern gives it the class videoOsd-appBar; // the 10.10.7 "experimental" layout renders the same toolbar (same // icons) without any class and without a title; it is recognised by // those icons (back / SyncPlay / cast) and by having no drawer menu // button (that would be an app toolbar), and gets our own attribute // (React leaves attributes it does not manage alone). The legacy // layouts have no MUI toolbar at all. const MUI_BAR_ATTR = 'data-jvosd-mui-bar'; const MUI_BAR_ICONS = 'svg[data-testid="ArrowBackIcon"], svg[data-testid="GroupsIcon"], ' + 'svg[data-testid="CastIcon"], svg[data-testid="CastConnectedIcon"]'; function getMuiBar() { const bar = document.querySelector('.videoOsd-appBar'); if (bar) return bar; const toolbar = Array.prototype.find.call(document.querySelectorAll('.MuiToolbar-root'), function (t) { return !t.closest('.dialogContainer') && !!t.querySelector(MUI_BAR_ICONS) && !t.querySelector('svg[data-testid="MenuIcon"]'); }); if (toolbar && !toolbar.hasAttribute(MUI_BAR_ATTR)) toolbar.setAttribute(MUI_BAR_ATTR, ''); return toolbar || null; } // Marks the header buttons by their MUI icon (independent of the UI // language): back, SyncPlay, cast — for the cast-connected state the // Box around its button. React re-creates a button on some state // changes; the observers call this again and the new node is marked. const MUI_BTN_ATTR = 'data-jvosd-mui-btn'; function markMuiButtons(bar) { const marks = [ ['back', 'button', 'ArrowBackIcon'], ['sync', 'button', 'GroupsIcon'], ['cast', 'button', 'CastIcon'], ['cast', '.MuiBox-root', 'CastConnectedIcon'] ]; marks.forEach(function (m) { Array.prototype.forEach.call(bar.querySelectorAll('svg[data-testid="' + m[2] + '"]'), function (svg) { let el = svg.closest('button'); if (el && m[1] !== 'button') el = el.parentElement && el.parentElement.closest(m[1]); if (el && bar.contains(el) && el !== bar && el.getAttribute(MUI_BTN_ATTR) !== m[0]) { el.setAttribute(MUI_BTN_ATTR, m[0]); } }); }); } function applyMuiHeader(config) { const bar = isVideoOsdActive() ? getMuiBar() : null; const on = !!bar; if (bar) markMuiButtons(bar); setRootFlag('data-jvosd-mui-hide-back', on && config.HideBackButton); setRootFlag('data-jvosd-mui-hide-sync', on && config.HideSyncPlayButton); setRootFlag('data-jvosd-mui-hide-cast', on && config.HideCastButton); let castFirst = false; if (typeof config.TopRightOrder === 'string' && config.TopRightOrder) { const order = config.TopRightOrder.split(',').map(function (s) { return s.trim(); }).filter(Boolean); const castIdx = order.indexOf('cast'); const syncIdx = order.indexOf('sync'); castFirst = castIdx !== -1 && syncIdx !== -1 && castIdx < syncIdx; } setRootFlag('data-jvosd-mui-cast-first', on && castFirst); const ownTitle = on && needsTitleIntervention(config); const existing = document.querySelectorAll('.' + MUI_TITLE_CLASS); const typo = bar && bar.querySelector(':scope > .MuiTypography-root:not(.' + MUI_TITLE_CLASS + ')'); const source = document.querySelector('h3.' + TITLE_ID); if (!ownTitle || config.HideTitleBar || !typo || !source) { existing.forEach(function (el) { el.remove(); }); // The React title is hidden only when nothing is to be shown // (HideTitleBar) or the own title replaces it, never while the // mirror can't be built yet (no title at all). setRootFlag('data-jvosd-mui-own-title', ownTitle && !!config.HideTitleBar); return; } setRootFlag('data-jvosd-mui-own-title', true); let mirror = existing[0]; if (!mirror || mirror.parentNode !== bar || mirror.previousElementSibling !== typo) { existing.forEach(function (el) { el.remove(); }); mirror = document.createElement(typo.tagName.toLowerCase()); typo.after(mirror); } const cls = typo.className + ' ' + MUI_TITLE_CLASS; if (mirror.className !== cls) mirror.className = cls; if (mirror.innerHTML !== source.innerHTML) mirror.innerHTML = source.innerHTML; } function applyBottomLeftOrder(config) { const osdPage = getActiveOsdPage(); if (!osdPage) return; const container = osdPage.querySelector('.videoOsdBottom .buttons.focuscontainer-x > div[dir="ltr"]'); if (!container) return; const idMap = { abloop: '#btnAbLoop', speed: '.jfb-speed-step-container', framebyframe: '.jfb-frame-step-container' }; // setAttribute() triggers a MutationObserver callback even for an // unchanged value, so the tag is only written when it differs; // otherwise every applyAll() pass would re-trigger osdObserver in // an endless loop. Object.keys(idMap).forEach(function (id) { const el = container.querySelector(idMap[id]); if (el && el.getAttribute('data-jvosd-order-id') !== id) el.setAttribute('data-jvosd-order-id', id); }); // Default order when none is configured: the three addon scripts // insert themselves independently, so their raw order is a race. // applyOrder() enforces it regardless of insertion timing. const orderCsv = (typeof config.BottomLeftOrder === 'string' && config.BottomLeftOrder) ? config.BottomLeftOrder : 'abloop,speed,framebyframe'; // The custom buttons go after the last native transport control // (NextTrack, or FastForward when NextTrack isn't present), not to // the front of the container ahead of Play/Pause/Rewind etc. const anchor = container.querySelector('.btnNextTrack') || container.querySelector('.btnFastForward'); applyOrder(container, orderCsv, 'data-jvosd-order-id', anchor); applyCustomGapSpacing(container, config); } // The Centered Gap behaves like vanilla spacing: every native button // has the same 0.29em per side, so every gap a custom addon takes part // in grows by exactly 1x the configured value, never 2x between two // adjacent addons (as margins on both sides of each element would // give). That needs the actual neighbors after sorting, which only // this script knows, so the gap is applied here and not in the addon // scripts (their applySpacing() sets only the native 0.29em baseline; // without the plugin there is no gap setting). Runs on every // applyAll() pass, so a reorder re-derives the sides at once. // Per addon: its RIGHT side carries the gap only when another element // follows in this container, so the last custom keeps the native // distance to the "Ends at" text (its 1em margin-left plus our 0.29em // = the native 1.29em) at any gap value; anything placed after it // later (another addon or plugin button) gets the gap automatically. // Its LEFT side carries the gap only when the left neighbor is not // one of our addons (that neighbor's right side already has it). // Result: vanilla|addon, addon|vanilla and addon|addon all grow by // exactly 1x, and the group's outer edges stay native. // ABLoop is a bare button (its 0.29em baseline is in its own margins, // so the gap is added to 0.29), while Speed/Frame are fixed-width // containers whose 0.29em comes from the inner buttons (their // container margin carries only the gap, cleared at 0). Margins are // only written on a real change: inline style writes fire // MutationObservers even for identical values, and the addon // scripts' observers watch document.body. function applyCustomGapSpacing(container, config) { const NATIVE_EM = 0.29; function effectiveGap(flagKey, valueKey) { return config[flagKey] ? (Number(config[valueKey]) || 0) : (Number(config.GeneralCenteredGap) || 0); } const items = [ { el: container.querySelector('#btnAbLoop'), gap: effectiveGap('ABLoopIndividualCenteredGapOverride', 'ABLoopCenteredGapValue'), bareButton: true }, { el: container.querySelector('.jfb-speed-step-container'), gap: effectiveGap('SpeedIndividualCenteredGapOverride', 'SpeedCenteredGapValue'), bareButton: false }, { el: container.querySelector('.jfb-frame-step-container'), gap: effectiveGap('FrameByFrameIndividualCenteredGapOverride', 'FrameByFrameCenteredGapValue'), bareButton: false } ].filter(function (i) { return !!i.el; }); if (!items.length) return; const customEls = items.map(function (i) { return i.el; }); items.forEach(function (i) { const prev = i.el.previousElementSibling; const next = i.el.nextElementSibling; const leftExtra = (prev && customEls.indexOf(prev) === -1) ? i.gap : 0; const rightExtra = next ? i.gap : 0; let ml, mr; if (i.bareButton) { ml = (NATIVE_EM + leftExtra) + 'em'; mr = (NATIVE_EM + rightExtra) + 'em'; } else { ml = leftExtra > 0 ? leftExtra + 'em' : ''; mr = rightExtra > 0 ? rightExtra + 'em' : ''; } if (i.el.style.marginLeft !== ml) i.el.style.marginLeft = ml; if (i.el.style.marginRight !== mr) i.el.style.marginRight = mr; }); } // This zone is sorted with flexbox "order", never by moving nodes. // Jellyfin's emby-slider is not move-safe (src/elements/emby-slider/ // emby-slider.js): its detachedCallback nulls backgroundLower/ // backgroundUpper (the refs behind the blue track fill), and its // attachedCallback returns early on re-attach (data-embyslider="true") // without restoring them. Any insertBefore() is a detach plus a // re-attach, so a single move of the slider freezes its fill for good. // ".videoOsdBottom .buttons" is a flex container, and "order" changes // the visual sequence without any element leaving the DOM. // Consequences: // - ".volumeButtons" stays intact with the slider inside (native // flex-grow, 43em narrow-window hide and hide-mouse-idle-tv keep // working); the wrapper itself is the sortable "volumeslider". // Its margin is evened out in ensureCoreStyle(). // - Only .buttonMute leaves the wrapper, once, so it can be sorted on // its own. Plain emby buttons are move-safe (their attachedCallback // re-binds on every re-attach, see emby-ratingbutton/emby-button). // It gets hide-mouse-idle-tv re-applied, since that sat on the // wrapper. // - Untagged siblings (the left transport group and ".osdTimeText") // keep the default order 0 and so always render before everything // numbered from 1: the "Ends at" spacer stays first without any // anchor logic. // - Known edge: an element another plugin appends here also has // order 0 and shows before our numbered items; unknown elements // are deliberately not styled. // - Known trade-off: keyboard Tab order follows the DOM (the vanilla // sequence), not the sorted visual sequence. // - Loop safety: style.order writes are attribute mutations, which // osdObserver ignores (attributeFilter: ['class']) and the addon // scripts' childList observers never see; all writes are // change-guarded, so a settled state produces no mutations. function applyBottomRightOrder(config) { // Scoped to the live OSD page (see getActiveOsdPage()): a bare // document-wide '.btnUserRating' also matches the favorite button // of a cached item details page (itemDetails/index.html), which // would be sorted instead whenever that page sits in an earlier // slot. const osdPage = getActiveOsdPage(); const favBtn = osdPage && osdPage.querySelector('.videoOsdBottom .btnUserRating'); const container = favBtn && favBtn.parentNode; // html[data-jvosd-order-br]: this zone is sorted here right now. // The VisoMaster Bridge plugin reads it and then leaves its own // button where it is. setRootFlag('data-jvosd-order-br', !!container); if (!container) return; // Mute moves out of the wrapper exactly once (guarded: only // while it still sits inside), gaining independent sortability; // the wrapper-provided TV auto-hide is re-applied directly. const wrapper = container.querySelector('.volumeButtons'); if (wrapper) { const muteInWrapper = wrapper.querySelector('.buttonMute'); if (muteInWrapper) { wrapper.insertAdjacentElement('afterend', muteInWrapper); } } const mute = container.querySelector('.buttonMute'); if (mute && !mute.classList.contains('hide-mouse-idle-tv')) mute.classList.add('hide-mouse-idle-tv'); // "volumeslider" = the native ".volumeButtons" wrapper (the slider; // mute is moved out of it above). Present in 10.10.7 and 12.x. const idMap = { favorite: '.btnUserRating', episodepreview: '#popupPreviewButton', subtitles: '.btnSubtitles', audio: '.btnAudio', mute: '.buttonMute', volumeslider: '.volumeButtons', settings: '.btnVideoOsdSettings', pip: '.btnPip', fullscreen: '.btnFullscreen', airplay: '.btnAirPlay', download: '.btnDownload', screenshot: '.btnScreenshot', // VisoMaster Bridge plugin's button (inserted by its own script) visomaster: '.btnVisoMaster' }; // Default order when none is configured: download, screenshot and // episodepreview are inserted by separate scripts, so their raw // insertion order would be a race. const orderCsv = (typeof config.BottomRightOrder === 'string' && config.BottomRightOrder) ? config.BottomRightOrder : 'screenshot,download,visomaster,favorite,episodepreview,subtitles,audio,mute,volumeslider,settings,airplay,pip,fullscreen'; const order = orderCsv.split(',').map(function (s) { return s.trim(); }).filter(Boolean); // A list saved before VisoMaster Bridge was installed has no // "visomaster": place it left of favorite (its own default), // never at order 0 in front of everything. if (order.indexOf('visomaster') === -1) { const favIdx = order.indexOf('favorite'); order.splice(favIdx === -1 ? order.length : favIdx, 0, 'visomaster'); } Object.keys(idMap).forEach(function (id) { const el = container.querySelector(idMap[id]); if (!el) return; if (el.getAttribute('data-jvosd-order-id') !== id) el.setAttribute('data-jvosd-order-id', id); const idx = order.indexOf(id); const val = idx === -1 ? '' : String(idx + 1); if (el.style.order !== val) el.style.order = val; }); } // ============================================================ // ORCHESTRATION // ============================================================ let currentConfig = null; let currentItemInfo = null; // Native title text the current item info belongs to (see // applyTitleDisplay()); null = no title seen yet on this OSD visit. let titleRawTextForItemInfo = null; let itemInfoRequestSeq = 0; function applyAll() { if (!currentConfig) return; ensureCoreStyle(); applyOsdInternalHides(currentConfig); applyHeaderButtonHides(currentConfig); applyTitleDisplay(currentConfig, currentItemInfo); applyTopRightOrder(currentConfig); applyMuiPreHides(currentConfig); applyMuiHeader(currentConfig); applyBottomLeftOrder(currentConfig); applyBottomRightOrder(currentConfig); } // Jellyfin's own title text currently on screen (not our rebuilt one). function getNativeTitleText() { const el = document.querySelector('h3.' + TITLE_ID); if (!el) return ''; return el.getAttribute(RAW_TEXT_MARKER_ATTR) === el.textContent ? (el.dataset.jvosdTcSourceText || '') : el.textContent; } // Jellyfin sets the favorite button's data-id to the current item in // the same step as the title, so the item Id is the reliable check // (a name check alone would accept "Toy Story" for "Toy Story 2"). // Items that can't be rated have no data-id; the name check is used // then. function itemInfoMatchesOsd(info, titleText) { const osdPage = getActiveOsdPage(); const ratingBtn = osdPage && osdPage.querySelector('.btnUserRating'); const liveId = ratingBtn && ratingBtn.getAttribute('data-id'); if (liveId && info.id) return info.id === liveId; return !info.name || info.type === 'TvChannel' || titleText.includes(info.name); } // The server's session can still report the previous item for a // moment after the next one started, so the fetched info is only // used once it matches the item on screen (retried briefly). A newer // request supersedes an older one. async function refreshItemInfoAndReapply() { const seq = ++itemInfoRequestSeq; for (let attempt = 0; attempt < 8; attempt++) { const info = await getNowPlayingItemInfo(); if (seq !== itemInfoRequestSeq) return; // No title yet (Jellyfin sets it right after the page shows): // retry rather than accept info that can't be checked. Live TV // titles show the program, not the channel item's name, so the // name check can't apply there. const titleText = getNativeTitleText(); if (info && titleText && itemInfoMatchesOsd(info, titleText)) { currentItemInfo = info; applyAll(); return; } await new Promise(function (resolve) { setTimeout(resolve, 500); }); if (seq !== itemInfoRequestSeq) return; } } // osdObserver watches only the live #videoOsdPage subtree and exists // only while the video page is shown (started on "pageshow", // disconnected on "pagehide"). let osdObserver = null; let observedOsdPage = null; function startOsdObserver() { const osdPage = getActiveOsdPage(); if (!osdPage) return; if (osdObserver && observedOsdPage === osdPage) { startMuiObserver(); return; } stopOsdObserver(); observedOsdPage = osdPage; osdObserver = new MutationObserver(function () { applyAll(); }); osdObserver.observe(osdPage, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); startMuiObserver(); } function stopOsdObserver() { stopMuiObserver(); if (!osdObserver) return; osdObserver.disconnect(); osdObserver = null; observedOsdPage = null; } // Jellyfin 12.1 modern: the MUI video header and the legacy title it is // mirrored from live OUTSIDE #videoOsdPage, so the observer above never // sees them. When the header mounts or the title arrives after the // config (slow devices), nothing inside the OSD page changes on a paused // video and the header settings would stay unapplied. This observer // watches the header box (or, until it exists, the body for its // arrival) and the legacy title while the OSD is active; applyMuiHeader() // only writes on a difference, so its own changes settle at once. const MUI_WAIT_MS = 5000; let muiObserver = null; let muiObserverTarget = null; let muiObserverTimer = null; function startMuiObserver() { if (!isVideoOsdActive()) return; const bar = getMuiBar(); const target = bar ? (bar.closest('.osdHeader') || bar.parentNode) : document.body; if (muiObserver && muiObserverTarget === target) return; stopMuiObserver(); muiObserverTarget = target; muiObserver = new MutationObserver(function () { if (!currentConfig) return; if (!bar && getMuiBar()) { // The header has mounted: narrow the watch to it. startMuiObserver(); } applyTitleDisplay(currentConfig, currentItemInfo); applyMuiHeader(currentConfig); }); muiObserver.observe(target, { childList: true, subtree: true, characterData: !!bar }); const title = document.querySelector('h3.' + TITLE_ID); if (bar && title) muiObserver.observe(title, { childList: true, subtree: true, characterData: true }); if (!bar) { // Legacy layouts and 10.10.7 never mount this header: stop // waiting for it after a few seconds. muiObserverTimer = setTimeout(function () { if (muiObserverTarget === document.body) stopMuiObserver(); }, MUI_WAIT_MS); } } function stopMuiObserver() { if (muiObserverTimer) { clearTimeout(muiObserverTimer); muiObserverTimer = null; } if (!muiObserver) return; muiObserver.disconnect(); muiObserver = null; muiObserverTarget = null; } // Config cache, so the layout is right from the first frame. The fetch // is a network round trip that resolves in a later task, so without // the cache the vanilla arrangement is briefly visible and then // re-shuffles. Jellyfin makes the page visible and dispatches // 'pageshow' in one synchronous chain (viewContainer.js // "classList.remove('hide')" -> viewManager.js // "dispatchEvent('pageshow')"), and browsers paint only after the // current task and its microtasks have run, so whatever the pageshow // handler applies synchronously is on screen from the first painted // frame. Stale-while-revalidate: the cached copy is applied at once, // the fetch (on every video start) refreshes the cache and // re-applies; since every write in applyAll() is change-guarded, an // identical config causes no mutations. Trade-off: the first video // after a settings change briefly shows the cached arrangement, then // corrects. Storage access is try/catch-guarded; without usable // localStorage (or with a corrupt entry) this falls back to // fetch-only. The plugin config is server-wide and localStorage is // per origin (per server), so one shared cache is always correct. const CONFIG_CACHE_KEY = 'jvosd-tc-config-cache'; function readCachedConfig() { try { const raw = localStorage.getItem(CONFIG_CACHE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function writeCachedConfig(cfg) { try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(cfg)); } catch (e) { /* storage unavailable or full: degrade to fetch-only */ } } function onVideoOsdShow() { // Item info always belongs to one item; a new OSD visit starts // without it (the fetch below brings the current one). currentItemInfo = null; titleRawTextForItemInfo = null; itemInfoRequestSeq++; // Synchronous cache pass first: it runs to completion inside the // pageshow dispatch, i.e. before the first paint (see the comment // above CONFIG_CACHE_KEY). applyAll() depends on nothing // asynchronous (no ApiClient use), and startOsdObserver() is // idempotent, so the fetch path below calling both again is // harmless. const cached = readCachedConfig(); if (cached) { currentConfig = cached; applyAll(); startOsdObserver(); } // Fetch fresh on every OSD visit, so settings saved in the admin // panel apply without a full page reload (the single-page app keeps // running). fetchPluginConfig() resolves almost at once here, since // ApiClient exists by now. It also refreshes the cache used above. fetchPluginConfig().then(function (pluginConfig) { if (!pluginConfig) { // No fresh config (no access, plugin missing or server // error). A cached config applied above still needs the // current item's info. if (currentConfig) refreshItemInfoAndReapply(); return; } writeCachedConfig(pluginConfig); currentConfig = pluginConfig; applyAll(); refreshItemInfoAndReapply(); startOsdObserver(); }).catch(function (err) { console.error('[VideoOSD Tweaks and Candy] Core init failed:', err); }); } function onVideoOsdHide(page) { stopOsdObserver(); // Re-run once more so the header elements (Back/Title/Sync/Cast) // correctly un-hide again now that we've left the video page, // isVideoOsdActive() inside applyHeaderButtonHides()/ // applyTitleDisplay() picks up the new state on its own. // The page being left is excluded explicitly, it may not carry // "hide" yet (see leavingOsdPage). leavingOsdPage = page; try { applyAll(); } finally { leavingOsdPage = null; } } document.addEventListener('pageshow', function (e) { if (e.target && e.target.id === OSD_PAGE_ID) { onVideoOsdShow(); } }); document.addEventListener('pagehide', function (e) { if (e.target && e.target.id === OSD_PAGE_ID) { onVideoOsdHide(e.target); } }); // Catches the case where the video OSD was already active by the // time this script's listeners above got attached (e.g. a page // refresh while a video was already playing), so its own earlier // "pageshow" event (which fired before we were listening yet) wasn't // missed. onVideoOsdShow() does its own fresh config fetch, no need // to duplicate that here. if (isVideoOsdActive()) { onVideoOsdShow(); } else { // Before the first video: the header can mount before "pageshow". // The cached config covers a returning browser at once; one fetch // per page load covers the first visit and a config saved since (the // same endpoint the OSD visit uses). Signed out (login page) nothing // is sent and no timer runs: the fetch waits for the first view // change ("viewshow", both versions' viewManager) or hash change // with a token. ensureCoreStyle(); applyMuiPreHides(readCachedConfig()); let preHideFetchStarted = false; const startPreHideFetch = function () { if (preHideFetchStarted) return; if (!window.ApiClient || typeof ApiClient.accessToken !== 'function' || !ApiClient.accessToken()) return; preHideFetchStarted = true; document.removeEventListener('viewshow', startPreHideFetch, true); window.removeEventListener('hashchange', startPreHideFetch); fetchPluginConfig().then(function (pluginConfig) { if (!pluginConfig) return; writeCachedConfig(pluginConfig); applyMuiPreHides(pluginConfig); }).catch(function () { /* the OSD visit fetches again */ }); }; startPreHideFetch(); if (!preHideFetchStarted) { document.addEventListener('viewshow', startPreHideFetch, true); window.addEventListener('hashchange', startPreHideFetch); } } })();