Jellyfin-VideoOSD-Tweaks-Candy-Core-v1.js
Rama main · réplica de lectura del repositorio original.
/*
* 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 <html>. 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);
}
}
})();