/* * Jellyfin Reddit Comments plugin - web client script. * Adds a "Reddit comments" button to the video player OSD which opens a sidebar * with the episode's Reddit discussion thread. Nothing is fetched until the * button is clicked. */ (function () { 'use strict'; var BUTTON_ID = 'redditCommentsOsdButton'; var SIDEBAR_ID = 'reddit-comments-sidebar'; var STYLE_ID = 'reddit-comments-style'; var ACCENT = '#a55aea'; var loadedItemId = null; var sidebarOpen = false; // --------------------------------------------------------------------- // API helpers // --------------------------------------------------------------------- function getCredentials() { // Primary path: the web client's global ApiClient. if (window.ApiClient && typeof window.ApiClient.getUrl === 'function') { var deviceId = window.ApiClient.deviceId; if (typeof deviceId === 'function') { deviceId = window.ApiClient.deviceId(); } return { getUrl: function (path) { return window.ApiClient.getUrl(path); }, token: window.ApiClient.accessToken ? window.ApiClient.accessToken() : null, deviceId: deviceId || null }; } // Fallback: read stored credentials directly. try { var raw = localStorage.getItem('jellyfin_credentials') || localStorage.getItem('jellyfin-credentials'); if (!raw) { return null; } var creds = JSON.parse(raw); var server = (creds.Servers || [])[0]; if (!server || !server.AccessToken) { return null; } var address = server.ManualAddress || server.LocalAddress; if (!address) { return null; } address = address.replace(/\/$/, ''); return { getUrl: function (path) { return address + '/' + path; }, token: server.AccessToken, deviceId: localStorage.getItem('_deviceId') || null }; } catch (e) { return null; } } function apiFetch(path, options) { var creds = getCredentials(); if (!creds) { return Promise.reject(new Error('Could not determine Jellyfin API credentials.')); } return fetch(creds.getUrl(path), { method: (options && options.method) || 'GET', headers: { 'X-Emby-Token': creds.token } }).then(function (res) { if (!res.ok) { throw new Error('Request failed with HTTP ' + res.status); } return res.json(); }); } function getCurrentItemId() { return apiFetch('Sessions?activeWithinSeconds=960').then(function (sessions) { var creds = getCredentials(); var session = null; if (creds && creds.deviceId) { session = sessions.find(function (s) { return s.DeviceId === creds.deviceId && s.NowPlayingItem; }); } if (!session) { session = sessions.find(function (s) { return s.NowPlayingItem; }); } return session && session.NowPlayingItem ? session.NowPlayingItem.Id : null; }); } // --------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------- function timeAgo(date) { var seconds = Math.floor((Date.now() - date.getTime()) / 1000); if (seconds < 60) { return 'just now'; } var minutes = Math.floor(seconds / 60); if (minutes < 60) { return minutes + 'm ago'; } var hours = Math.floor(minutes / 60); if (hours < 24) { return hours + 'h ago'; } var days = Math.floor(hours / 24); if (days < 30) { return days + 'd ago'; } var months = Math.floor(days / 30); if (months < 12) { return months + 'mo ago'; } return Math.floor(months / 12) + 'y ago'; } function formatScore(score) { if (score >= 1000) { return (score / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; } return String(score); } function el(tag, className, text) { var node = document.createElement(tag); if (className) { node.className = className; } if (text !== undefined && text !== null) { node.textContent = text; } return node; } function svgIcon(pathData, size) { var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', String(size || 20)); svg.setAttribute('height', String(size || 20)); svg.setAttribute('fill', 'currentColor'); var path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', pathData); svg.appendChild(path); return svg; } var ICONS = { chat: 'M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z', close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z', refresh: 'M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z', upvote: 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z', external: 'M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z' }; // --------------------------------------------------------------------- // Sidebar // --------------------------------------------------------------------- function injectStyles() { if (document.getElementById(STYLE_ID)) { return; } var style = document.createElement('style'); style.id = STYLE_ID; style.textContent = [ /* ---- panel ---- */ '#' + SIDEBAR_ID + ' { position: fixed; top: 0; right: 0; height: 100%; width: min(440px, 94vw);', ' background: #101010; color: #e8e8e8; z-index: 100000; display: flex; flex-direction: column;', ' box-shadow: -8px 0 32px rgba(0,0,0,0.65);', ' font-family: inherit; font-size: 14px;', ' transform: translateX(105%); transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); }', '#' + SIDEBAR_ID + '.rcs-open { transform: translateX(0); }', /* ---- header ---- */ '#' + SIDEBAR_ID + ' .rcs-header { padding: 14px 16px 12px; background: #181818;', ' border-bottom: 1px solid rgba(255,255,255,0.07); display: flex; align-items: flex-start; gap: 6px; }', '#' + SIDEBAR_ID + ' .rcs-title-wrap { flex: 1; min-width: 0; }', '#' + SIDEBAR_ID + ' .rcs-heading { display: flex; align-items: center; gap: 7px; font-weight: 700;', ' font-size: 12px; text-transform: uppercase; letter-spacing: 0.09em; color: ' + ACCENT + '; margin-bottom: 7px; }', '#' + SIDEBAR_ID + ' .rcs-thread-link { display: block; color: #fff; font-weight: 600; font-size: 14.5px;', ' line-height: 1.35; text-decoration: none; margin-bottom: 8px; word-wrap: break-word; }', '#' + SIDEBAR_ID + ' .rcs-thread-link:hover { color: ' + ACCENT + '; }', '#' + SIDEBAR_ID + ' .rcs-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }', '#' + SIDEBAR_ID + ' .rcs-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px;', ' border-radius: 999px; font-size: 11.5px; font-weight: 600; line-height: 1.4;', ' background: rgba(165,90,234,0.14); color: #cf9df5; }', '#' + SIDEBAR_ID + ' .rcs-chip.rcs-chip-muted { background: rgba(255,255,255,0.06); color: #999; font-weight: 500; }', '#' + SIDEBAR_ID + ' .rcs-chip svg { opacity: 0.85; }', /* ---- header buttons (jellyfin-style round icon buttons) ---- */ '#' + SIDEBAR_ID + ' .rcs-icon-btn { flex-shrink: 0; width: 36px; height: 36px; display: inline-flex;', ' align-items: center; justify-content: center; background: transparent; border: none; border-radius: 50%;', ' color: #aaa; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }', '#' + SIDEBAR_ID + ' .rcs-icon-btn:hover { color: #fff; background: rgba(165,90,234,0.18); }', '#' + SIDEBAR_ID + ' .rcs-icon-btn:active { background: rgba(165,90,234,0.3); }', '#' + SIDEBAR_ID + ' .rcs-icon-btn.rcs-spinning svg { animation: rcs-spin 0.9s linear infinite; }', /* ---- body ---- */ '#' + SIDEBAR_ID + ' .rcs-body { flex: 1; overflow-y: auto; padding: 8px 12px 40px; scrollbar-width: thin;', ' scrollbar-color: #333 transparent; }', '#' + SIDEBAR_ID + ' .rcs-body::-webkit-scrollbar { width: 8px; }', '#' + SIDEBAR_ID + ' .rcs-body::-webkit-scrollbar-thumb { background: #2e2e2e; border-radius: 4px; }', '#' + SIDEBAR_ID + ' .rcs-body::-webkit-scrollbar-thumb:hover { background: rgba(165,90,234,0.5); }', /* ---- empty / loading states ---- */ '#' + SIDEBAR_ID + ' .rcs-state { display: flex; flex-direction: column; align-items: center;', ' text-align: center; margin-top: 64px; padding: 0 24px; color: #888; line-height: 1.55; }', '#' + SIDEBAR_ID + ' .rcs-state svg { color: rgba(165,90,234,0.45); margin-bottom: 14px; }', '#' + SIDEBAR_ID + ' .rcs-spinner { margin: 64px auto 0; width: 36px; height: 36px;', ' border: 3px solid rgba(165,90,234,0.15); border-top-color: ' + ACCENT + ';', ' border-radius: 50%; animation: rcs-spin 0.9s linear infinite; }', '@keyframes rcs-spin { to { transform: rotate(360deg); } }', /* ---- comments ---- */ '#' + SIDEBAR_ID + ' .rcs-comment { margin: 2px 0; }', '#' + SIDEBAR_ID + ' .rcs-comment-inner { padding: 8px 10px; border-radius: 8px;', ' transition: background 0.12s ease; }', '#' + SIDEBAR_ID + ' .rcs-comment-inner:hover { background: rgba(255,255,255,0.035); }', '#' + SIDEBAR_ID + ' .rcs-comment-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px;', ' font-size: 12px; color: #8b8b8b; margin-bottom: 4px; cursor: pointer; user-select: none; }', '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-author { color: #cf9df5; font-weight: 700; }', '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-score { display: inline-flex; align-items: center; gap: 1px; color: #b0b0b0; }', '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-score svg { color: ' + ACCENT + '; }', '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-dot { color: #4a4a4a; }', '#' + SIDEBAR_ID + ' .rcs-comment-meta .rcs-collapse { margin-left: auto; padding: 0 8px; border-radius: 999px;', ' font-size: 11px; font-weight: 600; color: #cf9df5; background: rgba(165,90,234,0.12); }', '#' + SIDEBAR_ID + ' .rcs-comment-body { white-space: pre-wrap; word-wrap: break-word;', ' overflow-wrap: anywhere; line-height: 1.5; color: #dcdcdc; }', '#' + SIDEBAR_ID + ' .rcs-replies { margin-left: 6px; padding-left: 10px;', ' border-left: 2px solid rgba(165,90,234,0.22); }', '#' + SIDEBAR_ID + ' .rcs-replies .rcs-replies { border-left-color: rgba(165,90,234,0.13); }', '#' + SIDEBAR_ID + ' .rcs-collapsed > .rcs-replies,', '#' + SIDEBAR_ID + ' .rcs-collapsed > .rcs-comment-inner > .rcs-comment-body { display: none; }' ].join('\n'); document.head.appendChild(style); } function getSidebar() { var sidebar = document.getElementById(SIDEBAR_ID); if (sidebar) { return sidebar; } injectStyles(); sidebar = el('div'); sidebar.id = SIDEBAR_ID; var header = el('div', 'rcs-header'); var titleWrap = el('div', 'rcs-title-wrap'); var heading = el('div', 'rcs-heading'); heading.appendChild(svgIcon(ICONS.chat, 15)); heading.appendChild(document.createTextNode('Reddit Comments')); titleWrap.appendChild(heading); var threadLink = el('a', 'rcs-thread-link'); threadLink.target = '_blank'; threadLink.rel = 'noopener noreferrer'; titleWrap.appendChild(threadLink); titleWrap.appendChild(el('div', 'rcs-chips')); header.appendChild(titleWrap); var refreshBtn = el('button', 'rcs-icon-btn rcs-refresh'); refreshBtn.title = 'Refresh comments'; refreshBtn.appendChild(svgIcon(ICONS.refresh, 19)); refreshBtn.addEventListener('click', function () { if (loadedItemId && !refreshBtn.classList.contains('rcs-spinning')) { refreshBtn.classList.add('rcs-spinning'); loadComments(loadedItemId, true); } }); header.appendChild(refreshBtn); var closeBtn = el('button', 'rcs-icon-btn rcs-close'); closeBtn.title = 'Close'; closeBtn.appendChild(svgIcon(ICONS.close, 19)); closeBtn.addEventListener('click', closeSidebar); header.appendChild(closeBtn); sidebar.appendChild(header); sidebar.appendChild(el('div', 'rcs-body')); document.body.appendChild(sidebar); return sidebar; } function setBodyContent(node) { var body = getSidebar().querySelector('.rcs-body'); body.innerHTML = ''; body.appendChild(node); } function showMessage(text) { var state = el('div', 'rcs-state'); state.appendChild(svgIcon(ICONS.chat, 44)); state.appendChild(el('div', null, text)); setBodyContent(state); } function showSpinner() { setBodyContent(el('div', 'rcs-spinner')); } function renderComment(comment) { var wrapper = el('div', 'rcs-comment'); var inner = el('div', 'rcs-comment-inner'); var meta = el('div', 'rcs-comment-meta'); meta.appendChild(el('span', 'rcs-author', comment.Author)); meta.appendChild(el('span', 'rcs-dot', '\u2022')); var score = el('span', 'rcs-score'); score.appendChild(svgIcon(ICONS.upvote, 13)); score.appendChild(document.createTextNode(formatScore(comment.Score))); meta.appendChild(score); meta.appendChild(el('span', 'rcs-dot', '\u2022')); meta.appendChild(el('span', null, timeAgo(new Date(comment.CreatedUtc * 1000)))); inner.appendChild(meta); inner.appendChild(el('div', 'rcs-comment-body', comment.Body)); wrapper.appendChild(inner); if (comment.Replies && comment.Replies.length > 0) { var collapse = el('span', 'rcs-collapse', '\u2212'); meta.appendChild(collapse); var replies = el('div', 'rcs-replies'); comment.Replies.forEach(function (reply) { replies.appendChild(renderComment(reply)); }); wrapper.appendChild(replies); meta.addEventListener('click', function () { wrapper.classList.toggle('rcs-collapsed'); collapse.textContent = wrapper.classList.contains('rcs-collapsed') ? '+' : '\u2212'; }); } return wrapper; } function makeChip(text, icon, muted) { var chip = el('span', 'rcs-chip' + (muted ? ' rcs-chip-muted' : '')); if (icon) { chip.appendChild(svgIcon(icon, 12)); } chip.appendChild(document.createTextNode(text)); return chip; } function renderResponse(data) { var sidebar = getSidebar(); sidebar.querySelector('.rcs-refresh').classList.remove('rcs-spinning'); var threadLink = sidebar.querySelector('.rcs-thread-link'); var chips = sidebar.querySelector('.rcs-chips'); chips.innerHTML = ''; if (!data.Found) { threadLink.textContent = data.MediaLabel || ''; threadLink.removeAttribute('href'); showMessage(data.Message || 'No Reddit discussion thread was found for this title.'); return; } threadLink.textContent = data.ThreadTitle; threadLink.href = 'https://www.reddit.com' + data.Permalink; chips.appendChild(makeChip('r/' + data.Subreddit, null, false)); chips.appendChild(makeChip(formatScore(data.ThreadScore) + ' points', ICONS.upvote, false)); chips.appendChild(makeChip(data.NumComments + ' comments', ICONS.chat, false)); chips.appendChild(makeChip( 'fetched ' + timeAgo(new Date(data.FetchedAt)) + (data.Cached ? ' \u2022 cached' : ''), null, true)); if (!data.Comments || data.Comments.length === 0) { showMessage('The thread was found, but it has no comments to show.'); return; } var container = el('div'); data.Comments.forEach(function (comment) { container.appendChild(renderComment(comment)); }); setBodyContent(container); } function loadComments(itemId, forceRefresh) { showSpinner(); var request = forceRefresh ? apiFetch('RedditComments/Item/' + itemId + '/Refresh', { method: 'POST' }) : apiFetch('RedditComments/Item/' + itemId); request.then(function (data) { loadedItemId = itemId; renderResponse(data); }).catch(function (err) { getSidebar().querySelector('.rcs-refresh').classList.remove('rcs-spinning'); showMessage('Failed to load Reddit comments: ' + err.message); }); } function openSidebar() { sidebarOpen = true; getSidebar().classList.add('rcs-open'); getCurrentItemId().then(function (itemId) { if (!itemId) { showMessage('Nothing is playing right now.'); return; } if (itemId === loadedItemId) { return; // already showing this item } loadComments(itemId, false); }).catch(function (err) { showMessage('Could not determine the currently playing item: ' + err.message); }); } function closeSidebar() { sidebarOpen = false; var sidebar = document.getElementById(SIDEBAR_ID); if (sidebar) { sidebar.classList.remove('rcs-open'); } } function toggleSidebar() { if (sidebarOpen) { closeSidebar(); } else { openSidebar(); } } // --------------------------------------------------------------------- // OSD button // --------------------------------------------------------------------- function findOsdControls() { return document.querySelector('.videoOsdBottom .buttons') || document.querySelector('.videoOsdBottom-maincontrols') || document.querySelector('.videoOsdBottom'); } function ensureButton() { if (document.getElementById(BUTTON_ID)) { return; } var controls = findOsdControls(); if (!controls) { return; } var button = document.createElement('button'); button.id = BUTTON_ID; button.type = 'button'; button.className = 'paper-icon-button-light autoSize'; button.title = 'Reddit comments'; var icon = svgIcon(ICONS.chat, 24); icon.style.pointerEvents = 'none'; button.appendChild(icon); button.addEventListener('click', function (e) { e.stopPropagation(); toggleSidebar(); }); var fullscreenBtn = controls.querySelector('.btnFullscreen'); if (fullscreenBtn && fullscreenBtn.parentNode === controls) { controls.insertBefore(button, fullscreenBtn); } else { controls.appendChild(button); } } function maybeAutoClose() { // Close the sidebar when playback ends (the video OSD leaves the DOM). if (sidebarOpen && !document.querySelector('.videoOsdBottom')) { closeSidebar(); } } var observer = new MutationObserver(function () { ensureButton(); maybeAutoClose(); }); observer.observe(document.body, { childList: true, subtree: true }); ensureButton(); })();