Files
Jellyfin-Comments/Jellyfin.Plugin.RedditComments/Web/reddit-comments.js
Gabrieal Jimmy f321976d70 init
2026-07-16 13:14:58 -05:00

482 lines
20 KiB
JavaScript

/*
* 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();
})();