feat: Jellyfin Plugin UI Integration #51

Merged
devitq merged 13 commits from feature/jellyfin-plugin-ui-integration-2323592149917875874 into feat/implement-jellyfin-plugin-46 2026-05-22 13:36:25 +00:00
Showing only changes of commit fe4b40c3dd - Show all commits
1
@@ -13,66 +13,142 @@
const showMsg = getAlert(); 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 = `
<div class="detailButton-content">
<span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
</div>
`;
btn.onclick = onClick;
return btn;
}
function injectUI() { function injectUI() {
const headerButtons = document.querySelector('.headerViewButtons, .view-library .content-primary, .home-section .sectionTitleContainer'); // 1. Item Detail Page - Add icon button for rating
const detailButtons = document.querySelector('.mainDetailButtons');
if (headerButtons) { if (detailButtons && !document.querySelector('.btnMovieNightRate')) {
if (!document.querySelector('.btnMovieNightRecommend')) { const itemId = getItemIdFromUrl();
const btn = document.createElement('button'); if (itemId) {
btn.className = 'emby-button raised btnMovieNightRecommend'; const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
btn.innerHTML = '<span>Recommend Film</span>'; e.preventDefault();
btn.style.marginLeft = '1em'; e.stopPropagation();
btn.onclick = showRecommendation; showRatingDialog(itemId);
headerButtons.appendChild(btn); });
} const moreBtn = detailButtons.querySelector('.btnMoreCommands');
if (moreBtn) {
if (!document.querySelector('.btnMovieNightAddMovie')) { detailButtons.insertBefore(rateBtn, moreBtn);
const btn = document.createElement('button'); } else {
btn.className = 'emby-button raised btnMovieNightAddMovie'; detailButtons.appendChild(rateBtn);
btn.innerHTML = '<span>Add Movie (STRM)</span>'; }
btn.style.marginLeft = '1em';
btn.onclick = promptAddMovie;
headerButtons.appendChild(btn);
} }
} }
const detailButtons = document.querySelector('.itemDetailButtons, .itemDetailsButtons'); // 2. Library Pages - Add text buttons to toolbar
if (detailButtons && !document.querySelector('.movieNightRatingContainer')) { const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x');
const itemId = getItemIdFromUrl(); if (toolBar && !document.querySelector('.btnMovieNightRecommend')) {
if (itemId) { toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => {
const container = document.createElement('div'); e.preventDefault();
container.className = 'movieNightRatingContainer'; showRecommendation();
container.style.display = 'inline-flex'; }));
container.style.alignItems = 'center'; toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
container.style.marginLeft = '1em'; e.preventDefault();
promptAddMovie();
}));
}
const label = document.createElement('span'); // 3. Home Page - Prepend a MovieNight section
label.innerText = 'MovieNight: '; const homeSections = document.querySelector('.sections.homeSectionsContainer');
label.style.marginRight = '0.5em'; if (homeSections && !document.querySelector('.movieNightHomeButtons')) {
container.appendChild(label); const section = document.createElement('div');
section.className = 'verticalSection movieNightHomeButtons';
const select = document.createElement('select'); section.style.padding = '0 var(--sidePadding)';
select.className = 'emby-select'; section.innerHTML = '<h2 class="sectionTitle">MovieNight</h2><div class="movieNightBtnContainer" style="display:flex; flex-wrap:wrap;"></div>';
select.style.padding = '0.2em'; const btnContainer = section.querySelector('.movieNightBtnContainer');
for (let i = 0; i <= 10; i++) { btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation));
const opt = document.createElement('option'); btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', promptAddMovie));
opt.value = i; homeSections.insertBefore(section, homeSections.firstChild);
opt.innerText = i === 0 ? 'Rate...' : i;
select.appendChild(opt);
}
select.onchange = (e) => submitRating(itemId, e.target.value);
container.appendChild(select);
detailButtons.appendChild(container);
}
} }
} }
function getItemIdFromUrl() { 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'); 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';
copilot-pull-request-reviewer[bot] commented 2026-05-20 17:42:37 +00:00 (Migrated from github.com)
Review

itemId is taken directly from the page URL and then used as {filmId} in the rating POST path. Elsewhere in the plugin (sync/playback events) Jellyfin ids are serialized using ToString("N") (no dashes), so this can lead to inconsistent identifiers being sent to the backend depending on URL format. Consider normalizing the URL id to the same canonical format before calling the API (and applying encodeURIComponent when interpolating path segments).

`itemId` is taken directly from the page URL and then used as `{filmId}` in the rating POST path. Elsewhere in the plugin (sync/playback events) Jellyfin ids are serialized using `ToString("N")` (no dashes), so this can lead to inconsistent identifiers being sent to the backend depending on URL format. Consider normalizing the URL `id` to the same canonical format before calling the API (and applying `encodeURIComponent` when interpolating path segments).
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() { async function showRecommendation() {
const userId = ApiClient.getCurrentUserId(); const userId = ApiClient.getCurrentUserId();
try { try {
@@ -114,7 +190,6 @@
} }
async function submitRating(itemId, score) { async function submitRating(itemId, score) {
if (score === "0") return;
const userId = ApiClient.getCurrentUserId(); const userId = ApiClient.getCurrentUserId();
try { try {
await ApiClient.ajax({ 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 }); observer.observe(document.body, { childList: true, subtree: true });
injectUI(); injectUI();