Refactor Plugin UI for Jellyfin 10.11+ #52

Merged
devitq merged 8 commits from fix-ui-components-jellyfin-10-11-8294678353369689429 into feature/jellyfin-plugin-ui-integration-2323592149917875874 2026-05-22 13:36:01 +00:00
Showing only changes of commit fe4b40c3dd - Show all commits
@@ -13,66 +13,142 @@
copilot-pull-request-reviewer[bot] commented 2026-05-20 22:05:46 +00:00 (Migrated from github.com)
Review

createIconButton treats icon (e.g. star_rate) as an extra CSS class on the <span class="material-icons ...">, but Material Icons expects the glyph name as the element text content. As written, the star icon is likely to render blank. Set the span’s textContent/innerText to the icon name (and keep material-icons as the class), or use the same DOM structure Jellyfin uses for detail buttons.

`createIconButton` treats `icon` (e.g. `star_rate`) as an extra CSS class on the `<span class="material-icons ...">`, but Material Icons expects the glyph name as the element text content. As written, the star icon is likely to render blank. Set the span’s textContent/innerText to the icon name (and keep `material-icons` as the class), or use the same DOM structure Jellyfin uses for detail buttons.
copilot-pull-request-reviewer[bot] commented 2026-05-20 22:05:47 +00:00 (Migrated from github.com)
Review

The guard !document.querySelector('.btnMovieNightRecommend') is global, but this script now renders Recommend/Add buttons in multiple places (library toolbar and home section). If the home section is present, this condition will prevent the library toolbar buttons from being injected (and vice versa). Scope the checks to the intended container (e.g., !toolBar.querySelector(...)) and consider checking each button independently so one missing button doesn’t block the other.

The guard `!document.querySelector('.btnMovieNightRecommend')` is global, but this script now renders Recommend/Add buttons in multiple places (library toolbar and home section). If the home section is present, this condition will prevent the library toolbar buttons from being injected (and vice versa). Scope the checks to the intended container (e.g., `!toolBar.querySelector(...)`) and consider checking each button independently so one missing button doesn’t block the other.
copilot-pull-request-reviewer[bot] commented 2026-05-20 22:05:47 +00:00 (Migrated from github.com)
Review

showRatingDialog hard-codes dialog colors (#222 background, white text) and several layout styles inline. This will likely clash with non-default themes and undermines the goal of using native Jellyfin styling. Prefer using Jellyfin dialog/backdrop classes (or CSS variables like --theme-*) and minimal inline styles so the dialog inherits theme styling.

`showRatingDialog` hard-codes dialog colors (`#222` background, `white` text) and several layout styles inline. This will likely clash with non-default themes and undermines the goal of using native Jellyfin styling. Prefer using Jellyfin dialog/backdrop classes (or CSS variables like `--theme-*`) and minimal inline styles so the dialog inherits theme styling.
copilot-pull-request-reviewer[bot] commented 2026-05-20 22:05:47 +00:00 (Migrated from github.com)
Review

createTextButton uses innerHTML to insert the button label even though the content is plain text. Using textContent (and creating the <span> node) avoids unnecessary HTML parsing and reduces XSS risk if this helper is ever reused with non-constant text.

`createTextButton` uses `innerHTML` to insert the button label even though the content is plain text. Using `textContent` (and creating the `<span>` node) avoids unnecessary HTML parsing and reduces XSS risk if this helper is ever reused with non-constant text.
const showMsg = getAlert();
function createTextButton(text, className, onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.is = 'emby-button';
btn.className = `emby-button raised ${className}`;
btn.style.margin = '0.5em';
btn.style.padding = '0.4em 1em';
btn.innerHTML = `<span>${text}</span>`;
btn.onclick = onClick;
return btn;
}
function createIconButton(icon, title, className, onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.is = 'emby-button';
btn.className = `button-flat detailButton emby-button ${className}`;
btn.title = title;
btn.innerHTML = `
copilot-pull-request-reviewer[bot] commented 2026-05-20 22:05:46 +00:00 (Migrated from github.com)
Review

Buttons are created with btn.is = 'emby-button', but is is an HTML attribute (for customized built-in elements) and is not reliably reflected as a JS property. This may result in the element not being upgraded/styled as an emby-button. Prefer setting the attribute (btn.setAttribute('is','emby-button')) or creating the element with the is option, and apply the same fix to the rating-grid buttons.

Buttons are created with `btn.is = 'emby-button'`, but `is` is an HTML attribute (for customized built-in elements) and is not reliably reflected as a JS property. This may result in the element not being upgraded/styled as an `emby-button`. Prefer setting the attribute (`btn.setAttribute('is','emby-button')`) or creating the element with the `is` option, and apply the same fix to the rating-grid buttons.
<div class="detailButton-content">
<span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
</div>
`;
btn.onclick = onClick;
return btn;
}
function injectUI() {
const headerButtons = document.querySelector('.headerViewButtons, .view-library .content-primary, .home-section .sectionTitleContainer');
if (headerButtons) {
if (!document.querySelector('.btnMovieNightRecommend')) {
const btn = document.createElement('button');
btn.className = 'emby-button raised btnMovieNightRecommend';
btn.innerHTML = '<span>Recommend Film</span>';
btn.style.marginLeft = '1em';
btn.onclick = showRecommendation;
headerButtons.appendChild(btn);
}
if (!document.querySelector('.btnMovieNightAddMovie')) {
const btn = document.createElement('button');
btn.className = 'emby-button raised btnMovieNightAddMovie';
btn.innerHTML = '<span>Add Movie (STRM)</span>';
btn.style.marginLeft = '1em';
btn.onclick = promptAddMovie;
headerButtons.appendChild(btn);
// 1. Item Detail Page - Add icon button for rating
const detailButtons = document.querySelector('.mainDetailButtons');
if (detailButtons && !document.querySelector('.btnMovieNightRate')) {
const itemId = getItemIdFromUrl();
if (itemId) {
const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
e.preventDefault();
e.stopPropagation();
showRatingDialog(itemId);
});
const moreBtn = detailButtons.querySelector('.btnMoreCommands');
copilot-pull-request-reviewer[bot] commented 2026-05-20 22:05:47 +00:00 (Migrated from github.com)
Review

The rating button is icon-only; relying on title alone is not sufficient for accessible naming in many screen readers. Add an explicit aria-label (and/or visually-hidden text) so the control is discoverable to assistive tech.

The rating button is icon-only; relying on `title` alone is not sufficient for accessible naming in many screen readers. Add an explicit `aria-label` (and/or visually-hidden text) so the control is discoverable to assistive tech.
if (moreBtn) {
detailButtons.insertBefore(rateBtn, moreBtn);
} else {
detailButtons.appendChild(rateBtn);
}
}
}
const detailButtons = document.querySelector('.itemDetailButtons, .itemDetailsButtons');
if (detailButtons && !document.querySelector('.movieNightRatingContainer')) {
const itemId = getItemIdFromUrl();
if (itemId) {
const container = document.createElement('div');
container.className = 'movieNightRatingContainer';
container.style.display = 'inline-flex';
container.style.alignItems = 'center';
container.style.marginLeft = '1em';
// 2. Library Pages - Add text buttons to toolbar
const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x');
if (toolBar && !document.querySelector('.btnMovieNightRecommend')) {
toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => {
e.preventDefault();
showRecommendation();
}));
toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
e.preventDefault();
promptAddMovie();
}));
}
const label = document.createElement('span');
label.innerText = 'MovieNight: ';
label.style.marginRight = '0.5em';
container.appendChild(label);
const select = document.createElement('select');
select.className = 'emby-select';
select.style.padding = '0.2em';
for (let i = 0; i <= 10; i++) {
const opt = document.createElement('option');
opt.value = i;
opt.innerText = i === 0 ? 'Rate...' : i;
select.appendChild(opt);
}
select.onchange = (e) => submitRating(itemId, e.target.value);
container.appendChild(select);
detailButtons.appendChild(container);
}
// 3. Home Page - Prepend a MovieNight section
const homeSections = document.querySelector('.sections.homeSectionsContainer');
if (homeSections && !document.querySelector('.movieNightHomeButtons')) {
const section = document.createElement('div');
section.className = 'verticalSection movieNightHomeButtons';
section.style.padding = '0 var(--sidePadding)';
section.innerHTML = '<h2 class="sectionTitle">MovieNight</h2><div class="movieNightBtnContainer" style="display:flex; flex-wrap:wrap;"></div>';
const btnContainer = section.querySelector('.movieNightBtnContainer');
btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation));
btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', promptAddMovie));
homeSections.insertBefore(section, homeSections.firstChild);
}
}
function getItemIdFromUrl() {
const params = new URLSearchParams(window.location.search);
const queryString = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : window.location.search;
const params = new URLSearchParams(queryString);
return params.get('id') || params.get('itemId');
}
async function showRatingDialog(itemId) {
const overlay = document.createElement('div');
overlay.className = 'dialogBackdrop dialogBackdropOpened';
overlay.style.zIndex = '99998';
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.right = '0';
overlay.style.bottom = '0';
const dialog = document.createElement('div');
dialog.className = 'dialog';
dialog.style.position = 'fixed';
dialog.style.top = '50%';
dialog.style.left = '50%';
dialog.style.transform = 'translate(-50%, -50%)';
dialog.style.zIndex = '99999';
dialog.style.padding = '2em';
dialog.style.minWidth = '250px';
dialog.style.backgroundColor = '#222';
dialog.style.borderRadius = '1em';
dialog.style.color = 'white';
dialog.innerHTML = `
<h2 style="margin-top:0; text-align:center;">Rate on MovieNight</h2>
<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.5em; margin:1.5em 0;"></div>
<button is="emby-button" class="emby-button button-flat btnCancel" style="width:100%; color: white;">Cancel</button>
`;
const grid = dialog.querySelector('.rating-grid');
for (let i = 1; i <= 10; i++) {
const btn = document.createElement('button');
btn.type = 'button';
btn.is = 'emby-button';
btn.className = 'emby-button raised';
btn.innerText = i;
btn.style.padding = '0.5em';
btn.onclick = async () => {
cleanup();
await submitRating(itemId, i);
};
grid.appendChild(btn);
}
const cleanup = () => {
if (overlay.parentNode) document.body.removeChild(overlay);
};
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
async function showRecommendation() {
const userId = ApiClient.getCurrentUserId();
try {
@@ -114,7 +190,6 @@
}
async function submitRating(itemId, score) {
if (score === "0") return;
const userId = ApiClient.getCurrentUserId();
try {
await ApiClient.ajax({
@@ -130,7 +205,16 @@
}
}
const observer = new MutationObserver(injectUI);
let timeout;
const throttledInject = () => {
if (timeout) return;
timeout = setTimeout(() => {
injectUI();
timeout = null;
}, 100);
};
const observer = new MutationObserver(throttledInject);
observer.observe(document.body, { childList: true, subtree: true });
injectUI();