jRay had no requirement IDs, so nothing in this repo could be traced to and the CI gate had no denominator to read. The other two components had already moved to registers; this brings the plugin level with them. Adds docs/requirements.md with 46 permanent JR-nnn IDs, each carrying a parent requirement, priority, status and verification tier, plus a per-requirement verification plan. JR is flat rather than split by theme: the plugin is one deployable with one audience, and JRay-public-server already ships UR/DR, so a second repo using those prefixes would make UR-007 ambiguous across registers. Rewrites SPEC.md as requirements prose with Current:/Gap: on every one. It had drifted into a format-plus-API reference that documented schema_version 1 while owning a format whose v2 shape was specified only in the other two repos, said nothing about SR-002's scene-scoped semantics, and carried the manifest exchange as a "planned" aside while its configuration classes were already implemented. Plugin-side exchange obligations move here from the server's spec, where they were an ownership inversion. Adds JR-038..041 for PR-005, which had no software row in any repo -- it was held structurally by SR-004 and GR-005 both being prohibitions, and a goal preserved only by prohibitions is the kind that erodes unnoticed. jRay is the component that actually opens a socket. Tags 18 units with the requirements they satisfy. Tags name what the code satisfies, so FileTransformationRegistration is not tagged JR-021: that requirement is a prohibition and was still violated elsewhere when this was written. Vendors jray-project as a submodule for the system spec and shared gate. TRACES: JR-001, JR-004, JR-005, JR-007, JR-008, JR-009, JR-010, JR-011 TRACES: JR-012, JR-013, JR-014, JR-015, JR-016, JR-017, JR-018, JR-019 TRACES: JR-020, JR-024, JR-025, JR-036, JR-038 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
12 KiB
JavaScript
344 lines
12 KiB
JavaScript
/*
|
|
* JRay pause overlay: lists the cast of the scene the viewer paused in.
|
|
*
|
|
* Presence is scene-scoped. An actor who has turned away, is occluded, or is
|
|
* off-camera during a reverse shot is still in the scene, so this must not be
|
|
* presented as "who is visible right now" — that is a different, and weaker,
|
|
* claim than the data makes.
|
|
*
|
|
* Every server-supplied string is written with textContent, never innerHTML.
|
|
* With the manifest exchange these strings may originate from a third-party
|
|
* server, and this is the one control that holds even if every other check is
|
|
* bypassed.
|
|
*
|
|
* TRACES: JR-005, JR-020, JR-024 | SR-002, SR-004
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var POLL_INTERVAL_MS = 1000;
|
|
var OVERVIEW_MAX_LENGTH = 160;
|
|
var overlayEl = null;
|
|
var detailEl = null;
|
|
var personCache = {};
|
|
|
|
function truncate(text, maxLength) {
|
|
if (text.length <= maxLength) {
|
|
return text;
|
|
}
|
|
|
|
return text.slice(0, maxLength).trim() + '…';
|
|
}
|
|
|
|
function getPerson(jellyfinId) {
|
|
if (personCache[jellyfinId]) {
|
|
return personCache[jellyfinId];
|
|
}
|
|
|
|
var promise = window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), jellyfinId)
|
|
.catch(function () {
|
|
return null;
|
|
});
|
|
|
|
personCache[jellyfinId] = promise;
|
|
return promise;
|
|
}
|
|
|
|
function getNowPlaying() {
|
|
if (!window.ApiClient) {
|
|
return Promise.reject(new Error('no ApiClient'));
|
|
}
|
|
|
|
var url = window.ApiClient.getUrl('Sessions', { DeviceId: window.ApiClient.deviceId() });
|
|
return window.ApiClient.ajax({ url: url, type: 'GET', dataType: 'json' }).then(function (sessions) {
|
|
var session = sessions && sessions[0];
|
|
if (!session || !session.NowPlayingItem || !session.PlayState) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
itemId: session.NowPlayingItem.Id,
|
|
positionSeconds: (session.PlayState.PositionTicks || 0) / 10000000
|
|
};
|
|
});
|
|
}
|
|
|
|
function removeDetail() {
|
|
if (detailEl && detailEl.parentNode) {
|
|
detailEl.parentNode.removeChild(detailEl);
|
|
}
|
|
|
|
detailEl = null;
|
|
document.removeEventListener('keydown', onDetailKeydown, true);
|
|
}
|
|
|
|
function removeOverlay() {
|
|
removeDetail();
|
|
|
|
if (overlayEl && overlayEl.parentNode) {
|
|
overlayEl.parentNode.removeChild(overlayEl);
|
|
}
|
|
|
|
overlayEl = null;
|
|
}
|
|
|
|
function onDetailKeydown(event) {
|
|
// Back button on TV remotes / keyboards maps to Escape / Backspace.
|
|
if (event.key === 'Escape' || event.key === 'Backspace' || event.keyCode === 27 || event.keyCode === 8) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
removeDetail();
|
|
}
|
|
}
|
|
|
|
// A larger, closeable "pop-up" card shown over the video when an actor is
|
|
// clicked. It stays inside the player so playback position is never lost.
|
|
function showDetail(container, actor, person) {
|
|
removeDetail();
|
|
|
|
detailEl = document.createElement('div');
|
|
detailEl.className = 'jrayActorDetail';
|
|
detailEl.style.position = 'absolute';
|
|
detailEl.style.top = '0';
|
|
detailEl.style.left = '0';
|
|
detailEl.style.right = '0';
|
|
detailEl.style.bottom = '0';
|
|
detailEl.style.zIndex = '10000';
|
|
detailEl.style.display = 'flex';
|
|
detailEl.style.alignItems = 'center';
|
|
detailEl.style.justifyContent = 'center';
|
|
detailEl.style.background = 'rgba(0, 0, 0, 0.6)';
|
|
detailEl.style.pointerEvents = 'auto';
|
|
|
|
// Click on the dimmed backdrop closes the pop-up.
|
|
detailEl.addEventListener('click', function (event) {
|
|
if (event.target === detailEl) {
|
|
removeDetail();
|
|
}
|
|
});
|
|
|
|
var panel = document.createElement('div');
|
|
panel.style.position = 'relative';
|
|
panel.style.display = 'flex';
|
|
panel.style.gap = '24px';
|
|
panel.style.maxWidth = '720px';
|
|
panel.style.width = '80%';
|
|
panel.style.maxHeight = '80%';
|
|
panel.style.overflowY = 'auto';
|
|
panel.style.background = 'rgba(20, 20, 20, 0.96)';
|
|
panel.style.color = '#fff';
|
|
panel.style.padding = '24px';
|
|
panel.style.borderRadius = '10px';
|
|
panel.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.6)';
|
|
|
|
if (person && person.ImageTags && person.ImageTags.Primary) {
|
|
var img = document.createElement('img');
|
|
img.src = window.ApiClient.getImageUrl(actor.jellyfin_id, {
|
|
type: 'Primary',
|
|
maxHeight: 400,
|
|
tag: person.ImageTags.Primary
|
|
});
|
|
img.style.height = '300px';
|
|
img.style.width = 'auto';
|
|
img.style.borderRadius = '8px';
|
|
img.style.objectFit = 'cover';
|
|
img.style.flexShrink = '0';
|
|
panel.appendChild(img);
|
|
}
|
|
|
|
var text = document.createElement('div');
|
|
text.style.flex = '1';
|
|
|
|
var name = document.createElement('div');
|
|
name.style.fontWeight = 'bold';
|
|
name.style.fontSize = '24px';
|
|
name.style.marginBottom = '12px';
|
|
name.textContent = actor.name;
|
|
text.appendChild(name);
|
|
|
|
if (person && person.Overview) {
|
|
var overview = document.createElement('div');
|
|
overview.style.fontSize = '15px';
|
|
overview.style.lineHeight = '1.5';
|
|
overview.style.opacity = '0.9';
|
|
overview.textContent = person.Overview;
|
|
text.appendChild(overview);
|
|
}
|
|
|
|
panel.appendChild(text);
|
|
|
|
var closeBtn = document.createElement('button');
|
|
closeBtn.type = 'button';
|
|
closeBtn.setAttribute('aria-label', 'Close');
|
|
closeBtn.textContent = '✕';
|
|
closeBtn.style.position = 'absolute';
|
|
closeBtn.style.top = '8px';
|
|
closeBtn.style.right = '8px';
|
|
closeBtn.style.width = '32px';
|
|
closeBtn.style.height = '32px';
|
|
closeBtn.style.border = 'none';
|
|
closeBtn.style.borderRadius = '50%';
|
|
closeBtn.style.background = 'rgba(255, 255, 255, 0.15)';
|
|
closeBtn.style.color = '#fff';
|
|
closeBtn.style.fontSize = '16px';
|
|
closeBtn.style.cursor = 'pointer';
|
|
closeBtn.addEventListener('click', function (event) {
|
|
event.stopPropagation();
|
|
removeDetail();
|
|
});
|
|
panel.appendChild(closeBtn);
|
|
|
|
detailEl.appendChild(panel);
|
|
container.appendChild(detailEl);
|
|
|
|
// Back button (Escape/Backspace) closes the pop-up first.
|
|
document.addEventListener('keydown', onDetailKeydown, true);
|
|
}
|
|
|
|
function showOverlay(video, actors) {
|
|
removeOverlay();
|
|
|
|
if (!actors || actors.length === 0) {
|
|
return;
|
|
}
|
|
|
|
var container = video.parentElement;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
overlayEl = document.createElement('div');
|
|
overlayEl.className = 'jrayOverlay';
|
|
overlayEl.style.position = 'absolute';
|
|
overlayEl.style.bottom = '10%';
|
|
overlayEl.style.left = '2%';
|
|
overlayEl.style.zIndex = '9999';
|
|
overlayEl.style.display = 'flex';
|
|
overlayEl.style.flexWrap = 'wrap';
|
|
overlayEl.style.gap = '12px';
|
|
overlayEl.style.pointerEvents = 'none';
|
|
|
|
actors.forEach(function (actor) {
|
|
var card = document.createElement('div');
|
|
card.className = 'jrayActorCard';
|
|
card.style.background = 'rgba(0, 0, 0, 0.75)';
|
|
card.style.color = '#fff';
|
|
card.style.padding = '8px 12px';
|
|
card.style.borderRadius = '6px';
|
|
card.style.display = 'flex';
|
|
card.style.alignItems = 'center';
|
|
card.style.gap = '10px';
|
|
card.style.maxWidth = '360px';
|
|
|
|
var textBlock = document.createElement('div');
|
|
|
|
var name = document.createElement('div');
|
|
name.style.fontWeight = 'bold';
|
|
name.style.fontSize = '16px';
|
|
name.textContent = actor.name;
|
|
textBlock.appendChild(name);
|
|
|
|
card.appendChild(textBlock);
|
|
overlayEl.appendChild(card);
|
|
|
|
if (!actor.jellyfin_id || !window.ApiClient) {
|
|
return;
|
|
}
|
|
|
|
card.style.pointerEvents = 'auto';
|
|
card.style.cursor = 'pointer';
|
|
|
|
getPerson(actor.jellyfin_id).then(function (person) {
|
|
if (!person || !overlayEl || !overlayEl.contains(card)) {
|
|
return;
|
|
}
|
|
|
|
// Clicking the card opens the in-player detail pop-up rather than
|
|
// navigating away — this keeps the current playback position.
|
|
card.addEventListener('click', function (event) {
|
|
event.stopPropagation();
|
|
showDetail(container, actor, person);
|
|
});
|
|
|
|
if (person.ImageTags && person.ImageTags.Primary) {
|
|
var img = document.createElement('img');
|
|
img.src = window.ApiClient.getImageUrl(actor.jellyfin_id, {
|
|
type: 'Primary',
|
|
maxHeight: 120,
|
|
tag: person.ImageTags.Primary
|
|
});
|
|
img.style.height = '120px';
|
|
img.style.width = 'auto';
|
|
img.style.borderRadius = '4px';
|
|
img.style.objectFit = 'cover';
|
|
card.insertBefore(img, textBlock);
|
|
}
|
|
|
|
if (person.Overview) {
|
|
var overview = document.createElement('div');
|
|
overview.style.fontSize = '12px';
|
|
overview.style.opacity = '0.85';
|
|
overview.style.marginTop = '4px';
|
|
overview.textContent = truncate(person.Overview, OVERVIEW_MAX_LENGTH);
|
|
textBlock.appendChild(overview);
|
|
}
|
|
});
|
|
});
|
|
|
|
container.appendChild(overlayEl);
|
|
}
|
|
|
|
function fetchContext(itemId, t) {
|
|
if (!window.ApiClient) {
|
|
return;
|
|
}
|
|
|
|
var url = window.ApiClient.getUrl('Plugins/JRay/Items/' + itemId + '/jray', { t: t });
|
|
window.ApiClient.ajax({ url: url, type: 'GET', dataType: 'json' }).then(function (context) {
|
|
var video = document.querySelector('video');
|
|
if (!video || !video.paused) {
|
|
return;
|
|
}
|
|
|
|
showOverlay(video, context.actors);
|
|
}, function () {
|
|
// No truth data (404) or request error - fail silently, never break playback.
|
|
});
|
|
}
|
|
|
|
function onPause() {
|
|
getNowPlaying().then(function (info) {
|
|
if (info) {
|
|
fetchContext(info.itemId, info.positionSeconds);
|
|
}
|
|
}, function () {
|
|
// ApiClient unavailable or request error - fail silently, never break playback.
|
|
});
|
|
}
|
|
|
|
function onPlay() {
|
|
removeOverlay();
|
|
}
|
|
|
|
function attach(video) {
|
|
if (video.dataset.jrayAttached) {
|
|
return;
|
|
}
|
|
|
|
video.dataset.jrayAttached = 'true';
|
|
video.addEventListener('pause', onPause);
|
|
video.addEventListener('play', onPlay);
|
|
video.addEventListener('playing', onPlay);
|
|
video.addEventListener('seeking', removeOverlay);
|
|
}
|
|
|
|
setInterval(function () {
|
|
var video = document.querySelector('video');
|
|
if (video) {
|
|
attach(video);
|
|
} else {
|
|
removeOverlay();
|
|
}
|
|
}, POLL_INTERVAL_MS);
|
|
})();
|