move the ShareLink action to its own section at the bottom of the item menu

The button used to be cloned in next to "Copy Stream URL", or shoved at the top
of the menu when there was none, which put it in the middle of Jellyfin's own
command groups. Now there is a single injection path: append an actionsheetDivider
plus the action at the end of the sheet's scroller, so it reads as its own
section the way Jellyfin separates its groups.

It also stopped appearing on menus for things that are not media. The old code
resolved the item from the URL, so opening the "..." menu on a cast member inside
a movie page showed ShareLink and would have shared the movie behind it. The item
is now taken from the trigger that opened the menu (card, list row or the detail
page's own button, all of which we can walk up from) and its type is checked
against the server before anything is injected: movie, series, season, episode,
nothing else. The API refuses everything else too, so a hand-rolled request
cannot tag a person or a playlist either.

Clicking the action now dismisses the action sheet instead of leaving it stacked
under the dialog.
Cette révision appartient à :
Franciskid
2026-07-26 16:21:05 +02:00
Parent 36fc574d49
révision 35631eae46
5 fichiers modifiés avec 253 ajouts et 123 suppressions
+224 -112
Voir le fichier
@@ -2,7 +2,7 @@
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
var copyLabel = 'Copy Stream URL';
var actionLabel = 'ShareLink';
var clientVersion = '1.0.1-ui-2';
var clientVersion = '1.0.2-ui-1';
var allowedItemStorageKey = 'sharelinks.allowedItemId';
var guestClassName = 'sharelinks-guest';
var hiddenAttr = 'data-sharelinks-hidden';
@@ -14,15 +14,22 @@
var scanQueued = false;
var bootRetry = null;
var observer = null;
var lastContextItemId = null;
var lastContextItemTs = 0;
var menuContext = null;
var shareableItemTypes = ['movie', 'series', 'season', 'episode'];
var menuTriggerSelector = '[data-action="menu"], .btnMoreCommands, .btnCardOptions, .cardOverlayButton';
var itemTypeCache = {};
var itemTypeInFlight = {};
var historyPatched = false;
var itemMenuActionIds = [
'moreinfo', 'mediainfo', 'editmetadata', 'editimages', 'editsubtitles',
'editlyrics', 'identify', 'refreshmetadata', 'refresh', 'playlist',
'addtoplaylist', 'addtocollection', 'instantmix', 'shuffle', 'resume',
'copy-stream', 'copystream', 'share', 'download', 'delete',
'edit'
'resume', 'playallfromhere', 'queue', 'queuenext', 'shuffle', 'instantmix',
'multiselect', 'addtocollection', 'addtoplaylist', 'editplaylist',
'removefromcollection', 'removefromplaylist', 'movetotop', 'movetobottom',
'download', 'downloadall', 'copy-stream', 'delete', 'edit', 'editimages',
'editsubtitles', 'editlyrics', 'identify', 'moremediainfo', 'refresh',
'record', 'canceltimer', 'cancelseriestimer', 'share', 'album', 'artist',
'lyrics',
// Older Jellyfin web builds used these ids for the same commands.
'moreinfo', 'mediainfo', 'editmetadata', 'playlist', 'copystream'
];
var durationOptions = [
{ label: '1 hour', hours: 1 },
@@ -142,16 +149,7 @@
window.addEventListener('popstate', scheduleWork, true);
document.addEventListener('pointerdown', function (event) {
var node = event.target;
while (node && node !== document) {
var id = readItemIdFromNode(node);
if (id) {
lastContextItemId = id;
lastContextItemTs = Date.now();
return;
}
node = node.parentElement;
}
rememberMenuContext(event.target);
}, true);
observer = new MutationObserver(scheduleWork);
@@ -441,17 +439,123 @@
return;
}
var copyNodes = [];
Array.from(document.querySelectorAll('button, a, [role="menuitem"], [role="option"], .actionsheetMenuItem, .paperListButton')).forEach(function (node) {
if (isCopyStreamUrlLabel(getVisibleLabel(node))) {
copyNodes.push(node);
insertActionAfter(node);
}
});
if (copyNodes.length === 0) {
insertIntoOpenMenuFallback();
var container = findOpenActionContainer();
if (!container || container.querySelector('[' + injectedAttr + '="1"]')) {
return;
}
var itemId = resolveMenuItemId();
if (!itemId || isShareableItem(itemId) !== true) {
// Either this is not an item menu we can share from (a person, a
// studio, a library...) or the type lookup is still running.
// fetchItemType() re-runs this scan once the verdict is known.
return;
}
appendActionSection(container, itemId);
}
/**
* Records which item's "more" menu is about to open. The action sheet is a
* detached, body-level element with no link back to the card or row it was
* opened from, so the trigger that was just pressed is the only reliable
* signal. Any other pointerdown clears the context, so a stale item can never
* be picked up by a later menu.
*/
function rememberMenuContext(target) {
var node = target;
while (node && node !== document) {
if (isMenuTrigger(node)) {
menuContext = {
itemId: findItemIdInAncestors(node),
isPageMenu: !!(node.classList && node.classList.contains('btnMoreCommands')),
ts: Date.now()
};
return;
}
node = node.parentElement;
}
menuContext = null;
}
function isMenuTrigger(node) {
if (!node || !node.matches) {
return false;
}
if (node.matches(menuTriggerSelector)) {
return true;
}
return node.tagName === 'BUTTON' && !!node.querySelector('.material-icons.more_vert');
}
function findItemIdInAncestors(node) {
var current = node;
while (current && current !== document) {
var id = readItemIdFromNode(current);
if (id) {
return id;
}
current = current.parentElement;
}
return null;
}
/** The item whose menu is open right now, or null when we cannot tell. */
function resolveMenuItemId() {
if (!menuContext || (Date.now() - menuContext.ts) > 15000) {
return null;
}
if (menuContext.itemId) {
return menuContext.itemId;
}
// The detail page's own "..." button sits in the page header, outside any
// element carrying the item id, so the route is the item in that case.
return menuContext.isPageMenu ? (parseItemIdFromUrl() || findItemIdInDocument()) : null;
}
/**
* Returns true when the item is a movie, series, season or episode, false when
* it is anything else (a person, a studio, a library, a track), and null while
* the lookup is still in flight.
*/
function isShareableItem(itemId) {
var id = String(itemId || '').toLowerCase();
if (!isItemGuid(id)) {
return false;
}
if (Object.prototype.hasOwnProperty.call(itemTypeCache, id)) {
return shareableItemTypes.indexOf(itemTypeCache[id]) >= 0;
}
if (!itemTypeInFlight[id]) {
itemTypeInFlight[id] = fetchItemType(id);
}
return null;
}
function fetchItemType(id) {
return Promise.resolve()
.then(function () {
return ApiClient.getItem(ApiClient.getCurrentUserId(), id);
})
.then(function (item) {
itemTypeCache[id] = String((item && item.Type) || '').toLowerCase();
})
.catch(function () {
itemTypeCache[id] = '';
})
.finally(function () {
delete itemTypeInFlight[id];
scheduleWork();
});
}
function isCopyStreamUrlLabel(label) {
@@ -465,25 +569,6 @@
|| (value.indexOf('copier') >= 0 && value.indexOf('url') >= 0 && (value.indexOf('flux') >= 0 || value.indexOf('stream') >= 0));
}
function insertIntoOpenMenuFallback() {
var itemId = resolveItemId(document);
if (!itemId) {
return;
}
var container = findOpenActionContainer();
if (!container || container.querySelector('[' + injectedAttr + '="1"]')) {
return;
}
var template = findBestActionTemplate(container);
if (!template) {
return;
}
insertActionAfter(template, itemId, true);
}
function isItemMenuAction(node) {
if (!node || !node.getAttribute) {
return false;
@@ -535,12 +620,10 @@
function findBestActionTemplate(container) {
var actions = Array.from(container.querySelectorAll('.actionSheetMenuItem, .actionsheetMenuItem'))
.filter(isVisible);
for (var i = 0; i < actions.length; i += 1) {
if (isCopyStreamUrlLabel(getVisibleLabel(actions[i]))) {
return actions[i];
}
}
.filter(isVisible)
.filter(function (node) {
return node.getAttribute(injectedAttr) !== '1';
});
return actions.length ? actions[0] : null;
}
@@ -549,88 +632,117 @@
return !!(node && (node.offsetWidth || node.offsetHeight || node.getClientRects().length));
}
function insertActionAfter(copyNode, explicitItemId, insertAtTop) {
var parent = copyNode.parentElement;
if (!parent || parent.querySelector('[' + injectedAttr + '="1"]')) {
/**
* Appends the ShareLink action as its own section at the very bottom of the
* menu, separated from the native commands by the same divider Jellyfin
* renders between its own groups.
*/
function appendActionSection(container, itemId) {
var template = findBestActionTemplate(container);
if (!template) {
return;
}
var itemId = explicitItemId || resolveItemId(copyNode) || resolveItemId(document);
if (!itemId) {
var scroller = container.querySelector('.actionSheetScroller') || template.parentElement;
if (!scroller) {
return;
}
var sourceLabel = getVisibleLabel(copyNode);
var injected = copyNode.cloneNode(true);
injected.setAttribute(injectedAttr, '1');
injected.setAttribute('type', 'button');
injected.removeAttribute('id');
injected.removeAttribute('href');
injected.removeAttribute('onclick');
injected.removeAttribute('target');
injected.removeAttribute('download');
// The clone inherits the template's data-id (e.g. 'moreinfo'), which would duplicate
// an existing menu item's id and could shadow/trigger its command; strip it.
injected.removeAttribute('data-id');
injected.setAttribute('aria-label', actionLabel);
injected.setAttribute('title', actionLabel);
injected.dataset.sharelinksItemId = itemId;
var divider = document.createElement('div');
divider.className = 'actionsheetDivider';
divider.setAttribute(injectedAttr, '1');
var injectedIcon = injected.querySelector('.material-icons');
if (injectedIcon) {
injectedIcon.classList.remove('content_copy');
injectedIcon.classList.add('share');
var action = buildShareAction(template, itemId, container);
// Jellyfin puts its Cancel button in a trailing .buttons block inside the
// scroller; our section belongs after the last command but above that.
var cancelBlock = scroller.querySelector('.buttons');
if (cancelBlock) {
cancelBlock.insertAdjacentElement('beforebegin', divider);
divider.insertAdjacentElement('afterend', action);
return;
}
injected.addEventListener('click', function (event) {
scroller.appendChild(divider);
scroller.appendChild(action);
}
function buildShareAction(template, itemId, container) {
var action = template.cloneNode(true);
action.setAttribute(injectedAttr, '1');
action.setAttribute('type', 'button');
action.removeAttribute('id');
action.removeAttribute('href');
action.removeAttribute('onclick');
action.removeAttribute('target');
action.removeAttribute('download');
action.removeAttribute('autoFocus');
// The clone inherits the template's data-id (e.g. 'edit'), which would
// duplicate an existing menu item's id and let the action sheet run that
// command instead; strip it.
action.removeAttribute('data-id');
action.setAttribute('aria-label', actionLabel);
action.setAttribute('title', actionLabel);
action.dataset.sharelinksItemId = itemId;
setActionLabel(action);
setActionIcon(action);
action.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
closeActionSheet(container);
void createGuestLink(itemId);
}, true);
if (!replaceText(injected, sourceLabel, actionLabel)) {
injected.textContent = actionLabel;
}
if (insertAtTop) {
copyNode.insertAdjacentElement('beforebegin', injected);
} else {
copyNode.insertAdjacentElement('afterend', injected);
}
return action;
}
function replaceText(root, from, to) {
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
while (walker.nextNode()) {
var node = walker.currentNode;
if (normalizeText(node.nodeValue) === from) {
node.nodeValue = node.nodeValue.replace(from, to);
return true;
}
function setActionLabel(action) {
Array.from(action.querySelectorAll('.listItemBodyText.secondary, .listItemAside')).forEach(function (node) {
node.remove();
});
var text = action.querySelector('.actionSheetItemText') || action.querySelector('.listItemBodyText');
if (text) {
text.textContent = actionLabel;
return;
}
return false;
action.textContent = actionLabel;
}
function resolveItemId(node) {
var direct = parseItemIdFromUrl();
if (direct) {
return direct;
function setActionIcon(action) {
var icon = action.querySelector('.material-icons');
if (!icon) {
icon = document.createElement('span');
icon.setAttribute('aria-hidden', 'true');
action.insertBefore(icon, action.firstChild);
}
var current = node;
while (current && current !== document) {
var id = readItemIdFromNode(current);
if (id) {
return id;
}
current = current.parentElement;
icon.className = 'actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons share';
icon.style.removeProperty('visibility');
}
/**
* Dismisses the native action sheet so our dialog is not stacked on top of it.
* Clicking the sheet's own close button keeps Jellyfin's animation and state
* handling; Escape is the fallback when the sheet renders without one.
*/
function closeActionSheet(container) {
var closeButton = container.querySelector('.btnCloseActionSheet');
if (closeButton) {
closeButton.click();
return;
}
if (lastContextItemId && (Date.now() - lastContextItemTs) < 8000) {
return lastContextItemId;
}
return findItemIdInDocument();
container.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true
}));
}
function parseItemIdFromUrl() {